package tools
import (
"context"
"errors"
"fmt"
"os"
"sort"
"strings"
"github.com/johannSo/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)
}