Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/dull-bees-hang.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte-language-server': patch
---

feat: support hierarchical document symbols
2 changes: 1 addition & 1 deletion .changeset/old-carrots-rescue.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
"svelte-check": patch
'svelte-check': patch
---

chore: use machine format when run by Claude Code
61 changes: 60 additions & 1 deletion packages/language-server/src/plugins/PluginHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ import {
TextEdit,
WorkspaceEdit,
InlayHint,
WorkspaceSymbol
WorkspaceSymbol,
DocumentSymbol
} from 'vscode-languageserver';
import { DocumentManager, getNodeIfIsInHTMLStartTag } from '../lib/documents';
import { Logger } from '../logger';
Expand Down Expand Up @@ -307,6 +308,7 @@ export class PluginHost implements LSProvider, OnWatchFileChanges {
if (cancellationToken.isCancellationRequested) {
return [];
}

return flatten(
await this.execute<SymbolInformation[]>(
'getDocumentSymbols',
Expand All @@ -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<DocumentSymbol[]> {
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
Expand Down
13 changes: 10 additions & 3 deletions packages/language-server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
147 changes: 146 additions & 1 deletion packages/language-server/test/plugins/PluginHost.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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');
});
});
});