Skip to content
Open
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
143 changes: 143 additions & 0 deletions src/extension/linkify/test/vscode-node/findSymbol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,147 @@ suite('Find symbol', () => {
test('Should match on symbols with _', () => {
assert.strictEqual(findBestSymbolByPath([docSymbol('_a_')], '_a_')?.name, '_a_');
});

test('Should prefer rightmost symbol in flat symbols', () => {
// When symbols are flat (SymbolInformation), prefer the rightmost match
// This handles cases like `TextModel.undo()` where we want `undo`, not `TextModel`
assert.strictEqual(
findBestSymbolByPath([
symbolInfo('TextModel'),
symbolInfo('undo')
], 'TextModel.undo()')?.name,
'undo'
);
});

test('Should fall back to leftmost symbol if rightmost not found in flat symbols', () => {
// If the rightmost part isn't found, fall back to leftmost matches
assert.strictEqual(
findBestSymbolByPath([
symbolInfo('TextModel'),
symbolInfo('someOtherMethod')
], 'TextModel.undo()')?.name,
'TextModel'
);
});

test('Should prefer hierarchical match over flat last part match', () => {
// When both hierarchical and flat symbols exist, prefer the hierarchical match
assert.strictEqual(
findBestSymbolByPath([
docSymbol('TextModel', docSymbol('undo')),
symbolInfo('undo') // This is a different undo from a different class
], 'TextModel.undo()')?.name,
'undo'
);
});

test('Should handle deeply qualified names', () => {
// Test multiple levels of qualification
assert.strictEqual(
findBestSymbolByPath([
docSymbol('namespace', docSymbol('TextModel', docSymbol('undo')))
], 'namespace.TextModel.undo()')?.name,
'undo'
);

// With flat symbols, prefer the rightmost part
assert.strictEqual(
findBestSymbolByPath([
symbolInfo('namespace'),
symbolInfo('TextModel'),
symbolInfo('undo')
], 'namespace.TextModel.undo()')?.name,
'undo'
);

// Middle part should be preferred over leftmost
assert.strictEqual(
findBestSymbolByPath([
symbolInfo('namespace'),
symbolInfo('TextModel')
], 'namespace.TextModel.undo()')?.name,
'TextModel'
);
});

test('Should handle mixed flat and hierarchical symbols', () => {
// Some symbols are flat, some are nested
assert.strictEqual(
findBestSymbolByPath([
symbolInfo('Model'),
docSymbol('TextModel', docSymbol('undo')),
symbolInfo('OtherClass')
], 'TextModel.undo()')?.name,
'undo'
);
});

test('Should handle Python-style naming conventions', () => {
// Python uses underscores instead of camelCase
assert.strictEqual(
findBestSymbolByPath([
docSymbol('MyClass', docSymbol('my_method'))
], 'MyClass.my_method()')?.name,
'my_method'
);

// Python dunder methods
assert.strictEqual(
findBestSymbolByPath([
docSymbol('MyClass', docSymbol('__init__'))
], 'MyClass.__init__()')?.name,
'__init__'
);

// Python private methods
assert.strictEqual(
findBestSymbolByPath([
docSymbol('MyClass', docSymbol('_private_method'))
], 'MyClass._private_method()')?.name,
'_private_method'
);
});

test('Should handle Python module qualified names', () => {
// Python: module.Class.method
assert.strictEqual(
findBestSymbolByPath([
docSymbol('my_module', docSymbol('MyClass', docSymbol('my_method')))
], 'my_module.MyClass.my_method()')?.name,
'my_method'
);
});

test('Should prefer rightmost match in flat symbols using position-based priority', () => {
// When both class and method exist as flat symbols, prefer rightmost
assert.strictEqual(
findBestSymbolByPath([
symbolInfo('TextModel'), // matchCount=1 (index 0)
symbolInfo('undo') // matchCount=2 (index 1)
], 'TextModel.undo()')?.name,
'undo'
);

// Reverse order - should still prefer undo due to higher matchCount
assert.strictEqual(
findBestSymbolByPath([
symbolInfo('undo'), // matchCount=2 (index 1)
symbolInfo('TextModel') // matchCount=1 (index 0)
], 'TextModel.undo()')?.name,
'undo'
);

// Works for longer qualified names too
// For 'a.b.c.d' => ['a', 'b', 'c', 'd']:
// 'd' (index 3, matchCount=4) > 'c' (index 2, matchCount=3) > 'b' (index 1, matchCount=2) > 'a' (index 0, matchCount=1)
assert.strictEqual(
findBestSymbolByPath([
symbolInfo('a'), // matchCount=1
symbolInfo('b'), // matchCount=2
symbolInfo('c'), // matchCount=3
], 'a.b.c.d')?.name,
'c' // Highest matchCount among available symbols
);
});
});
54 changes: 51 additions & 3 deletions src/extension/linkify/vscode-node/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export function registerLinkCommands(
// Command used when we have already resolved the link to a location.
// This is currently used by the inline code linkifier for links such as `symbolName`
vscode.commands.registerCommand(openSymbolFromReferencesCommand, async (...[_word, locations, requestId]: OpenSymbolFromReferencesCommandArgs) => {
const dest = await resolveSymbolFromReferences(locations, CancellationToken.None);
const dest = await resolveSymbolFromReferences(locations, undefined, CancellationToken.None);

/* __GDPR__
"panel.action.openSymbolFromReferencesLink" : {
Expand Down Expand Up @@ -136,12 +136,32 @@ function toLocationLink(def: vscode.Location | vscode.LocationLink): vscode.Loca
}
}

export async function resolveSymbolFromReferences(locations: ReadonlyArray<{ uri: UriComponents; pos: vscode.Position }>, token: CancellationToken) {
function findSymbolByName(symbols: Array<vscode.SymbolInformation | vscode.DocumentSymbol>, symbolName: string, maxDepth: number = 5): vscode.SymbolInformation | vscode.DocumentSymbol | undefined {
for (const symbol of symbols) {
if (symbol.name === symbolName) {
return symbol;
}
// Check children if it's a DocumentSymbol and we haven't exceeded max depth
if (maxDepth > 0 && 'children' in symbol && symbol.children) {
const found = findSymbolByName(symbol.children, symbolName, maxDepth - 1);
if (found) {
return found;
}
}
}
return undefined;
}

export async function resolveSymbolFromReferences(locations: ReadonlyArray<{ uri: UriComponents; pos: vscode.Position }>, symbolText: string | undefined, token: CancellationToken) {
let dest: {
type: 'definition' | 'firstOccurrence' | 'unresolved';
loc: vscode.LocationLink;
} | undefined;

// Extract the rightmost part from qualified symbol like "TextModel.undo()"
const symbolParts = symbolText ? Array.from(symbolText.matchAll(/[#\w$][\w\d$]*/g), x => x[0]) : [];
const targetSymbolName = symbolParts.length >= 2 ? symbolParts[symbolParts.length - 1] : undefined;

// TODO: These locations may no longer be valid if the user has edited the file since the references were found.
for (const loc of locations) {
try {
Expand All @@ -151,9 +171,37 @@ export async function resolveSymbolFromReferences(locations: ReadonlyArray<{ uri
}

if (def) {
const defLoc = toLocationLink(def);

// If we have a qualified name like "TextModel.undo()", try to find the specific symbol in the file
if (targetSymbolName && symbolParts.length >= 2) {
try {
const symbols = await vscode.commands.executeCommand<Array<vscode.SymbolInformation | vscode.DocumentSymbol> | undefined>('vscode.executeDocumentSymbolProvider', defLoc.targetUri);
if (symbols) {
// Search for the target symbol in the document symbols
const targetSymbol = findSymbolByName(symbols, targetSymbolName);
if (targetSymbol) {
let targetRange: vscode.Range;
if ('selectionRange' in targetSymbol) {
targetRange = targetSymbol.selectionRange;
} else {
targetRange = targetSymbol.location.range;
}
dest = {
type: 'definition',
loc: { targetUri: defLoc.targetUri, targetRange: targetRange, targetSelectionRange: targetRange },
};
break;
}
}
} catch {
// Failed to find symbol, fall through to use the first definition
}
}

dest = {
type: 'definition',
loc: toLocationLink(def),
loc: defLoc,
};
break;
}
Expand Down
12 changes: 10 additions & 2 deletions src/extension/linkify/vscode-node/findSymbol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,16 @@ function findBestSymbol(
bestMatch = match;
}
} else { // Is a vscode.SymbolInformation
if (symbol.name === symbolParts[0]) {
bestMatch ??= { symbol, matchCount: 1 };
// For flat symbol information, try to match against symbol parts
// Prefer symbols that appear more to the right (higher index) in the qualified name
// This prioritizes members over classes (e.g., in `TextModel.undo()`, prefer `undo`)
const matchIndex = symbolParts.indexOf(symbol.name);
if (matchIndex !== -1) {
// Higher index = more to the right = higher priority
const match = { symbol, matchCount: matchIndex + 1 };
if (!bestMatch || match.matchCount > bestMatch.matchCount) {
bestMatch = match;
}
}
}
}
Expand Down
47 changes: 33 additions & 14 deletions src/extension/linkify/vscode-node/findWord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,23 +220,42 @@ export class ReferencesSymbolResolver {

// But then try breaking up inline code into symbol parts
if (!wordMatches.length) {
// Find the first symbol name before a non-symbol character
// This will match `foo` in `this.foo(bar)`;
const parts = codeText.split(/([#\w$][\w\d$]*)/g).map(x => x.trim()).filter(x => x.length);
let primaryPart: string | undefined = undefined;
for (const part of parts) {
if (!/[#\w$][\w\d$]*/.test(part)) {
break;
}
primaryPart = part;
}

if (primaryPart && primaryPart !== codeText) {
wordMatches = await this.instantiationService.invokeFunction(accessor => findWordInReferences(accessor, references, primaryPart, {
// Always use stricter matching here as the parts can otherwise match on a lot of things
// Extract all symbol parts from the code text
// For example: `TextModel.undo()` -> ['TextModel', 'undo']
const symbolParts = Array.from(codeText.matchAll(/[#\w$][\w\d$]*/g), x => x[0]);

if (symbolParts.length >= 2) {
// For qualified names like `Class.method()`, search for both parts together
// This helps disambiguate when there are multiple methods with the same name
const firstPart = symbolParts[0];
const lastPart = symbolParts[symbolParts.length - 1];

// First, try to find the class
const classMatches = await this.instantiationService.invokeFunction(accessor => findWordInReferences(accessor, references, firstPart, {
symbolMatchesOnly: true,
maxResultCount: this.findWordOptions.maxResultCount,
}, token));

// If we found the class, we'll rely on the click-time resolution to find the method
if (classMatches.length) {
wordMatches = classMatches;
} else {
// If no class found, try just the method name as fallback
wordMatches = await this.instantiationService.invokeFunction(accessor => findWordInReferences(accessor, references, lastPart, {
symbolMatchesOnly: true,
maxResultCount: this.findWordOptions.maxResultCount,
}, token));
}
} else if (symbolParts.length > 0) {
// For single names like `undo`, try to find the method directly
const lastPart = symbolParts[symbolParts.length - 1];

if (lastPart && lastPart !== codeText) {
wordMatches = await this.instantiationService.invokeFunction(accessor => findWordInReferences(accessor, references, lastPart, {
symbolMatchesOnly: true,
maxResultCount: this.findWordOptions.maxResultCount,
}, token));
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export class InlineCodeSymbolLinkifier implements IContributedLinkifier {
};

out.push(new LinkifySymbolAnchor(info, async (token) => {
const dest = await resolveSymbolFromReferences(loc.map(loc => ({ uri: loc.uri, pos: loc.range.start })), token);
const dest = await resolveSymbolFromReferences(loc.map(loc => ({ uri: loc.uri, pos: loc.range.start })), symbolText, token);
if (dest) {
const selectionRange = dest.loc.targetSelectionRange ?? dest.loc.targetRange;
info.location = new vscode.Location(dest.loc.targetUri, collapseRangeToStart(selectionRange));
Expand Down
Loading