diff --git a/.changeset/dull-bees-hang.md b/.changeset/dull-bees-hang.md new file mode 100644 index 000000000..37aabe47a --- /dev/null +++ b/.changeset/dull-bees-hang.md @@ -0,0 +1,5 @@ +--- +'svelte-language-server': patch +--- + +feat: support hierarchical document symbols diff --git a/.changeset/old-carrots-rescue.md b/.changeset/old-carrots-rescue.md index 5c3f5acad..4f1567cef 100644 --- a/.changeset/old-carrots-rescue.md +++ b/.changeset/old-carrots-rescue.md @@ -1,5 +1,5 @@ --- -"svelte-check": patch +'svelte-check': patch --- chore: use machine format when run by Claude Code diff --git a/packages/language-server/src/plugins/PluginHost.ts b/packages/language-server/src/plugins/PluginHost.ts index f251582c0..949108808 100644 --- a/packages/language-server/src/plugins/PluginHost.ts +++ b/packages/language-server/src/plugins/PluginHost.ts @@ -35,7 +35,8 @@ import { TextEdit, WorkspaceEdit, InlayHint, - WorkspaceSymbol + WorkspaceSymbol, + DocumentSymbol } from 'vscode-languageserver'; import { DocumentManager, getNodeIfIsInHTMLStartTag } from '../lib/documents'; import { Logger } from '../logger'; @@ -307,6 +308,7 @@ export class PluginHost implements LSProvider, OnWatchFileChanges { if (cancellationToken.isCancellationRequested) { return []; } + return flatten( await this.execute( 'getDocumentSymbols', @@ -317,6 +319,63 @@ export class PluginHost implements LSProvider, OnWatchFileChanges { ); } + private comparePosition(pos1: Position, pos2: Position) { + if (pos1.line < pos2.line) return -1; + if (pos1.line > pos2.line) return 1; + if (pos1.character < pos2.character) return -1; + if (pos1.character > pos2.character) return 1; + return 0; + } + + private rangeContains(parent: Range, child: Range) { + return ( + this.comparePosition(parent.start, child.start) <= 0 && + this.comparePosition(child.end, parent.end) <= 0 + ); + } + + async getHierarchicalDocumentSymbols( + textDocument: TextDocumentIdentifier, + cancellationToken: CancellationToken + ): Promise { + const flat = await this.getDocumentSymbols(textDocument, cancellationToken); + const symbols = flat + .map((s) => + DocumentSymbol.create( + s.name, + undefined, + s.kind, + s.location.range, + s.location.range, + [] + ) + ) + .sort((a, b) => { + const start = this.comparePosition(a.range.start, b.range.start); + if (start !== 0) return start; + return this.comparePosition(b.range.end, a.range.end); + }); + + const stack: DocumentSymbol[] = []; + const roots: DocumentSymbol[] = []; + + for (const node of symbols) { + while (stack.length > 0 && !this.rangeContains(stack.at(-1)!.range, node.range)) { + stack.pop(); + } + + if (stack.length > 0) { + stack.at(-1)!.children!.push(node); + } else { + roots.push(node); + } + + stack.push(node); + } + + return roots; + } + async getDefinitions( textDocument: TextDocumentIdentifier, position: Position diff --git a/packages/language-server/src/server.ts b/packages/language-server/src/server.ts index b3fbf6853..bc00206bd 100644 --- a/packages/language-server/src/server.ts +++ b/packages/language-server/src/server.ts @@ -429,9 +429,16 @@ export function startServer(options?: LSOptions) { connection.onColorPresentation((evt) => pluginHost.getColorPresentations(evt.textDocument, evt.range, evt.color) ); - connection.onDocumentSymbol((evt, cancellationToken) => - pluginHost.getDocumentSymbols(evt.textDocument, cancellationToken) - ); + connection.onDocumentSymbol((evt, cancellationToken) => { + if ( + configManager.getClientCapabilities()?.textDocument?.documentSymbol + ?.hierarchicalDocumentSymbolSupport + ) { + return pluginHost.getHierarchicalDocumentSymbols(evt.textDocument, cancellationToken); + } else { + return pluginHost.getDocumentSymbols(evt.textDocument, cancellationToken); + } + }); connection.onDefinition((evt) => pluginHost.getDefinitions(evt.textDocument, evt.position)); connection.onReferences((evt, cancellationToken) => pluginHost.findReferences(evt.textDocument, evt.position, evt.context, cancellationToken) diff --git a/packages/language-server/test/plugins/PluginHost.test.ts b/packages/language-server/test/plugins/PluginHost.test.ts index c7b1807c5..ed4fa7806 100644 --- a/packages/language-server/test/plugins/PluginHost.test.ts +++ b/packages/language-server/test/plugins/PluginHost.test.ts @@ -1,15 +1,18 @@ import sinon from 'sinon'; import { CompletionItem, + DocumentSymbol, Location, LocationLink, Position, Range, + SymbolInformation, + SymbolKind, TextDocumentItem } from 'vscode-languageserver-types'; import { DocumentManager, Document } from '../../src/lib/documents'; import { LSPProviderConfig, PluginHost } from '../../src/plugins'; -import { CompletionTriggerKind } from 'vscode-languageserver'; +import { CompletionTriggerKind, CancellationToken } from 'vscode-languageserver'; import assert from 'assert'; describe('PluginHost', () => { @@ -187,4 +190,146 @@ describe('PluginHost', () => { ]); }); }); + + describe('getHierarchicalDocumentSymbols', () => { + it('converts flat symbols to hierarchical structure', async () => { + const cancellation_token: CancellationToken = { + isCancellationRequested: false, + onCancellationRequested: () => ({ dispose: () => {} }) + }; + + const flat_symbols: SymbolInformation[] = [ + // Root level class (lines 0-10) + SymbolInformation.create( + 'MyClass', + SymbolKind.Class, + Range.create(Position.create(0, 0), Position.create(10, 0)), + 'file:///hello.svelte' + ), + // Method inside class (lines 1-5) + SymbolInformation.create( + 'myMethod', + SymbolKind.Method, + Range.create(Position.create(1, 0), Position.create(5, 0)), + 'file:///hello.svelte' + ), + // Variable inside method (lines 2-3) + SymbolInformation.create( + 'localVar', + SymbolKind.Variable, + Range.create(Position.create(2, 0), Position.create(3, 0)), + 'file:///hello.svelte' + ), + // Another method in class (lines 6-8) + SymbolInformation.create( + 'anotherMethod', + SymbolKind.Method, + Range.create(Position.create(6, 0), Position.create(8, 0)), + 'file:///hello.svelte' + ), + // Root level function (lines 12-15) + SymbolInformation.create( + 'topLevelFunction', + SymbolKind.Function, + Range.create(Position.create(12, 0), Position.create(15, 0)), + 'file:///hello.svelte' + ) + ]; + + const { docManager, pluginHost } = setup({ + getDocumentSymbols: sinon.stub().returns(flat_symbols) + }); + docManager.openClientDocument(textDocument); + + const result = await pluginHost.getHierarchicalDocumentSymbols( + textDocument, + cancellation_token + ); + + // Should have 2 root symbols: MyClass and topLevelFunction + assert.strictEqual(result.length, 2); + + // Check first root symbol (MyClass) + assert.strictEqual(result[0].name, 'MyClass'); + assert.strictEqual(result[0].kind, SymbolKind.Class); + assert.strictEqual(result[0].children?.length, 2); + + // Check children of MyClass + assert.strictEqual(result[0].children![0].name, 'myMethod'); + assert.strictEqual(result[0].children![0].kind, SymbolKind.Method); + assert.strictEqual(result[0].children![0].children?.length, 1); + + // Check nested child (localVar inside myMethod) + assert.strictEqual(result[0].children![0].children![0].name, 'localVar'); + assert.strictEqual(result[0].children![0].children![0].kind, SymbolKind.Variable); + assert.strictEqual(result[0].children![0].children![0].children?.length, 0); + + // Check second child of MyClass + assert.strictEqual(result[0].children![1].name, 'anotherMethod'); + assert.strictEqual(result[0].children![1].kind, SymbolKind.Method); + assert.strictEqual(result[0].children![1].children?.length, 0); + + // Check second root symbol (topLevelFunction) + assert.strictEqual(result[1].name, 'topLevelFunction'); + assert.strictEqual(result[1].kind, SymbolKind.Function); + assert.strictEqual(result[1].children?.length, 0); + }); + + it('handles empty symbol list', async () => { + const cancellation_token: CancellationToken = { + isCancellationRequested: false, + onCancellationRequested: () => ({ dispose: () => {} }) + }; + + const { docManager, pluginHost } = setup({ + getDocumentSymbols: sinon.stub().returns([]) + }); + docManager.openClientDocument(textDocument); + + const result = await pluginHost.getHierarchicalDocumentSymbols( + textDocument, + cancellation_token + ); + + assert.deepStrictEqual(result, []); + }); + + it('handles symbols with same start position', async () => { + const cancellation_token: CancellationToken = { + isCancellationRequested: false, + onCancellationRequested: () => ({ dispose: () => {} }) + }; + + const flat_symbols: SymbolInformation[] = [ + // Two symbols starting at same position, longer one should be parent + SymbolInformation.create( + 'outer', + SymbolKind.Class, + Range.create(Position.create(0, 0), Position.create(10, 0)), + 'file:///hello.svelte' + ), + SymbolInformation.create( + 'inner', + SymbolKind.Method, + Range.create(Position.create(0, 0), Position.create(5, 0)), + 'file:///hello.svelte' + ) + ]; + + const { docManager, pluginHost } = setup({ + getDocumentSymbols: sinon.stub().returns(flat_symbols) + }); + docManager.openClientDocument(textDocument); + + const result = await pluginHost.getHierarchicalDocumentSymbols( + textDocument, + cancellation_token + ); + + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].name, 'outer'); + assert.strictEqual(result[0].children?.length, 1); + assert.strictEqual(result[0].children![0].name, 'inner'); + }); + }); });