package tools import ( "context" "errors" "fmt" "os" "sort" "strings" "codey/internal/diag" ) // LanguageServer is what the lsp tool asks about code. *diag.Runner // implements it; it is an interface here so the tool's formatting and its // error wording can be tested against a stub, the way edit_file's diagnostics // are, and so the tools package never starts a process of its own. type LanguageServer interface { Definition(ctx context.Context, path string, line, col int) ([]diag.Location, error) References(ctx context.Context, path string, line, col int) ([]diag.Location, error) Hover(ctx context.Context, path string, line, col int) (string, error) DocumentSymbols(ctx context.Context, path string) ([]diag.Symbol, error) WorkspaceSymbols(ctx context.Context, path, query string) ([]diag.Symbol, error) } // Caps on what one lsp call puts in the context window. // // Fifty locations is more references than a model acts on in one go — past // that it wants the count and a narrower question, not the list. Symbols get // more room because a file outline is only useful whole, and a hundred names // at a line each is still cheaper than reading the file. Hover text is capped // by lines because what runs long is a doc comment, and the first lines of a // doc comment are the ones that say what the thing is. const ( maxLSPLocations = 50 maxLSPSymbols = 100 maxHoverLines = 30 ) // LSPArgs is the lsp input. type LSPArgs struct { Operation string `json:"operation" jsonschema:"enum=definition,enum=references,enum=hover,enum=document_symbols,enum=workspace_symbols" jsonschema_description:"What to ask: definition (where the symbol at line:character is declared), references (everywhere it is used), hover (its signature and documentation), document_symbols (an outline of the file), workspace_symbols (symbols across the project whose name matches query)"` Path string `json:"path" jsonschema_description:"File to ask about, absolute or relative to the working root. For workspace_symbols it picks the language and project to search"` Line int `json:"line,omitempty" jsonschema_description:"1-based line of the symbol, as read_file numbers lines. Required for definition, references and hover"` Character int `json:"character,omitempty" jsonschema_description:"1-based column of the symbol on that line; anywhere within the name works. Required for definition, references and hover"` Query string `json:"query,omitempty" jsonschema_description:"Name, or part of one, to search for. Required for workspace_symbols"` } // NewLSP builds the lsp tool over ls. root anchors relative paths and jails // them; the language server itself may answer with files anywhere — the // standard library, a dependency — and those are shown as they are, since // reading them is exactly what the model would do next. // // It returns nil for a nil ls so wiring can register it unconditionally the // way NewSkill is. A typed nil *diag.Runner is not nil here; that case // answers every call with diag.ErrDisabled, which the tool relays. func NewLSP(root string, ls LanguageServer) Tool { if ls == nil { return nil } return New("lsp", strings.TrimSpace(` Ask the language server about code: where a symbol is defined, everywhere it is used, what it is (hover), the symbols in a file, or symbols across the project by name. Prefer this to grep for "where is this defined" and "where is this used" — grep matches text and this understands the code, so one call replaces a round of searches and false positives. Positions are 1-based, matching read_file's line numbers. Results are "path:line:col" with the source line. It needs a language server on PATH for the file's language (gopls, typescript-language-server, pyright-langserver, rust-analyzer); if there is none the result says so — fall back to grep.`), func(ctx context.Context, in LSPArgs) (string, error) { path, err := resolve(root, in.Path) if err != nil { return "", err } if info, err := os.Stat(path); err != nil { return "", fmt.Errorf("no such file: %s", in.Path) } else if info.IsDir() { return "", fmt.Errorf("%s is a directory; lsp needs a file, even for workspace_symbols, to know which language server to ask", in.Path) } where := fmt.Sprintf("%s:%d:%d", displayPath(root, path), in.Line, in.Character) switch in.Operation { case "definition", "references", "hover": if in.Line < 1 || in.Character < 1 { return "", fmt.Errorf("%s needs line and character, both 1-based (the first character of the first line is line 1, character 1); got %d:%d", in.Operation, in.Line, in.Character) } case "document_symbols": case "workspace_symbols": if strings.TrimSpace(in.Query) == "" { return "", fmt.Errorf("workspace_symbols needs query: the name, or part of the name, to look for") } case "": return "", fmt.Errorf("operation is required: one of definition, references, hover, document_symbols, workspace_symbols") default: return "", fmt.Errorf("unknown operation %q: use definition, references, hover, document_symbols or workspace_symbols", in.Operation) } var out string switch in.Operation { case "definition": locs, err := ls.Definition(ctx, path, in.Line, in.Character) if err != nil { return "", lspError(err) } if len(locs) == 0 { return fmt.Sprintf("No definition found for the symbol at %s. Check the position is on a name (not whitespace or an operator); anywhere within the name works.", where), nil } out = formatLocations(root, path, "definition", "definitions", where, locs) case "references": locs, err := ls.References(ctx, path, in.Line, in.Character) if err != nil { return "", lspError(err) } if len(locs) == 0 { return fmt.Sprintf("No references found for the symbol at %s. Check the position is on a name; anywhere within the name works.", where), nil } out = formatLocations(root, path, "reference", "references", where, locs) case "hover": text, err := ls.Hover(ctx, path, in.Line, in.Character) if err != nil { return "", lspError(err) } if text == "" { return fmt.Sprintf("Nothing to show for %s. Check the position is on a name; anywhere within the name works.", where), nil } out = fmt.Sprintf("%s:\n%s", where, trimHover(text)) case "document_symbols": syms, err := ls.DocumentSymbols(ctx, path) if err != nil { return "", lspError(err) } if len(syms) == 0 { return fmt.Sprintf("No symbols in %s.", displayPath(root, path)), nil } out = formatSymbols(root, fmt.Sprintf("%s in %s", plural(len(syms), "symbol"), displayPath(root, path)), syms, false) case "workspace_symbols": syms, err := ls.WorkspaceSymbols(ctx, path, in.Query) if err != nil { return "", lspError(err) } if len(syms) == 0 { return fmt.Sprintf("No symbols matching %q in the project around %s.", in.Query, displayPath(root, path)), nil } out = formatSymbols(root, fmt.Sprintf("%s matching %q", plural(len(syms), "symbol"), in.Query), syms, true) } return out, nil }) } // lspError turns a failure into the instruction the model needs. A missing or // broken server is the common case on a machine that has never installed one, // and it must read as "use grep" rather than as something to fix: the model // cannot install gopls, and trying the same call again would only fail again. // A timeout is the one failure worth a retry, because a server that is still // loading a module will answer the next time. func lspError(err error) error { switch { case errors.Is(err, diag.ErrDisabled): return fmt.Errorf("%v; use grep to find definitions and uses", err) case errors.Is(err, diag.ErrNoServer): return fmt.Errorf("%v; use grep instead", err) case errors.Is(err, diag.ErrNotInstalled): return fmt.Errorf("%v. Install it to use lsp for this language, or use grep instead", err) case errors.Is(err, diag.ErrUnsupported): return fmt.Errorf("%v; use grep instead", err) case errors.Is(err, context.DeadlineExceeded): return fmt.Errorf("%v: the language server did not answer in time. It may still be loading the project; try once more, and use grep if that also times out", err) case errors.Is(err, context.Canceled): return err default: return fmt.Errorf("%v; use grep instead", err) } } // formatLocations renders locations as grep does — "path:line:col: text" — // so the model reads them with the same eyes. The asked-about file comes // first, then everything else by path and position, so that when the list is // cut the model keeps the uses nearest to where it is working. The source // line is read from disk per location because it turns a bare position into // something the model can judge without another read_file. func formatLocations(root, asked, singular, pluralNoun, where string, locs []diag.Location) string { sort.SliceStable(locs, func(i, j int) bool { a, b := locs[i], locs[j] if (a.File == asked) != (b.File == asked) { return a.File == asked } if a.File != b.File { return a.File < b.File } if a.Line != b.Line { return a.Line < b.Line } return a.Col < b.Col }) shown := locs if len(shown) > maxLSPLocations { shown = shown[:maxLSPLocations] } var b strings.Builder if len(locs) == 1 { fmt.Fprintf(&b, "1 %s for the symbol at %s", singular, where) } else { fmt.Fprintf(&b, "%d %s for the symbol at %s", len(locs), pluralNoun, where) } lines := sourceLines{} for _, l := range shown { fmt.Fprintf(&b, "\n %s:%d:%d", displayPath(root, l.File), l.Line, l.Col) if text := lines.at(l.File, l.Line); text != "" { b.WriteString(": ") b.WriteString(text) } } if dropped := len(locs) - len(shown); dropped > 0 { fmt.Fprintf(&b, "\n … and %d more not shown; narrow the question or grep for the name", dropped) } return b.String() } // formatSymbols renders an outline or a search result, one "kind name" per // line with its position in front. withFile is set for a workspace search, // where the symbols come from many files; an outline is all one file, already // named in the heading, and repeating it on every line would double the // width for nothing. func formatSymbols(root, heading string, syms []diag.Symbol, withFile bool) string { shown := syms if len(shown) > maxLSPSymbols { shown = shown[:maxLSPSymbols] } var b strings.Builder b.WriteString(heading) for _, s := range shown { b.WriteString("\n ") if withFile { b.WriteString(displayPath(root, s.File)) if s.Line > 0 { fmt.Fprintf(&b, ":%d:%d", s.Line, s.Col) } b.WriteString(": ") } else if s.Line > 0 { fmt.Fprintf(&b, "%d:%d: ", s.Line, s.Col) } b.WriteString(s.Kind) b.WriteString(" ") if s.Container != "" { b.WriteString(s.Container) b.WriteString(".") } b.WriteString(s.Name) } if dropped := len(syms) - len(shown); dropped > 0 { fmt.Fprintf(&b, "\n … and %d more not shown", dropped) } return b.String() } // trimHover reduces a hover to what the model needs from it. Servers send // Markdown meant for a tooltip: a fenced signature, the doc comment, and for // gopls a trailing link to pkg.go.dev. The fences are removed because the // text is already inside a tool result and a nested fence confuses the // reading; the link is removed because the model cannot follow it; and the // whole is capped so a package-level doc comment does not become the result. func trimHover(text string) string { var lines []string for _, line := range strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") { t := strings.TrimSpace(line) if strings.HasPrefix(t, "```") { continue } if strings.HasPrefix(t, "[") && strings.Contains(t, "](http") && strings.HasSuffix(t, ")") { continue } if t == "---" { continue } if t == "" && (len(lines) == 0 || lines[len(lines)-1] == "") { continue } lines = append(lines, capLine(strings.TrimRight(line, " \t"))) } for len(lines) > 0 && lines[len(lines)-1] == "" { lines = lines[:len(lines)-1] } if len(lines) > maxHoverLines { dropped := len(lines) - maxHoverLines lines = append(lines[:maxHoverLines], fmt.Sprintf("… %d more lines not shown", dropped)) } return strings.Join(lines, "\n") } // sourceLines reads files once per call and hands out trimmed lines by // number. A references result names the same file many times over, and // re-reading it for each would make a fifty-hit answer fifty reads. type sourceLines map[string][]string func (c sourceLines) at(path string, line int) string { lines, ok := c[path] if !ok { data, err := os.ReadFile(path) if err == nil { lines = splitLines(string(data)) } c[path] = lines } if line < 1 || line > len(lines) { return "" } return capLine(strings.TrimSpace(lines[line-1])) } func plural(n int, noun string) string { if n == 1 { return fmt.Sprintf("1 %s", noun) } return fmt.Sprintf("%d %ss", n, noun) }