package lsp import ( "bytes" "context" "encoding/json" "fmt" ) // Symbol is a named thing in the code, flattened to the one shape the editor // draws: a name, what sort of thing it is, where it lives, and — for a symbol // found in a project rather than in the file in front of you — what contains // it. // // symbols, err := client.DocumentSymbols(ctx, "main.go") // for _, s := range symbols { // fmt.Println(s.Kind, s.Name, s.Location.URI) // } // // The protocol has three shapes for this and the editor wants one. Which // shape arrives depends on the server and on the request, not on anything the // caller can choose, so the flattening happens here rather than in every // caller. type Symbol struct { // Name is the symbol itself: "ServeHTTP", "Config". Name string // Kind says what sort of thing it is, in the same numbering completions // use for the overlapping values. Kind SymbolKind // Container is what the symbol is inside — a type for a method, a package // for a type — when the server says. Empty otherwise. Container string // Depth is how deeply nested the symbol was in the answer, so a caller can // indent a file's outline. Zero for a flat answer. Depth int // Location is where to go to reach it. Location Location } // SymbolKind says what sort of thing a symbol is. The values are the // protocol's own, and overlap with CompletionItemKind only by coincidence — // they are separate numberings in the specification, so they are separate // types here. type SymbolKind int // The kinds a language server actually returns for source code. const ( SymbolFile SymbolKind = 1 SymbolModule SymbolKind = 2 SymbolNamespace SymbolKind = 3 SymbolPackage SymbolKind = 4 SymbolClass SymbolKind = 5 SymbolMethod SymbolKind = 6 SymbolProperty SymbolKind = 7 SymbolField SymbolKind = 8 SymbolConstructor SymbolKind = 9 SymbolEnum SymbolKind = 10 SymbolInterface SymbolKind = 11 SymbolFunction SymbolKind = 12 SymbolVariable SymbolKind = 13 SymbolConstant SymbolKind = 14 SymbolStruct SymbolKind = 23 SymbolEnumMember SymbolKind = 22 SymbolTypeParameter SymbolKind = 26 ) // symbolKindNames are the short tags shown beside a symbol, in the same spirit // as the ones beside a completion: enough to tell a method from a field, no // more. var symbolKindNames = map[SymbolKind]string{ SymbolModule: "module", SymbolNamespace: "namespace", SymbolPackage: "package", SymbolClass: "class", SymbolMethod: "method", SymbolProperty: "property", SymbolField: "field", SymbolConstructor: "new", SymbolEnum: "enum", SymbolInterface: "interface", SymbolFunction: "func", SymbolVariable: "var", SymbolConstant: "const", SymbolStruct: "struct", SymbolEnumMember: "case", SymbolTypeParameter: "type param", } // String returns the short tag for a kind, or "" for one with no name worth // showing. A kind with no tag is drawn without one rather than as a number. // // lsp.SymbolMethod.String() // "method" func (k SymbolKind) String() string { return symbolKindNames[k] } // DocumentSymbols returns the symbols declared in one file, in the order the // server gives them — which is the order they appear in the file. // // symbols, err := client.DocumentSymbols(ctx, "main.go") // // Nested symbols are flattened, and each carries the Depth it was found at, so // a caller can show a method under its type without walking a tree. func (c *Client) DocumentSymbols(ctx context.Context, path string) ([]Symbol, error) { if !c.Ready() { return nil, ErrNotReady } ctx, cancel := context.WithTimeout(ctx, RequestTimeout) defer cancel() params := map[string]any{"textDocument": TextDocumentIdentifier{URI: PathToURI(path)}} var raw json.RawMessage if err := c.conn.Call(ctx, "textDocument/documentSymbol", params, &raw); err != nil { return nil, err } return decodeDocumentSymbols(raw, PathToURI(path)) } // WorkspaceSymbols searches the whole project for symbols matching a query. // // symbols, err := client.WorkspaceSymbols(ctx, "ServeHTTP") // // What "matching" means belongs to the server: gopls does a fuzzy match, // others a prefix. An empty query is the server's business too — some answer // with everything, some with nothing — so it is passed through rather than // refused here. func (c *Client) WorkspaceSymbols(ctx context.Context, query string) ([]Symbol, error) { if !c.Ready() { return nil, ErrNotReady } ctx, cancel := context.WithTimeout(ctx, RequestTimeout) defer cancel() var raw json.RawMessage if err := c.conn.Call(ctx, "workspace/symbol", map[string]any{"query": query}, &raw); err != nil { return nil, err } return decodeWorkspaceSymbols(raw) } // documentSymbolNode is the nested shape: a symbol with its children, no URI, // and two ranges — the whole declaration and just the name. type documentSymbolNode struct { Name string `json:"name"` Kind SymbolKind `json:"kind"` Detail string `json:"detail"` Range Range `json:"range"` SelectionRange Range `json:"selectionRange"` Children []documentSymbolNode `json:"children"` } // symbolInformation is the flat shape: a symbol with a full location and, // sometimes, the name of what contains it. type symbolInformation struct { Name string `json:"name"` Kind SymbolKind `json:"kind"` ContainerName string `json:"containerName"` Location Location `json:"location"` } // decodeDocumentSymbols reads either shape a documentSymbol answer may take. // // Which one arrives depends on the server: the nested DocumentSymbol[] is // preferred by the specification and is what gopls and rust-analyzer send, but // the flat SymbolInformation[] is still legal and older servers send it. They // are told apart by a field only one of them has, because both are arrays of // objects with a name and a kind. // // The nested shape carries no URI — every symbol is in the file that was // asked about — so the caller's own URI is put back in. func decodeDocumentSymbols(raw json.RawMessage, uri string) ([]Symbol, error) { trimmed := bytes.TrimSpace(raw) if len(trimmed) == 0 || string(trimmed) == "null" { return nil, nil } if nested, ok := looksNested(trimmed); ok { var nodes []documentSymbolNode if err := json.Unmarshal(nested, &nodes); err != nil { return nil, fmt.Errorf("lsp: unreadable document symbols: %w", err) } return flattenSymbols(nodes, uri, 0, nil), nil } var flat []symbolInformation if err := json.Unmarshal(trimmed, &flat); err != nil { return nil, fmt.Errorf("lsp: unreadable document symbols: %w", err) } return fromSymbolInformation(flat), nil } // looksNested reports whether an array of symbols is the nested shape, by // looking for the one field only that shape has. // // "selectionRange" is the discriminator rather than "children" because // children is optional and a file whose symbols happen to have none would // otherwise be read as flat — and then every symbol would lose its position. func looksNested(raw json.RawMessage) (json.RawMessage, bool) { var probe []map[string]json.RawMessage if err := json.Unmarshal(raw, &probe); err != nil || len(probe) == 0 { return raw, false } _, nested := probe[0]["selectionRange"] return raw, nested } // flattenSymbols walks the nested shape depth-first, which is the order the // symbols appear in the file. // // The position used is the **selection** range — the name — not the whole // declaration: jumping to a function should put the cursor on its name, not on // the doc comment above it. func flattenSymbols(nodes []documentSymbolNode, uri string, depth int, container []string) []Symbol { var out []Symbol for _, node := range nodes { where := node.SelectionRange if where == (Range{}) { where = node.Range } out = append(out, Symbol{ Name: node.Name, Kind: node.Kind, Container: joinContainer(container), Depth: depth, Location: Location{URI: uri, Range: where}, }) out = append(out, flattenSymbols(node.Children, uri, depth+1, append(container, node.Name))...) } return out } // joinContainer names what a nested symbol sits inside, innermost last. func joinContainer(names []string) string { if len(names) == 0 { return "" } return names[len(names)-1] } // decodeWorkspaceSymbols reads a workspace/symbol answer. // // Since 3.17 a server may answer with a WorkspaceSymbol whose location is // `{"uri": …}` and no range at all, meaning "I know which file, ask me later // for where". There is no "ask me later" here, so such a symbol is kept with a // zero range: the top of the right file is a better answer than nothing. func decodeWorkspaceSymbols(raw json.RawMessage) ([]Symbol, error) { trimmed := bytes.TrimSpace(raw) if len(trimmed) == 0 || string(trimmed) == "null" { return nil, nil } var flat []symbolInformation if err := json.Unmarshal(trimmed, &flat); err != nil { return nil, fmt.Errorf("lsp: unreadable workspace symbols: %w", err) } return fromSymbolInformation(flat), nil } // fromSymbolInformation turns the flat shape into the editor's own. func fromSymbolInformation(flat []symbolInformation) []Symbol { out := make([]Symbol, 0, len(flat)) for _, symbol := range flat { out = append(out, Symbol{ Name: symbol.Name, Kind: symbol.Kind, Container: symbol.ContainerName, Location: symbol.Location, }) } return out }