turbo-editors/turbo-corepublic Fork 0
28d59854361aeda8541d853093e732126f3d7bff
Commits
Clone
git clone https://git.rickub.com/turbo-editors/turbo-core.git
git clone ssh://git@rickub.com/turbo-editors/turbo-core.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

🛟 Updated. 28d5985 · on 28d59854361aeda8541d853093e732126f3d7bff · k33g · 16h ago
symbol.go · 269 lines · 9.4 KBGo Blame HistoryRaw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
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
}