| 🛟 Updated. 28d5985 k33g 11h ago | 1 | package lsp |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "fmt" |
| 8 | ) |
| 9 | |
| 10 | // Symbol is a named thing in the code, flattened to the one shape the editor |
| 11 | // draws: a name, what sort of thing it is, where it lives, and — for a symbol |
| 12 | // found in a project rather than in the file in front of you — what contains |
| 13 | // it. |
| 14 | // |
| 15 | // symbols, err := client.DocumentSymbols(ctx, "main.go") |
| 16 | // for _, s := range symbols { |
| 17 | // fmt.Println(s.Kind, s.Name, s.Location.URI) |
| 18 | // } |
| 19 | // |
| 20 | // The protocol has three shapes for this and the editor wants one. Which |
| 21 | // shape arrives depends on the server and on the request, not on anything the |
| 22 | // caller can choose, so the flattening happens here rather than in every |
| 23 | // caller. |
| 24 | type Symbol struct { |
| 25 | // Name is the symbol itself: "ServeHTTP", "Config". |
| 26 | Name string |
| 27 | // Kind says what sort of thing it is, in the same numbering completions |
| 28 | // use for the overlapping values. |
| 29 | Kind SymbolKind |
| 30 | // Container is what the symbol is inside — a type for a method, a package |
| 31 | // for a type — when the server says. Empty otherwise. |
| 32 | Container string |
| 33 | // Depth is how deeply nested the symbol was in the answer, so a caller can |
| 34 | // indent a file's outline. Zero for a flat answer. |
| 35 | Depth int |
| 36 | // Location is where to go to reach it. |
| 37 | Location Location |
| 38 | } |
| 39 | |
| 40 | // SymbolKind says what sort of thing a symbol is. The values are the |
| 41 | // protocol's own, and overlap with CompletionItemKind only by coincidence — |
| 42 | // they are separate numberings in the specification, so they are separate |
| 43 | // types here. |
| 44 | type SymbolKind int |
| 45 | |
| 46 | // The kinds a language server actually returns for source code. |
| 47 | const ( |
| 48 | SymbolFile SymbolKind = 1 |
| 49 | SymbolModule SymbolKind = 2 |
| 50 | SymbolNamespace SymbolKind = 3 |
| 51 | SymbolPackage SymbolKind = 4 |
| 52 | SymbolClass SymbolKind = 5 |
| 53 | SymbolMethod SymbolKind = 6 |
| 54 | SymbolProperty SymbolKind = 7 |
| 55 | SymbolField SymbolKind = 8 |
| 56 | SymbolConstructor SymbolKind = 9 |
| 57 | SymbolEnum SymbolKind = 10 |
| 58 | SymbolInterface SymbolKind = 11 |
| 59 | SymbolFunction SymbolKind = 12 |
| 60 | SymbolVariable SymbolKind = 13 |
| 61 | SymbolConstant SymbolKind = 14 |
| 62 | SymbolStruct SymbolKind = 23 |
| 63 | SymbolEnumMember SymbolKind = 22 |
| 64 | SymbolTypeParameter SymbolKind = 26 |
| 65 | ) |
| 66 | |
| 67 | // symbolKindNames are the short tags shown beside a symbol, in the same spirit |
| 68 | // as the ones beside a completion: enough to tell a method from a field, no |
| 69 | // more. |
| 70 | var symbolKindNames = map[SymbolKind]string{ |
| 71 | SymbolModule: "module", |
| 72 | SymbolNamespace: "namespace", |
| 73 | SymbolPackage: "package", |
| 74 | SymbolClass: "class", |
| 75 | SymbolMethod: "method", |
| 76 | SymbolProperty: "property", |
| 77 | SymbolField: "field", |
| 78 | SymbolConstructor: "new", |
| 79 | SymbolEnum: "enum", |
| 80 | SymbolInterface: "interface", |
| 81 | SymbolFunction: "func", |
| 82 | SymbolVariable: "var", |
| 83 | SymbolConstant: "const", |
| 84 | SymbolStruct: "struct", |
| 85 | SymbolEnumMember: "case", |
| 86 | SymbolTypeParameter: "type param", |
| 87 | } |
| 88 | |
| 89 | // String returns the short tag for a kind, or "" for one with no name worth |
| 90 | // showing. A kind with no tag is drawn without one rather than as a number. |
| 91 | // |
| 92 | // lsp.SymbolMethod.String() // "method" |
| 93 | func (k SymbolKind) String() string { return symbolKindNames[k] } |
| 94 | |
| 95 | // DocumentSymbols returns the symbols declared in one file, in the order the |
| 96 | // server gives them — which is the order they appear in the file. |
| 97 | // |
| 98 | // symbols, err := client.DocumentSymbols(ctx, "main.go") |
| 99 | // |
| 100 | // Nested symbols are flattened, and each carries the Depth it was found at, so |
| 101 | // a caller can show a method under its type without walking a tree. |
| 102 | func (c *Client) DocumentSymbols(ctx context.Context, path string) ([]Symbol, error) { |
| 103 | if !c.Ready() { |
| 104 | return nil, ErrNotReady |
| 105 | } |
| 106 | ctx, cancel := context.WithTimeout(ctx, RequestTimeout) |
| 107 | defer cancel() |
| 108 | |
| 109 | params := map[string]any{"textDocument": TextDocumentIdentifier{URI: PathToURI(path)}} |
| 110 | var raw json.RawMessage |
| 111 | if err := c.conn.Call(ctx, "textDocument/documentSymbol", params, &raw); err != nil { |
| 112 | return nil, err |
| 113 | } |
| 114 | return decodeDocumentSymbols(raw, PathToURI(path)) |
| 115 | } |
| 116 | |
| 117 | // WorkspaceSymbols searches the whole project for symbols matching a query. |
| 118 | // |
| 119 | // symbols, err := client.WorkspaceSymbols(ctx, "ServeHTTP") |
| 120 | // |
| 121 | // What "matching" means belongs to the server: gopls does a fuzzy match, |
| 122 | // others a prefix. An empty query is the server's business too — some answer |
| 123 | // with everything, some with nothing — so it is passed through rather than |
| 124 | // refused here. |
| 125 | func (c *Client) WorkspaceSymbols(ctx context.Context, query string) ([]Symbol, error) { |
| 126 | if !c.Ready() { |
| 127 | return nil, ErrNotReady |
| 128 | } |
| 129 | ctx, cancel := context.WithTimeout(ctx, RequestTimeout) |
| 130 | defer cancel() |
| 131 | |
| 132 | var raw json.RawMessage |
| 133 | if err := c.conn.Call(ctx, "workspace/symbol", map[string]any{"query": query}, &raw); err != nil { |
| 134 | return nil, err |
| 135 | } |
| 136 | return decodeWorkspaceSymbols(raw) |
| 137 | } |
| 138 | |
| 139 | // documentSymbolNode is the nested shape: a symbol with its children, no URI, |
| 140 | // and two ranges — the whole declaration and just the name. |
| 141 | type documentSymbolNode struct { |
| 142 | Name string `json:"name"` |
| 143 | Kind SymbolKind `json:"kind"` |
| 144 | Detail string `json:"detail"` |
| 145 | Range Range `json:"range"` |
| 146 | SelectionRange Range `json:"selectionRange"` |
| 147 | Children []documentSymbolNode `json:"children"` |
| 148 | } |
| 149 | |
| 150 | // symbolInformation is the flat shape: a symbol with a full location and, |
| 151 | // sometimes, the name of what contains it. |
| 152 | type symbolInformation struct { |
| 153 | Name string `json:"name"` |
| 154 | Kind SymbolKind `json:"kind"` |
| 155 | ContainerName string `json:"containerName"` |
| 156 | Location Location `json:"location"` |
| 157 | } |
| 158 | |
| 159 | // decodeDocumentSymbols reads either shape a documentSymbol answer may take. |
| 160 | // |
| 161 | // Which one arrives depends on the server: the nested DocumentSymbol[] is |
| 162 | // preferred by the specification and is what gopls and rust-analyzer send, but |
| 163 | // the flat SymbolInformation[] is still legal and older servers send it. They |
| 164 | // are told apart by a field only one of them has, because both are arrays of |
| 165 | // objects with a name and a kind. |
| 166 | // |
| 167 | // The nested shape carries no URI — every symbol is in the file that was |
| 168 | // asked about — so the caller's own URI is put back in. |
| 169 | func decodeDocumentSymbols(raw json.RawMessage, uri string) ([]Symbol, error) { |
| 170 | trimmed := bytes.TrimSpace(raw) |
| 171 | if len(trimmed) == 0 || string(trimmed) == "null" { |
| 172 | return nil, nil |
| 173 | } |
| 174 | |
| 175 | if nested, ok := looksNested(trimmed); ok { |
| 176 | var nodes []documentSymbolNode |
| 177 | if err := json.Unmarshal(nested, &nodes); err != nil { |
| 178 | return nil, fmt.Errorf("lsp: unreadable document symbols: %w", err) |
| 179 | } |
| 180 | return flattenSymbols(nodes, uri, 0, nil), nil |
| 181 | } |
| 182 | |
| 183 | var flat []symbolInformation |
| 184 | if err := json.Unmarshal(trimmed, &flat); err != nil { |
| 185 | return nil, fmt.Errorf("lsp: unreadable document symbols: %w", err) |
| 186 | } |
| 187 | return fromSymbolInformation(flat), nil |
| 188 | } |
| 189 | |
| 190 | // looksNested reports whether an array of symbols is the nested shape, by |
| 191 | // looking for the one field only that shape has. |
| 192 | // |
| 193 | // "selectionRange" is the discriminator rather than "children" because |
| 194 | // children is optional and a file whose symbols happen to have none would |
| 195 | // otherwise be read as flat — and then every symbol would lose its position. |
| 196 | func looksNested(raw json.RawMessage) (json.RawMessage, bool) { |
| 197 | var probe []map[string]json.RawMessage |
| 198 | if err := json.Unmarshal(raw, &probe); err != nil || len(probe) == 0 { |
| 199 | return raw, false |
| 200 | } |
| 201 | _, nested := probe[0]["selectionRange"] |
| 202 | return raw, nested |
| 203 | } |
| 204 | |
| 205 | // flattenSymbols walks the nested shape depth-first, which is the order the |
| 206 | // symbols appear in the file. |
| 207 | // |
| 208 | // The position used is the **selection** range — the name — not the whole |
| 209 | // declaration: jumping to a function should put the cursor on its name, not on |
| 210 | // the doc comment above it. |
| 211 | func flattenSymbols(nodes []documentSymbolNode, uri string, depth int, container []string) []Symbol { |
| 212 | var out []Symbol |
| 213 | for _, node := range nodes { |
| 214 | where := node.SelectionRange |
| 215 | if where == (Range{}) { |
| 216 | where = node.Range |
| 217 | } |
| 218 | out = append(out, Symbol{ |
| 219 | Name: node.Name, |
| 220 | Kind: node.Kind, |
| 221 | Container: joinContainer(container), |
| 222 | Depth: depth, |
| 223 | Location: Location{URI: uri, Range: where}, |
| 224 | }) |
| 225 | out = append(out, flattenSymbols(node.Children, uri, depth+1, append(container, node.Name))...) |
| 226 | } |
| 227 | return out |
| 228 | } |
| 229 | |
| 230 | // joinContainer names what a nested symbol sits inside, innermost last. |
| 231 | func joinContainer(names []string) string { |
| 232 | if len(names) == 0 { |
| 233 | return "" |
| 234 | } |
| 235 | return names[len(names)-1] |
| 236 | } |
| 237 | |
| 238 | // decodeWorkspaceSymbols reads a workspace/symbol answer. |
| 239 | // |
| 240 | // Since 3.17 a server may answer with a WorkspaceSymbol whose location is |
| 241 | // `{"uri": …}` and no range at all, meaning "I know which file, ask me later |
| 242 | // for where". There is no "ask me later" here, so such a symbol is kept with a |
| 243 | // zero range: the top of the right file is a better answer than nothing. |
| 244 | func decodeWorkspaceSymbols(raw json.RawMessage) ([]Symbol, error) { |
| 245 | trimmed := bytes.TrimSpace(raw) |
| 246 | if len(trimmed) == 0 || string(trimmed) == "null" { |
| 247 | return nil, nil |
| 248 | } |
| 249 | |
| 250 | var flat []symbolInformation |
| 251 | if err := json.Unmarshal(trimmed, &flat); err != nil { |
| 252 | return nil, fmt.Errorf("lsp: unreadable workspace symbols: %w", err) |
| 253 | } |
| 254 | return fromSymbolInformation(flat), nil |
| 255 | } |
| 256 | |
| 257 | // fromSymbolInformation turns the flat shape into the editor's own. |
| 258 | func fromSymbolInformation(flat []symbolInformation) []Symbol { |
| 259 | out := make([]Symbol, 0, len(flat)) |
| 260 | for _, symbol := range flat { |
| 261 | out = append(out, Symbol{ |
| 262 | Name: symbol.Name, |
| 263 | Kind: symbol.Kind, |
| 264 | Container: symbol.ContainerName, |
| 265 | Location: symbol.Location, |
| 266 | }) |
| 267 | } |
| 268 | return out |
| 269 | } |