Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
123 changes: 123 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,127 @@ suite('Find symbol', () => {
test('Should match on symbols with _', () => {
assert.strictEqual(findBestSymbolByPath([docSymbol('_a_')], '_a_')?.name, '_a_');
});

test('Should prefer last part for flat symbol information', () => {
// When symbols are flat (SymbolInformation), prefer matching the last part
// 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 first part if last part not found in flat symbols', () => {
// If the last part isn't found, fall back to the first part
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 last part
assert.strictEqual(
findBestSymbolByPath([
symbolInfo('namespace'),
symbolInfo('TextModel'),
symbolInfo('undo')
], 'namespace.TextModel.undo()')?.name,
'undo'
);
});

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 last part over first part in flat symbols due to match priority', () => {
// When both class and method exist as flat symbols, prefer method (last part, matchCount=2)
// over class (first part, matchCount=1)
assert.strictEqual(
findBestSymbolByPath([
symbolInfo('TextModel'), // matchCount=1 (first part)
symbolInfo('undo') // matchCount=2 (last part)
], 'TextModel.undo()')?.name,
'undo'
);

// Reverse order - should still prefer undo due to higher matchCount
assert.strictEqual(
findBestSymbolByPath([
symbolInfo('undo'), // matchCount=2 (last part)
symbolInfo('TextModel') // matchCount=1 (first part)
], 'TextModel.undo()')?.name,
'undo'
);
});
});
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 findMethodInSymbols(symbols: Array<vscode.SymbolInformation | vscode.DocumentSymbol>, methodName: string, maxDepth: number = 5): vscode.SymbolInformation | vscode.DocumentSymbol | undefined {
for (const symbol of symbols) {
if (symbol.name === methodName) {
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 = findMethodInSymbols(symbol.children, methodName, 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 method name from qualified symbol like "TextModel.undo()"
const symbolParts = symbolText ? Array.from(symbolText.matchAll(/[#\w$][\w\d$]*/g), x => x[0]) : [];
const methodName = 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 method in the class file
if (methodName && 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 method in the document symbols
const methodSymbol = findMethodInSymbols(symbols, methodName);
if (methodSymbol) {
let methodRange: vscode.Range;
if ('selectionRange' in methodSymbol) {
methodRange = methodSymbol.selectionRange;
} else {
methodRange = methodSymbol.location.range;
}
dest = {
type: 'definition',
loc: { targetUri: defLoc.targetUri, targetRange: methodRange, targetSelectionRange: methodRange },
};
break;
}
}
} catch {
// Failed to find method, fall through to use class definition
}
}

dest = {
type: 'definition',
loc: toLocationLink(def),
loc: defLoc,
};
break;
}
Expand Down
19 changes: 17 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,23 @@ 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 last part (method) over first part (class) for qualified names
const lastPart = symbolParts[symbolParts.length - 1];
const firstPart = symbolParts[0];

if (symbol.name === lastPart && symbolParts.length > 1) {
// Last part match for qualified names like `TextModel.undo()` - prefer the method
const match = { symbol, matchCount: 2 }; // Higher priority
if (!bestMatch || match.matchCount > bestMatch.matchCount) {
bestMatch = match;
}
} else if (symbol.name === firstPart) {
// First part match - use as fallback
const match = { symbol, matchCount: 1 }; // Lower priority
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