// The Code menu: everything the editor asks the language server about the // symbol under the cursor, and what it does with the answers. package app import ( "context" "errors" "fmt" "os" "path/filepath" "strings" "codeberg.org/turbo-editors/turbo-core/lsp" "codeberg.org/turbo-editors/turbo-core/ui" ) // GoToTypeDefinition opens the declaration of the *type* of the thing under // the cursor, which is a different question from where the thing itself is. func (a *App) GoToTypeDefinition() { a.askForLocations("Type definition", a.language.TypeDefinition) } // FindImplementations lists what implements the thing under the cursor: the // types satisfying an interface, the impl blocks of a trait. func (a *App) FindImplementations() { a.askForLocations("Implementations", a.language.Implementation) } // FindReferences lists where the thing under the cursor is used. func (a *App) FindReferences() { a.askForLocations("References", a.language.References) } // locationQuestion is any of the four requests that answer with places in the // code. They differ only in their name, so they are asked the same way. type locationQuestion func(ctx context.Context, path string, line, column int, lineText string) ([]lsp.Location, error) // askForLocations puts one question to the server and shows the answer: it // jumps straight to a single result, offers a list for several, and says so // for none. // // The title doubles as the word used in the messages, so a question that // finds nothing says "No references found" rather than something generic. func (a *App) askForLocations(title string, ask locationQuestion) { view := a.activeView() if view == nil { return } buf := view.Buffer() cursor := buf.Cursor() locations, err := ask(context.Background(), buf.Path(), cursor.Line, cursor.Col, buf.Line(cursor.Line)) a.showLocations(title, locations, err) } // showLocations is what every location question does with its answer. // // The three outcomes are deliberately distinct. A server that has not finished // loading says so, because "nothing found" and "I cannot answer yet" look // identical to a user and only one of them is worth waiting out — it is the // most confusing way completion fails, and this would have inherited it. func (a *App) showLocations(title string, locations []lsp.Location, err error) { switch { case errors.Is(err, lsp.ErrNotReady): a.Message(a.language.Status()) case err != nil: a.Message(strings.ToLower(title) + ": " + err.Error()) case len(locations) == 0: a.Message("No " + strings.ToLower(title) + " found") case len(locations) == 1: a.jumpTo(locations[0]) default: a.chooseLocation(title, locations) } } // chooseLocation offers a list of places and goes to the one chosen. // // This exists because a single answer is the exception rather than the rule: a // Go interface has as many definitions as it has implementations, and until // this was written GoToDefinition took locations[0] and threw the rest away. func (a *App) chooseLocation(title string, locations []lsp.Location) { dialog, list := NewChoiceDialog(fmt.Sprintf("%s (%d)", title, len(locations)), a.locationLabels(locations), 0, a.screenRect()) a.pushModal(dialog, func(result ui.Result) { if result == ui.ResultOK && list.Selected() >= 0 { a.jumpTo(locations[list.Selected()]) } }) } // locationLabels is how a list of places reads: the file, the line, and the // text of that line. // // The line's text is what makes the list usable — twelve entries reading // "handler.go:42" say nothing about which one anybody wants. Each file is read // once however many places are in it, because a symbol used forty times is // used forty times in a handful of files. func (a *App) locationLabels(locations []lsp.Location) []string { lines := map[string][]string{} labels := make([]string, 0, len(locations)) for _, location := range locations { path := lsp.URIToPath(location.URI) if _, read := lines[path]; !read { lines[path] = a.linesOf(path) } line := location.Range.Start.Line label := fmt.Sprintf("%s:%d", filepath.Base(path), line+1) if line >= 0 && line < len(lines[path]) { if text := strings.TrimSpace(lines[path][line]); text != "" { label += " " + text } } labels = append(labels, label) } return labels } // linesOf returns a file's lines, from an open window when there is one and // from the disk otherwise. // // The window comes first because it is the truth: a file edited and not yet // saved would otherwise be listed with the text it used to have, and the line // numbers beside it would be the server's — which follow the edits. // // A file that cannot be read gives no lines rather than an error. The list is // still useful without the text, and a dialog that refuses to open because one // of forty files moved would be worse than one with a bare line number in it. func (a *App) linesOf(path string) []string { if window := a.windowFor(path); window != nil { if view, ok := editorViewOf(window); ok { return strings.Split(view.Buffer().Text(), "\n") } } data, err := os.ReadFile(path) if err != nil { return nil } return strings.Split(string(data), "\n") }