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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
------------

Expand Down
58 changes: 50 additions & 8 deletions src/symbols.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ export interface SymbolData {
readonly referencePosition: Position;
}

interface PendingDocumentSymbol {
readonly parentKind: SymbolKind | undefined;
readonly symbol: DocumentSymbol;
}

const supportedKinds: ReadonlyMap<string, readonly SymbolKind[]> = new Map([
['typescript', [
SymbolKind.Class,
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -129,7 +171,7 @@ function appendDocumentSymbols(
const child = symbol.children[index];

if (child !== undefined) {
pending.push(child);
pending.push({ parentKind: symbol.kind, symbol: child });
}
}
}
Expand Down
18 changes: 18 additions & 0 deletions test/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
});
});
34 changes: 34 additions & 0 deletions test/fixtures/workspace/fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Environment> {
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;
101 changes: 96 additions & 5 deletions test/symbols.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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',
'',
Expand All @@ -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 () => {
Expand Down