diff --git a/src/extension/linkify/test/vscode-node/findSymbol.test.ts b/src/extension/linkify/test/vscode-node/findSymbol.test.ts index 8140473a66..e7bf6317f5 100644 --- a/src/extension/linkify/test/vscode-node/findSymbol.test.ts +++ b/src/extension/linkify/test/vscode-node/findSymbol.test.ts @@ -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 + ); + }); }); diff --git a/src/extension/linkify/vscode-node/commands.ts b/src/extension/linkify/vscode-node/commands.ts index 92b5f2c5ab..54ef1472f0 100644 --- a/src/extension/linkify/vscode-node/commands.ts +++ b/src/extension/linkify/vscode-node/commands.ts @@ -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" : { @@ -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, 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 { @@ -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 | 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; } diff --git a/src/extension/linkify/vscode-node/findSymbol.ts b/src/extension/linkify/vscode-node/findSymbol.ts index 17e0fe0b2f..3d517eebff 100644 --- a/src/extension/linkify/vscode-node/findSymbol.ts +++ b/src/extension/linkify/vscode-node/findSymbol.ts @@ -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; + } } } } diff --git a/src/extension/linkify/vscode-node/findWord.ts b/src/extension/linkify/vscode-node/findWord.ts index 724a322eac..2584fafd42 100644 --- a/src/extension/linkify/vscode-node/findWord.ts +++ b/src/extension/linkify/vscode-node/findWord.ts @@ -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)); + } } } diff --git a/src/extension/linkify/vscode-node/inlineCodeSymbolLinkifier.ts b/src/extension/linkify/vscode-node/inlineCodeSymbolLinkifier.ts index 1c13541b3c..76cd78ebd8 100644 --- a/src/extension/linkify/vscode-node/inlineCodeSymbolLinkifier.ts +++ b/src/extension/linkify/vscode-node/inlineCodeSymbolLinkifier.ts @@ -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));