Skip to content

Commit ba9185b

Browse files
deirndummdidumm
andauthored
feat: support hierarchical document symbols (#2817)
Closes #1519 Return hierarchical DocumentSymbol, useful for breadcrumb feature in various clients. --------- Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com> Co-authored-by: Simon Holthausen <simon.holthausen@vercel.com>
1 parent 574c34d commit ba9185b

File tree

5 files changed

+222
-6
lines changed

5 files changed

+222
-6
lines changed

.changeset/dull-bees-hang.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte-language-server': patch
3+
---
4+
5+
feat: support hierarchical document symbols

.changeset/old-carrots-rescue.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
"svelte-check": patch
2+
'svelte-check': patch
33
---
44

55
chore: use machine format when run by Claude Code

packages/language-server/src/plugins/PluginHost.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ import {
3535
TextEdit,
3636
WorkspaceEdit,
3737
InlayHint,
38-
WorkspaceSymbol
38+
WorkspaceSymbol,
39+
DocumentSymbol
3940
} from 'vscode-languageserver';
4041
import { DocumentManager, getNodeIfIsInHTMLStartTag } from '../lib/documents';
4142
import { Logger } from '../logger';
@@ -307,6 +308,7 @@ export class PluginHost implements LSProvider, OnWatchFileChanges {
307308
if (cancellationToken.isCancellationRequested) {
308309
return [];
309310
}
311+
310312
return flatten(
311313
await this.execute<SymbolInformation[]>(
312314
'getDocumentSymbols',
@@ -317,6 +319,63 @@ export class PluginHost implements LSProvider, OnWatchFileChanges {
317319
);
318320
}
319321

322+
private comparePosition(pos1: Position, pos2: Position) {
323+
if (pos1.line < pos2.line) return -1;
324+
if (pos1.line > pos2.line) return 1;
325+
if (pos1.character < pos2.character) return -1;
326+
if (pos1.character > pos2.character) return 1;
327+
return 0;
328+
}
329+
330+
private rangeContains(parent: Range, child: Range) {
331+
return (
332+
this.comparePosition(parent.start, child.start) <= 0 &&
333+
this.comparePosition(child.end, parent.end) <= 0
334+
);
335+
}
336+
337+
async getHierarchicalDocumentSymbols(
338+
textDocument: TextDocumentIdentifier,
339+
cancellationToken: CancellationToken
340+
): Promise<DocumentSymbol[]> {
341+
const flat = await this.getDocumentSymbols(textDocument, cancellationToken);
342+
const symbols = flat
343+
.map((s) =>
344+
DocumentSymbol.create(
345+
s.name,
346+
undefined,
347+
s.kind,
348+
s.location.range,
349+
s.location.range,
350+
[]
351+
)
352+
)
353+
.sort((a, b) => {
354+
const start = this.comparePosition(a.range.start, b.range.start);
355+
if (start !== 0) return start;
356+
return this.comparePosition(b.range.end, a.range.end);
357+
});
358+
359+
const stack: DocumentSymbol[] = [];
360+
const roots: DocumentSymbol[] = [];
361+
362+
for (const node of symbols) {
363+
while (stack.length > 0 && !this.rangeContains(stack.at(-1)!.range, node.range)) {
364+
stack.pop();
365+
}
366+
367+
if (stack.length > 0) {
368+
stack.at(-1)!.children!.push(node);
369+
} else {
370+
roots.push(node);
371+
}
372+
373+
stack.push(node);
374+
}
375+
376+
return roots;
377+
}
378+
320379
async getDefinitions(
321380
textDocument: TextDocumentIdentifier,
322381
position: Position

packages/language-server/src/server.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -429,9 +429,16 @@ export function startServer(options?: LSOptions) {
429429
connection.onColorPresentation((evt) =>
430430
pluginHost.getColorPresentations(evt.textDocument, evt.range, evt.color)
431431
);
432-
connection.onDocumentSymbol((evt, cancellationToken) =>
433-
pluginHost.getDocumentSymbols(evt.textDocument, cancellationToken)
434-
);
432+
connection.onDocumentSymbol((evt, cancellationToken) => {
433+
if (
434+
configManager.getClientCapabilities()?.textDocument?.documentSymbol
435+
?.hierarchicalDocumentSymbolSupport
436+
) {
437+
return pluginHost.getHierarchicalDocumentSymbols(evt.textDocument, cancellationToken);
438+
} else {
439+
return pluginHost.getDocumentSymbols(evt.textDocument, cancellationToken);
440+
}
441+
});
435442
connection.onDefinition((evt) => pluginHost.getDefinitions(evt.textDocument, evt.position));
436443
connection.onReferences((evt, cancellationToken) =>
437444
pluginHost.findReferences(evt.textDocument, evt.position, evt.context, cancellationToken)

packages/language-server/test/plugins/PluginHost.test.ts

Lines changed: 146 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
import sinon from 'sinon';
22
import {
33
CompletionItem,
4+
DocumentSymbol,
45
Location,
56
LocationLink,
67
Position,
78
Range,
9+
SymbolInformation,
10+
SymbolKind,
811
TextDocumentItem
912
} from 'vscode-languageserver-types';
1013
import { DocumentManager, Document } from '../../src/lib/documents';
1114
import { LSPProviderConfig, PluginHost } from '../../src/plugins';
12-
import { CompletionTriggerKind } from 'vscode-languageserver';
15+
import { CompletionTriggerKind, CancellationToken } from 'vscode-languageserver';
1316
import assert from 'assert';
1417

1518
describe('PluginHost', () => {
@@ -187,4 +190,146 @@ describe('PluginHost', () => {
187190
]);
188191
});
189192
});
193+
194+
describe('getHierarchicalDocumentSymbols', () => {
195+
it('converts flat symbols to hierarchical structure', async () => {
196+
const cancellation_token: CancellationToken = {
197+
isCancellationRequested: false,
198+
onCancellationRequested: () => ({ dispose: () => {} })
199+
};
200+
201+
const flat_symbols: SymbolInformation[] = [
202+
// Root level class (lines 0-10)
203+
SymbolInformation.create(
204+
'MyClass',
205+
SymbolKind.Class,
206+
Range.create(Position.create(0, 0), Position.create(10, 0)),
207+
'file:///hello.svelte'
208+
),
209+
// Method inside class (lines 1-5)
210+
SymbolInformation.create(
211+
'myMethod',
212+
SymbolKind.Method,
213+
Range.create(Position.create(1, 0), Position.create(5, 0)),
214+
'file:///hello.svelte'
215+
),
216+
// Variable inside method (lines 2-3)
217+
SymbolInformation.create(
218+
'localVar',
219+
SymbolKind.Variable,
220+
Range.create(Position.create(2, 0), Position.create(3, 0)),
221+
'file:///hello.svelte'
222+
),
223+
// Another method in class (lines 6-8)
224+
SymbolInformation.create(
225+
'anotherMethod',
226+
SymbolKind.Method,
227+
Range.create(Position.create(6, 0), Position.create(8, 0)),
228+
'file:///hello.svelte'
229+
),
230+
// Root level function (lines 12-15)
231+
SymbolInformation.create(
232+
'topLevelFunction',
233+
SymbolKind.Function,
234+
Range.create(Position.create(12, 0), Position.create(15, 0)),
235+
'file:///hello.svelte'
236+
)
237+
];
238+
239+
const { docManager, pluginHost } = setup({
240+
getDocumentSymbols: sinon.stub().returns(flat_symbols)
241+
});
242+
docManager.openClientDocument(textDocument);
243+
244+
const result = await pluginHost.getHierarchicalDocumentSymbols(
245+
textDocument,
246+
cancellation_token
247+
);
248+
249+
// Should have 2 root symbols: MyClass and topLevelFunction
250+
assert.strictEqual(result.length, 2);
251+
252+
// Check first root symbol (MyClass)
253+
assert.strictEqual(result[0].name, 'MyClass');
254+
assert.strictEqual(result[0].kind, SymbolKind.Class);
255+
assert.strictEqual(result[0].children?.length, 2);
256+
257+
// Check children of MyClass
258+
assert.strictEqual(result[0].children![0].name, 'myMethod');
259+
assert.strictEqual(result[0].children![0].kind, SymbolKind.Method);
260+
assert.strictEqual(result[0].children![0].children?.length, 1);
261+
262+
// Check nested child (localVar inside myMethod)
263+
assert.strictEqual(result[0].children![0].children![0].name, 'localVar');
264+
assert.strictEqual(result[0].children![0].children![0].kind, SymbolKind.Variable);
265+
assert.strictEqual(result[0].children![0].children![0].children?.length, 0);
266+
267+
// Check second child of MyClass
268+
assert.strictEqual(result[0].children![1].name, 'anotherMethod');
269+
assert.strictEqual(result[0].children![1].kind, SymbolKind.Method);
270+
assert.strictEqual(result[0].children![1].children?.length, 0);
271+
272+
// Check second root symbol (topLevelFunction)
273+
assert.strictEqual(result[1].name, 'topLevelFunction');
274+
assert.strictEqual(result[1].kind, SymbolKind.Function);
275+
assert.strictEqual(result[1].children?.length, 0);
276+
});
277+
278+
it('handles empty symbol list', async () => {
279+
const cancellation_token: CancellationToken = {
280+
isCancellationRequested: false,
281+
onCancellationRequested: () => ({ dispose: () => {} })
282+
};
283+
284+
const { docManager, pluginHost } = setup({
285+
getDocumentSymbols: sinon.stub().returns([])
286+
});
287+
docManager.openClientDocument(textDocument);
288+
289+
const result = await pluginHost.getHierarchicalDocumentSymbols(
290+
textDocument,
291+
cancellation_token
292+
);
293+
294+
assert.deepStrictEqual(result, []);
295+
});
296+
297+
it('handles symbols with same start position', async () => {
298+
const cancellation_token: CancellationToken = {
299+
isCancellationRequested: false,
300+
onCancellationRequested: () => ({ dispose: () => {} })
301+
};
302+
303+
const flat_symbols: SymbolInformation[] = [
304+
// Two symbols starting at same position, longer one should be parent
305+
SymbolInformation.create(
306+
'outer',
307+
SymbolKind.Class,
308+
Range.create(Position.create(0, 0), Position.create(10, 0)),
309+
'file:///hello.svelte'
310+
),
311+
SymbolInformation.create(
312+
'inner',
313+
SymbolKind.Method,
314+
Range.create(Position.create(0, 0), Position.create(5, 0)),
315+
'file:///hello.svelte'
316+
)
317+
];
318+
319+
const { docManager, pluginHost } = setup({
320+
getDocumentSymbols: sinon.stub().returns(flat_symbols)
321+
});
322+
docManager.openClientDocument(textDocument);
323+
324+
const result = await pluginHost.getHierarchicalDocumentSymbols(
325+
textDocument,
326+
cancellation_token
327+
);
328+
329+
assert.strictEqual(result.length, 1);
330+
assert.strictEqual(result[0].name, 'outer');
331+
assert.strictEqual(result[0].children?.length, 1);
332+
assert.strictEqual(result[0].children![0].name, 'inner');
333+
});
334+
});
190335
});

0 commit comments

Comments
 (0)