Vectrum
Sign In
422 lines · 14.9 KB · Go
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"
}