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"
}