package diag import ( "context" "encoding/json" "errors" "fmt" "os" "path/filepath" "strings" "time" ) // queryTimeout is how long one query — a definition, the references to a // symbol — may take, server start-up included. // // It is many times checkTimeout because the two are paid differently. A check // rides along on an edit the model did not ask to have checked, so it has to // be nearly free; a query is the model's own request, and it would rather wait // for gopls to finish loading the module than be told to go and grep. Thirty // seconds is also about what the first request against a cold gopls on a // large module genuinely needs. const queryTimeout = 30 * time.Second // Why a query never reached a server, or was refused by one. They are // exported because the tool tells the model something different for each: // install a program, try a different file, or give up on the server and grep. var ( // ErrDisabled is returned by a nil Runner, which is how the feature is // switched off for the session. ErrDisabled = errors.New("language servers are turned off for this session") // ErrNoServer means no server in the table handles the file's extension. ErrNoServer = errors.New("no language server for this file type") // ErrNotInstalled means a server handles the file but is not on PATH. ErrNotInstalled = errors.New("language server is not installed") // ErrStartFailed means the server is on PATH but would not start, or // recently would not and is on cooldown. ErrStartFailed = errors.New("language server could not be started") // ErrUnsupported means the server is up but did not offer this request // in its capabilities, or answered it with method-not-found. ErrUnsupported = errors.New("language server does not support this request") ) // Location is one place in a file. Line and Col are 1-based, like // Diagnostic's and like everything else the model reads. The LSP counts from // zero; the conversion in both directions lives in this file — position on // the way out, toLocation on the way back — and nowhere else. type Location struct { File string Line, Col int } // Symbol is one named thing a server knows about: a function, a type, a // field. Container is the enclosing symbol's name — the struct a method // belongs to, the module a class sits in — and "" at top level. type Symbol struct { Name string Kind string Container string Location } // Definition returns where the symbol at line:col in path is defined. An // empty slice with a nil error means the server found nothing there. func (r *Runner) Definition(ctx context.Context, path string, line, col int) ([]Location, error) { raw, err := r.at(ctx, path, line, col, "textDocument/definition", "definitionProvider", nil) if err != nil { return nil, err } return parseLocations(raw) } // References returns every use of the symbol at line:col in path, the // declaration included: the model asking "where is this used" wants the // declaration in the list so it can tell which one it is. func (r *Runner) References(ctx context.Context, path string, line, col int) ([]Location, error) { raw, err := r.at(ctx, path, line, col, "textDocument/references", "referencesProvider", map[string]any{ "context": map[string]any{"includeDeclaration": true}, }) if err != nil { return nil, err } return parseLocations(raw) } // Hover returns what the server shows on hover at line:col in path — the // signature and documentation, usually — as Markdown or plain text, or "" // when there is nothing there. func (r *Runner) Hover(ctx context.Context, path string, line, col int) (string, error) { raw, err := r.at(ctx, path, line, col, "textDocument/hover", "hoverProvider", nil) if err != nil { return "", err } return parseHover(raw) } // DocumentSymbols returns the outline of path: every symbol the server knows // in it, nested ones flattened with their Container set, in file order. func (r *Runner) DocumentSymbols(ctx context.Context, path string) ([]Symbol, error) { raw, err := r.query(ctx, path, "textDocument/documentSymbol", "documentSymbolProvider", true, func(uri string) any { return map[string]any{"textDocument": map[string]any{"uri": uri}} }) if err != nil { return nil, err } return parseSymbols(raw, path) } // WorkspaceSymbols returns symbols across the workspace whose names match // query, as the server understands matching — fuzzy, for most of them. path // chooses the server and the workspace; it is not opened. func (r *Runner) WorkspaceSymbols(ctx context.Context, path, query string) ([]Symbol, error) { raw, err := r.query(ctx, path, "workspace/symbol", "workspaceSymbolProvider", false, func(string) any { return map[string]any{"query": query} }) if err != nil { return nil, err } return parseSymbols(raw, "") } // at runs a request that takes a position: the shared shape of definition, // references and hover. extra is merged into the parameters. func (r *Runner) at(ctx context.Context, path string, line, col int, method, capability string, extra map[string]any) (json.RawMessage, error) { if line < 1 || col < 1 { return nil, fmt.Errorf("line and character are 1-based; got %d:%d", line, col) } return r.query(ctx, path, method, capability, true, func(uri string) any { params := map[string]any{ "textDocument": map[string]any{"uri": uri}, "position": position(line, col), } for k, v := range extra { params[k] = v } return params }) } // query finds the server for path and runs one request against it under // queryTimeout. params is given the file's URI because the path is made // absolute here, and the URI has to be built from that. open says whether // the file's current content is sent first; every request about a position // in a file needs that, since the model may have edited the file a moment // ago, and a workspace-wide one does not. func (r *Runner) query(ctx context.Context, path, method, capability string, open bool, params func(uri string) any) (json.RawMessage, error) { if r == nil { return nil, ErrDisabled } path, err := filepath.Abs(path) if err != nil { return nil, err } s, err := r.lookup(path) if err != nil { return nil, err } ctx, cancel := context.WithTimeout(ctx, queryTimeout) defer cancel() return s.request(ctx, method, capability, path, open, params) } // request is one query against this server: wait for it, refuse what it // cannot do, open the file if asked, ask. It holds the request slot // throughout, for the same reason Check does — a didOpen from here while a // check is collecting would land in that check's batch. func (s *session) request(ctx context.Context, method, capability, path string, open bool, params func(uri string) any) (json.RawMessage, error) { release, err := s.acquire(ctx) if err != nil { return nil, describe(s.name, err) } defer release() if !s.supports(capability) { return nil, fmt.Errorf("%w: %s has no %s", ErrUnsupported, s.name, method) } uri := fileURI(path) if open { content, err := os.ReadFile(path) if err != nil { return nil, err } if err := s.sendDocument(uri, path, string(content)); err != nil { return nil, describe(s.name, err) } } raw, err := s.call(ctx, method, params(uri)) if err != nil { var rpc *rpcError // -32601 is JSON-RPC's method-not-found: the server did not // implement the request after all, whatever its capabilities said. if errors.As(err, &rpc) && rpc.Code == -32601 { return nil, fmt.Errorf("%w: %s has no %s", ErrUnsupported, s.name, method) } return nil, describe(s.name, err) } return raw, nil } // describe prefixes a connection-level error with the server's name. The // errors from acquire and call are about "the server" in the abstract; by the // time the model reads one it needs to know which program to go and look at. func describe(name string, err error) error { if errors.Is(err, errServerDead) || errors.Is(err, context.DeadlineExceeded) { return fmt.Errorf("%s: %w", name, err) } return err } // supports reports whether the server offered the named capability. The // value is true, an options object, or absent or false; only the last two // mean no. func (s *session) supports(capability string) bool { v, ok := s.caps[capability] if !ok { return false } t := strings.TrimSpace(string(v)) return t != "false" && t != "null" } // position is a 1-based line and column as the LSP wants it. This and // toLocation are the only two places the two conventions meet. // // The column is passed through as a character count. The LSP's default unit // is UTF-16 code units, which agrees with characters for everything but // astral-plane text — an emoji in a string literal — and a column off by one // on such a line is a smaller wrong than the negotiation it would take to be // exact. func position(line, col int) map[string]any { return map[string]any{"line": line - 1, "character": col - 1} } // lspRange is the LSP Range shape, of which only the start is used: a // location is reported as one line:col, the way grep and compilers report. type lspRange struct { Start struct { Line int `json:"line"` Character int `json:"character"` } `json:"start"` } // toLocation converts a URI and range to a Location, adding the one the LSP // subtracts. A URI that is not a file — an untitled buffer, a jar: scheme — // gives an empty File and is dropped by the callers. func toLocation(uri string, rng lspRange) Location { return Location{ File: uriPath(uri), Line: rng.Start.Line + 1, Col: rng.Start.Character + 1, } } // parseLocations reads the result of a definition or references request, // which the spec lets be null, one Location, a list of them, or a list of // LocationLinks. The three are told apart by shape rather than by asking // the server which it sends, since the server that sends links is the one // that was told the client accepts them, and this client was not. func parseLocations(raw json.RawMessage) ([]Location, error) { t := strings.TrimSpace(string(raw)) if t == "" || t == "null" { return nil, nil } if strings.HasPrefix(t, "{") { raw = json.RawMessage("[" + t + "]") } var items []struct { URI string `json:"uri"` Range lspRange `json:"range"` TargetURI string `json:"targetUri"` TargetSelectionRange lspRange `json:"targetSelectionRange"` } if err := json.Unmarshal(raw, &items); err != nil { return nil, fmt.Errorf("unexpected result shape: %w", err) } out := make([]Location, 0, len(items)) for _, it := range items { var loc Location if it.TargetURI != "" { loc = toLocation(it.TargetURI, it.TargetSelectionRange) } else { loc = toLocation(it.URI, it.Range) } if loc.File != "" { out = append(out, loc) } } return out, nil } // parseHover reads a hover result, whose contents field has had three shapes // over the protocol's life: a string, a {language, value} pair, an array of // either, or a {kind, value} MarkupContent. Every shape ends up as text; a // language-tagged pair is fenced so it reads like the Markdown a modern // server would have sent. func parseHover(raw json.RawMessage) (string, error) { t := strings.TrimSpace(string(raw)) if t == "" || t == "null" { return "", nil } var h struct { Contents json.RawMessage `json:"contents"` } if err := json.Unmarshal(raw, &h); err != nil { return "", fmt.Errorf("unexpected result shape: %w", err) } c := strings.TrimSpace(string(h.Contents)) var parts []json.RawMessage if strings.HasPrefix(c, "[") { if err := json.Unmarshal(h.Contents, &parts); err != nil { return "", fmt.Errorf("unexpected result shape: %w", err) } } else if c != "" && c != "null" { parts = []json.RawMessage{h.Contents} } var texts []string for _, p := range parts { var s string if json.Unmarshal(p, &s) == nil { texts = append(texts, s) continue } var m struct { Language string `json:"language"` Value string `json:"value"` } if json.Unmarshal(p, &m) == nil && m.Value != "" { if m.Language != "" { m.Value = "```" + m.Language + "\n" + m.Value + "\n```" } texts = append(texts, m.Value) } } return strings.TrimSpace(strings.Join(texts, "\n\n")), nil } // parseSymbols reads a documentSymbol or workspace/symbol result. A document // outline comes as nested DocumentSymbols when hierarchy was negotiated and // as flat SymbolInformation otherwise; a workspace search comes as // SymbolInformation or WorkspaceSymbol, which differ only in whether the // location carries a range. One decoder covers all four by reading every // field any of them has. file is the document for shapes that do not name // one. func parseSymbols(raw json.RawMessage, file string) ([]Symbol, error) { t := strings.TrimSpace(string(raw)) if t == "" || t == "null" { return nil, nil } var items []rawSymbol if err := json.Unmarshal(raw, &items); err != nil { return nil, fmt.Errorf("unexpected result shape: %w", err) } var out []Symbol for _, it := range items { out = it.flatten(out, file, "") } return out, nil } // rawSymbol is the union of the LSP's symbol shapes. type rawSymbol struct { Name string `json:"name"` Kind int `json:"kind"` ContainerName string `json:"containerName"` // DocumentSymbol fields. SelectionRange *lspRange `json:"selectionRange"` Range *lspRange `json:"range"` Children []rawSymbol `json:"children"` // SymbolInformation and WorkspaceSymbol fields. Location *struct { URI string `json:"uri"` Range *lspRange `json:"range"` } `json:"location"` } // flatten appends this symbol and, depth first, its children to out. The // selection range is preferred to the full range: it is the name itself, // which is where a model wanting to jump there should land, rather than the // doc comment above it. func (r rawSymbol) flatten(out []Symbol, file, container string) []Symbol { sym := Symbol{Name: r.Name, Kind: symbolKind(r.Kind), Container: container} if r.ContainerName != "" { sym.Container = r.ContainerName } switch { case r.Location != nil: if r.Location.Range != nil { sym.Location = toLocation(r.Location.URI, *r.Location.Range) } else { sym.File = uriPath(r.Location.URI) } case r.SelectionRange != nil: sym.Location = toLocation(fileURI(file), *r.SelectionRange) case r.Range != nil: sym.Location = toLocation(fileURI(file), *r.Range) default: sym.File = file } if sym.Name != "" { out = append(out, sym) } for _, c := range r.Children { out = c.flatten(out, file, r.Name) } return out } // symbolKinds are the LSP's SymbolKind numbers as short lower-case words, // index one being File. The wire uses numbers so the names are chosen here, // and chosen short because they lead every line of an outline. var symbolKinds = [...]string{ "", "file", "module", "namespace", "package", "class", "method", "property", "field", "constructor", "enum", "interface", "func", "var", "const", "string", "number", "bool", "array", "object", "key", "null", "enum member", "struct", "event", "operator", "type param", } func symbolKind(k int) string { if k > 0 && k < len(symbolKinds) { return symbolKinds[k] } return "symbol" }