From 81fbeb1c1243d0eea4e78067c2c377525aa5d9c4 Mon Sep 17 00:00:00 2001 From: "Pier Dolique (Perdolique)" Date: Tue, 11 Aug 2026 15:21:17 +0300 Subject: [PATCH] fix(extension): filter contract symbols MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 🐛 Ignore default exports and properties outside class or interface members - ✅ Cover Hono-like contract keys while preserving class property lenses - 📚 Document conservative symbol matching --- README.md | 5 ++ src/symbols.ts | 58 ++++++++++++++--- test/extension.test.ts | 18 +++++ test/fixtures/workspace/fixture.ts | 34 ++++++++++ test/symbols.test.ts | 101 +++++++++++++++++++++++++++-- 5 files changed, 203 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 69ec846..4ac8e53 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,11 @@ Supported Languages * TypeScript * JavaScript (note about [jsconfig.json](https://code.visualstudio.com/docs/languages/jsconfig)) +Conservative Matching +--------------------- + +Properties are analyzed only when Visual Studio Code reports them as direct class or interface members. Default export assignments are also ignored because their consumers can live outside the static reference graph. + Installation ------------ diff --git a/src/symbols.ts b/src/symbols.ts index 82b9807..0aeb3d6 100644 --- a/src/symbols.ts +++ b/src/symbols.ts @@ -16,6 +16,11 @@ export interface SymbolData { readonly referencePosition: Position; } +interface PendingDocumentSymbol { + readonly parentKind: SymbolKind | undefined; + readonly symbol: DocumentSymbol; +} + const supportedKinds: ReadonlyMap = new Map([ ['typescript', [ SymbolKind.Class, @@ -69,11 +74,18 @@ export function getSymbolData( continue; } - if (isKindSupported(symbol.kind, document.languageId)) { + const normalizedName = normalizeSymbolName(symbol.name); + + if (isSymbolEligible( + symbol.kind, + normalizedName, + undefined, + document.languageId + )) { symbolData.push({ kind: symbol.kind, name: symbol.name, - normalizedName: normalizeSymbolName(symbol.name), + normalizedName, range: symbol.location.range, declarationRange: symbol.location.range, referencePosition: symbol.location.range.start @@ -90,6 +102,25 @@ function isKindSupported(kind: SymbolKind, languageId: string): boolean { return kinds?.includes(kind) ?? false; } +function isSymbolEligible( + kind: SymbolKind, + normalizedName: string, + parentKind: SymbolKind | undefined, + languageId: string +): boolean { + if (!isKindSupported(kind, languageId)) { + return false; + } + + if (parentKind === undefined && normalizedName === 'default') { + return false; + } + + return kind !== SymbolKind.Property + || parentKind === SymbolKind.Class + || parentKind === SymbolKind.Interface; +} + function isDocumentSymbol( symbol: DocumentSymbol | SymbolInformation ): symbol is DocumentSymbol { @@ -101,23 +132,34 @@ function appendDocumentSymbols( root: DocumentSymbol, document: TextDocument ): void { - const pending = [root]; + const pending: PendingDocumentSymbol[] = [{ + parentKind: undefined, + symbol: root + }]; while (pending.length > 0) { - const symbol = pending.pop(); + const pendingSymbol = pending.pop(); - if (symbol === undefined) { + if (pendingSymbol === undefined) { continue; } - if (isKindSupported(symbol.kind, document.languageId)) { + const { parentKind, symbol } = pendingSymbol; + const normalizedName = normalizeSymbolName(symbol.name); + + if (isSymbolEligible( + symbol.kind, + normalizedName, + parentKind, + document.languageId + )) { const declarationRange = getDeclarationRange(symbol, document); if (declarationRange !== undefined) { destination.push({ kind: symbol.kind, name: symbol.name, - normalizedName: normalizeSymbolName(symbol.name), + normalizedName, range: symbol.range, declarationRange, referencePosition: declarationRange.start @@ -129,7 +171,7 @@ function appendDocumentSymbols( const child = symbol.children[index]; if (child !== undefined) { - pending.push(child); + pending.push({ parentKind: symbol.kind, symbol: child }); } } } diff --git a/test/extension.test.ts b/test/extension.test.ts index 83cbcb3..5469f50 100644 --- a/test/extension.test.ts +++ b/test/extension.test.ts @@ -34,5 +34,23 @@ suite('extension integration', () => { assert.equal(titles.filter(title => title.includes('overloaded')).length, 1); assert.equal(titles.some(title => title.includes('["literal"]')), false); assert.equal(titles.some(title => title.includes('shorthandValue')), false); + + const ignoredTitles = [ + '"Bindings" has zero references', + '"environment" has zero references', + '"runtime" has zero references', + '"status" has zero references', + '"error" has zero references', + '"default" has zero references' + ]; + + for (const ignoredTitle of ignoredTitles) { + assert.equal(titles.includes(ignoredTitle), false, ignoredTitle); + } + + assert.equal( + titles.includes('"preservedClassProperty" has zero references'), + true + ); }); }); diff --git a/test/fixtures/workspace/fixture.ts b/test/fixtures/workspace/fixture.ts index cb25133..4c9c2b9 100644 --- a/test/fixtures/workspace/fixture.ts +++ b/test/fixtures/workspace/fixture.ts @@ -21,3 +21,37 @@ new ComputedMember()["literal"](); export const shorthandValue = 1; export const shorthandObject = { shorthandValue }; + +interface ContractEnvironment { + REGISTRY_ENVIRONMENT: string; +} + +interface ContractContext { + readonly env: ContractEnvironment; + json(value: unknown, status?: number): unknown; +} + +declare class ContractApp { + get(path: string, handler: (context: ContractContext) => unknown): void; + notFound(handler: (context: ContractContext) => unknown): void; +} + +const contractApp = new ContractApp<{ Bindings: ContractEnvironment }>(); + +contractApp.get('/api/health', context => + context.json({ + environment: context.env.REGISTRY_ENVIRONMENT, + runtime: 'workerd', + status: 'ok' + }) +); + +contractApp.notFound(context => + context.json({ error: 'Not found' }, 404) +); + +export class PreservedClassMember { + readonly preservedClassProperty = true; +} + +export default contractApp; diff --git a/test/symbols.test.ts b/test/symbols.test.ts index 813f6e8..a6fcebb 100644 --- a/test/symbols.test.ts +++ b/test/symbols.test.ts @@ -66,7 +66,7 @@ suite('symbol classification', () => { test('recovers exactly one normalized name from a full declaration range', async () => { const document = await workspace.openTextDocument({ - content: 'function foo(value: string): string;\nget value(): string;', + content: 'function foo(value: string): string;\nclass Example { get value(): string; }', language: 'typescript' }); const overloadRange = new Range(0, 0, 0, 36); @@ -77,7 +77,14 @@ suite('symbol classification', () => { overloadRange, overloadRange ); - const getterRange = new Range(1, 0, 1, 20); + const classSymbol = new DocumentSymbol( + 'Example', + '', + SymbolKind.Class, + new Range(1, 0, 1, 38), + new Range(1, 6, 1, 13) + ); + const getterRange = new Range(1, 16, 1, 36); const getter = new DocumentSymbol( '(get) value', '', @@ -86,11 +93,95 @@ suite('symbol classification', () => { getterRange ); - const result = getSymbolData([overload, getter], document); + classSymbol.children.push(getter); + + const result = getSymbolData([overload, classSymbol], document); - assert.equal(result.length, 2); + assert.equal(result.length, 3); assert.ok(result[0]?.declarationRange.isEqual(new Range(0, 9, 0, 12))); - assert.ok(result[1]?.declarationRange.isEqual(new Range(1, 4, 1, 9))); + assert.ok(result[2]?.declarationRange.isEqual(new Range(1, 20, 1, 25))); + }); + + test('keeps member properties and omits contract properties', async () => { + const content = [ + 'variableContainer variableProperty functionContainer functionProperty', + 'classContainer classProperty interfaceContainer interfaceProperty', + 'default flatProperty' + ].join('\n'); + const document = await workspace.openTextDocument({ + content, + language: 'typescript' + }); + const createNamedSymbol = ( + name: string, + kind: SymbolKind + ): DocumentSymbol => { + const nameOffset = content.indexOf(name); + + assert.notEqual(nameOffset, -1); + + const nameRange = new Range( + document.positionAt(nameOffset), + document.positionAt(nameOffset + name.length) + ); + + return new DocumentSymbol(name, '', kind, nameRange, nameRange); + }; + const variableContainer = createNamedSymbol( + 'variableContainer', + SymbolKind.Variable + ); + const functionContainer = createNamedSymbol( + 'functionContainer', + SymbolKind.Function + ); + const classContainer = createNamedSymbol('classContainer', SymbolKind.Class); + const interfaceContainer = createNamedSymbol( + 'interfaceContainer', + SymbolKind.Interface + ); + const defaultExport = createNamedSymbol('default', SymbolKind.Variable); + const flatPropertyRange = createNamedSymbol( + 'flatProperty', + SymbolKind.Property + ).range; + const flatProperty = new SymbolInformation( + 'flatProperty', + SymbolKind.Property, + '', + new Location(document.uri, flatPropertyRange) + ); + + variableContainer.children.push( + createNamedSymbol('variableProperty', SymbolKind.Property) + ); + functionContainer.children.push( + createNamedSymbol('functionProperty', SymbolKind.Property) + ); + classContainer.children.push( + createNamedSymbol('classProperty', SymbolKind.Property) + ); + interfaceContainer.children.push( + createNamedSymbol('interfaceProperty', SymbolKind.Property) + ); + + const result = getSymbolData([ + variableContainer, + functionContainer, + classContainer, + interfaceContainer, + defaultExport, + flatProperty + ], document); + + assert.deepEqual(result.map(symbol => symbol.name), [ + 'variableContainer', + 'functionContainer', + 'classContainer', + 'classProperty', + 'interfaceContainer', + 'interfaceProperty' + ]); }); test('omits a full declaration range with multiple name candidates', async () => {