Move usages tool (#295139)

* feat: add UsagesTool for finding code symbol usages and implement related tests

* feat: handle late-registered tools in LanguageModelToolsExtensionPointHandler

* feat: enhance UsagesTool to utilize ISearchService for symbol searches and improve reference classification
This commit is contained in:
Johannes Rieken
2026-02-13 12:27:05 +01:00
committed by GitHub
parent 3eb326e807
commit 4d38837a83
7 changed files with 767 additions and 4 deletions

View File

@@ -6,7 +6,7 @@
import { Emitter } from '../../base/common/event.js';
import { IDisposable, toDisposable } from '../../base/common/lifecycle.js';
import { ITextModel, shouldSynchronizeModel } from './model.js';
import { LanguageFilter, LanguageSelector, score } from './languageSelector.js';
import { LanguageFilter, LanguageSelector, score, selectLanguageIds } from './languageSelector.js';
import { URI } from '../../base/common/uri.js';
interface Entry<T> {
@@ -115,6 +115,14 @@ export class LanguageFeatureRegistry<T> {
return this._entries.map(entry => entry.provider);
}
get registeredLanguageIds(): ReadonlySet<string> {
const result = new Set<string>();
for (const entry of this._entries) {
selectLanguageIds(entry.selector, result);
}
return result;
}
ordered(model: ITextModel, recursive = false): T[] {
const result: T[] = [];
this._orderedForEach(model, recursive, entry => result.push(entry.provider));
@@ -226,4 +234,3 @@ function isBuiltinSelector(selector: LanguageSelector): boolean {
return Boolean((selector as LanguageFilter).isBuiltin);
}

View File

@@ -142,3 +142,18 @@ export function targetsNotebooks(selector: LanguageSelector): boolean {
return !!(<LanguageFilter>selector).notebookType;
}
}
export function selectLanguageIds(selector: LanguageSelector, into: Set<string>): void {
if (typeof selector === 'string') {
into.add(selector);
} else if (Array.isArray(selector)) {
for (const item of selector) {
selectLanguageIds(item, into);
}
} else {
const language = (<LanguageFilter>selector).language;
if (language) {
into.add(language);
}
}
}

View File

@@ -6,7 +6,7 @@
import assert from 'assert';
import { URI } from '../../../../base/common/uri.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { LanguageSelector, score } from '../../../common/languageSelector.js';
import { LanguageSelector, score, selectLanguageIds } from '../../../common/languageSelector.js';
suite('LanguageSelector', function () {
@@ -173,4 +173,27 @@ suite('LanguageSelector', function () {
}, obj.uri, obj.langId, true, undefined, undefined);
assert.strictEqual(value, 0);
});
test('selectLanguageIds', function () {
const result = new Set<string>();
selectLanguageIds('typescript', result);
assert.deepStrictEqual([...result], ['typescript']);
result.clear();
selectLanguageIds({ language: 'python', scheme: 'file' }, result);
assert.deepStrictEqual([...result], ['python']);
result.clear();
selectLanguageIds({ scheme: 'file' }, result);
assert.deepStrictEqual([...result], []);
result.clear();
selectLanguageIds(['javascript', { language: 'css' }, { scheme: 'untitled' }], result);
assert.deepStrictEqual([...result].sort(), ['css', 'javascript']);
result.clear();
selectLanguageIds('*', result);
assert.deepStrictEqual([...result], ['*']);
});
});

View File

@@ -60,6 +60,7 @@ import { IPromptsService } from '../common/promptSyntax/service/promptsService.j
import { PromptsService } from '../common/promptSyntax/service/promptsServiceImpl.js';
import { LanguageModelToolsExtensionPointHandler } from '../common/tools/languageModelToolsContribution.js';
import { BuiltinToolsContribution } from '../common/tools/builtinTools/tools.js';
import { UsagesToolContribution } from './tools/usagesTool.js';
import { IVoiceChatService, VoiceChatService } from '../common/voiceChatService.js';
import { registerChatAccessibilityActions } from './actions/chatAccessibilityActions.js';
import { AgentChatAccessibilityHelp, EditsChatAccessibilityHelp, PanelChatAccessibilityHelp, QuickChatAccessibilityHelp } from './actions/chatAccessibilityHelp.js';
@@ -1422,6 +1423,7 @@ registerWorkbenchContribution2(ChatSetupContribution.ID, ChatSetupContribution,
registerWorkbenchContribution2(ChatTeardownContribution.ID, ChatTeardownContribution, WorkbenchPhase.AfterRestored);
registerWorkbenchContribution2(ChatStatusBarEntry.ID, ChatStatusBarEntry, WorkbenchPhase.BlockRestore);
registerWorkbenchContribution2(BuiltinToolsContribution.ID, BuiltinToolsContribution, WorkbenchPhase.Eventually);
registerWorkbenchContribution2(UsagesToolContribution.ID, UsagesToolContribution, WorkbenchPhase.BlockRestore);
registerWorkbenchContribution2(ChatAgentSettingContribution.ID, ChatAgentSettingContribution, WorkbenchPhase.AfterRestored);
registerWorkbenchContribution2(ChatAgentActionsContribution.ID, ChatAgentActionsContribution, WorkbenchPhase.Eventually);
registerWorkbenchContribution2(ToolReferenceNamesContribution.ID, ToolReferenceNamesContribution, WorkbenchPhase.AfterRestored);

View File

@@ -0,0 +1,371 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { CancellationToken } from '../../../../../base/common/cancellation.js';
import { Codicon } from '../../../../../base/common/codicons.js';
import { Emitter, Event } from '../../../../../base/common/event.js';
import { MarkdownString } from '../../../../../base/common/htmlContent.js';
import { escapeRegExpCharacters } from '../../../../../base/common/strings.js';
import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js';
import { ResourceSet } from '../../../../../base/common/map.js';
import { ThemeIcon } from '../../../../../base/common/themables.js';
import { URI } from '../../../../../base/common/uri.js';
import { relativePath } from '../../../../../base/common/resources.js';
import { Position } from '../../../../../editor/common/core/position.js';
import { Range } from '../../../../../editor/common/core/range.js';
import { Location, LocationLink } from '../../../../../editor/common/languages.js';
import { IModelService } from '../../../../../editor/common/services/model.js';
import { ILanguageFeaturesService } from '../../../../../editor/common/services/languageFeatures.js';
import { ITextModelService } from '../../../../../editor/common/services/resolverService.js';
import { getDefinitionsAtPosition, getImplementationsAtPosition, getReferencesAtPosition } from '../../../../../editor/contrib/gotoSymbol/browser/goToSymbol.js';
import { localize } from '../../../../../nls.js';
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js';
import { IWorkbenchContribution } from '../../../../common/contributions.js';
import { ISearchService, QueryType, resultIsMatch } from '../../../../services/search/common/search.js';
import { CountTokensCallback, ILanguageModelToolsService, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolResult, ToolDataSource, ToolProgress, } from '../../common/tools/languageModelToolsService.js';
import { createToolSimpleTextResult } from '../../common/tools/builtinTools/toolHelpers.js';
export const UsagesToolId = 'vscode_listCodeUsages';
interface IUsagesToolInput {
symbol: string;
uri?: string;
filePath?: string;
lineContent: string;
}
const BaseModelDescription = `Find all usages (references, definitions, and implementations) of a code symbol across the workspace. This tool locates where a symbol is referenced, defined, or implemented.
Input:
- "symbol": The exact name of the symbol to search for (function, class, method, variable, type, etc.).
- "uri": A full URI (e.g. "file:///path/to/file.ts") of a file where the symbol appears. Provide either "uri" or "filePath".
- "filePath": A workspace-relative file path (e.g. "src/utils/helpers.ts") of a file where the symbol appears. Provide either "uri" or "filePath".
- "lineContent": A substring of the line of code where the symbol appears. This is used to locate the exact position in the file. Must be the actual text from the file - do NOT fabricate it.
IMPORTANT: The file and line do NOT need to be the definition of the symbol. Any occurrence works - a usage, an import, a call site, etc. You can pick whichever occurrence is most convenient.
If the tool returns an error, retry with corrected input - ensure the file path is correct, the line content matches the actual file content, and the symbol name appears in that line.`;
export class UsagesTool extends Disposable implements IToolImpl {
private readonly _onDidUpdateToolData = this._store.add(new Emitter<void>());
readonly onDidUpdateToolData = this._onDidUpdateToolData.event;
constructor(
@ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService,
@IModelService private readonly _modelService: IModelService,
@ISearchService private readonly _searchService: ISearchService,
@ITextModelService private readonly _textModelService: ITextModelService,
@IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService,
) {
super();
this._store.add(Event.debounce(
this._languageFeaturesService.referenceProvider.onDidChange,
() => { },
2000
)((() => this._onDidUpdateToolData.fire())));
}
getToolData(): IToolData {
const languageIds = this._languageFeaturesService.referenceProvider.registeredLanguageIds;
let modelDescription = BaseModelDescription;
if (languageIds.has('*')) {
modelDescription += '\n\nSupported for all languages.';
} else if (languageIds.size > 0) {
const sorted = [...languageIds].sort();
modelDescription += `\n\nCurrently supported for: ${sorted.join(', ')}.`;
} else {
modelDescription += '\n\nNo languages currently have reference providers registered.';
}
return {
id: UsagesToolId,
toolReferenceName: 'usages',
canBeReferencedInPrompt: false,
icon: ThemeIcon.fromId(Codicon.references.id),
displayName: localize('tool.usages.displayName', 'List Code Usages'),
userDescription: localize('tool.usages.userDescription', 'Find references, definitions, and implementations of a symbol'),
modelDescription,
source: ToolDataSource.Internal,
inputSchema: {
type: 'object',
properties: {
symbol: {
type: 'string',
description: 'The exact name of the symbol (function, class, method, variable, type, etc.) to find usages of.'
},
uri: {
type: 'string',
description: 'A full URI of a file where the symbol appears (e.g. "file:///path/to/file.ts"). Provide either "uri" or "filePath".'
},
filePath: {
type: 'string',
description: 'A workspace-relative file path where the symbol appears (e.g. "src/utils/helpers.ts"). Provide either "uri" or "filePath".'
},
lineContent: {
type: 'string',
description: 'A substring of the line of code where the symbol appears. Used to locate the exact position. Must be actual text from the file.'
}
},
required: ['symbol', 'lineContent']
}
};
}
async prepareToolInvocation(context: IToolInvocationPreparationContext, _token: CancellationToken): Promise<IPreparedToolInvocation | undefined> {
const input = context.parameters as IUsagesToolInput;
return {
invocationMessage: localize('tool.usages.invocationMessage', 'Analyzing usages of `{0}`', input.symbol),
};
}
async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, token: CancellationToken): Promise<IToolResult> {
const input = invocation.parameters as IUsagesToolInput;
// --- resolve URI ---
const uri = this._resolveUri(input);
if (!uri) {
return this._errorResult('Provide either "uri" (a full URI) or "filePath" (a workspace-relative path) to identify the file.');
}
// --- open text model ---
const ref = await this._textModelService.createModelReference(uri);
try {
const model = ref.object.textEditorModel;
if (!this._languageFeaturesService.referenceProvider.has(model)) {
return this._errorResult(`No reference provider available for this file's language. The usages tool may not support this language.`);
}
// --- find line containing lineContent ---
const parts = input.lineContent.trim().split(/\s+/);
const lineContent = parts.map(escapeRegExpCharacters).join('\\s+');
const matches = model.findMatches(lineContent, false, true, false, null, false, 1);
if (matches.length === 0) {
return this._errorResult(`Could not find line content "${input.lineContent}" in ${uri.toString()}. Provide the exact text from the line where the symbol appears.`);
}
const lineNumber = matches[0].range.startLineNumber;
// --- find symbol in that line ---
const lineText = model.getLineContent(lineNumber);
const column = this._findSymbolColumn(lineText, input.symbol);
if (column === undefined) {
return this._errorResult(`Could not find symbol "${input.symbol}" in the matched line. Ensure the symbol name is correct and appears in the provided line content.`);
}
const position = new Position(lineNumber, column);
// --- query references, definitions, implementations in parallel ---
const [definitions, references, implementations] = await Promise.all([
getDefinitionsAtPosition(this._languageFeaturesService.definitionProvider, model, position, false, token),
getReferencesAtPosition(this._languageFeaturesService.referenceProvider, model, position, false, false, token),
getImplementationsAtPosition(this._languageFeaturesService.implementationProvider, model, position, false, token),
]);
if (references.length === 0) {
const result = createToolSimpleTextResult(`No usages found for \`${input.symbol}\`.`);
result.toolResultMessage = new MarkdownString(localize('tool.usages.noResults', 'Analyzed usages of `{0}`, no results', input.symbol));
return result;
}
// --- classify and format results with previews ---
const previews = await this._getLinePreviews(input.symbol, references, token);
const lines: string[] = [];
lines.push(`${references.length} usages of \`${input.symbol}\`:\n`);
for (let i = 0; i < references.length; i++) {
const ref = references[i];
const kind = this._classifyReference(ref, definitions, implementations);
const startLine = Range.lift(ref.range).startLineNumber;
const preview = previews[i];
if (preview) {
lines.push(`<usage type="${kind}" uri="${ref.uri.toString()}" line="${startLine}">`);
lines.push(`\t${preview}`);
lines.push(`</usage>`);
} else {
lines.push(`<usage type="${kind}" uri="${ref.uri.toString()}" line="${startLine}" />`);
}
}
const text = lines.join('\n');
const result = createToolSimpleTextResult(text);
result.toolResultMessage = references.length === 1
? new MarkdownString(localize('tool.usages.oneResult', 'Analyzed usages of `{0}`, 1 result', input.symbol))
: new MarkdownString(localize('tool.usages.results', 'Analyzed usages of `{0}`, {1} results', input.symbol, references.length));
result.toolResultDetails = references.map((r): Location => ({ uri: r.uri, range: r.range }));
return result;
} finally {
ref.dispose();
}
}
private async _getLinePreviews(symbol: string, references: LocationLink[], token: CancellationToken): Promise<(string | undefined)[]> {
const previews: (string | undefined)[] = new Array(references.length);
// Build a lookup: (uriString, lineNumber) → index in references array
const lookup = new Map<string, number>();
const needSearch = new ResourceSet();
for (let i = 0; i < references.length; i++) {
const ref = references[i];
const lineNumber = Range.lift(ref.range).startLineNumber;
// Try already-open models first
const existingModel = this._modelService.getModel(ref.uri);
if (existingModel) {
previews[i] = existingModel.getLineContent(lineNumber).trim();
} else {
lookup.set(`${ref.uri.toString()}:${lineNumber}`, i);
needSearch.add(ref.uri);
}
}
if (needSearch.size === 0 || token.isCancellationRequested) {
return previews;
}
// Use ISearchService to search for the symbol name, restricted to the
// referenced files. This is backed by ripgrep for file:// URIs.
try {
// Build includePattern from workspace-relative paths
const folders = this._workspaceContextService.getWorkspace().folders;
const relativePaths: string[] = [];
for (const uri of needSearch) {
const folder = this._workspaceContextService.getWorkspaceFolder(uri);
if (folder) {
const rel = relativePath(folder.uri, uri);
if (rel) {
relativePaths.push(rel);
}
}
}
if (relativePaths.length > 0) {
const includePattern: Record<string, true> = {};
if (relativePaths.length === 1) {
includePattern[relativePaths[0]] = true;
} else {
includePattern[`{${relativePaths.join(',')}}`] = true;
}
const searchResult = await this._searchService.textSearch(
{
type: QueryType.Text,
contentPattern: { pattern: escapeRegExpCharacters(symbol), isRegExp: true, isWordMatch: true },
folderQueries: folders.map(f => ({ folder: f.uri })),
includePattern,
},
token,
);
for (const fileMatch of searchResult.results) {
if (!fileMatch.results) {
continue;
}
for (const textMatch of fileMatch.results) {
if (!resultIsMatch(textMatch)) {
continue;
}
for (const range of textMatch.rangeLocations) {
const lineNumber = range.source.startLineNumber + 1; // 0-based → 1-based
const key = `${fileMatch.resource.toString()}:${lineNumber}`;
const idx = lookup.get(key);
if (idx !== undefined) {
previews[idx] = textMatch.previewText.trim();
lookup.delete(key);
}
}
}
}
}
} catch {
// search might fail, leave remaining previews as undefined
}
return previews;
}
private _resolveUri(input: IUsagesToolInput): URI | undefined {
if (input.uri) {
return URI.parse(input.uri);
}
if (input.filePath) {
const folders = this._workspaceContextService.getWorkspace().folders;
if (folders.length === 1) {
return folders[0].toResource(input.filePath);
}
// try each folder, return the first
for (const folder of folders) {
return folder.toResource(input.filePath);
}
}
return undefined;
}
private _findSymbolColumn(lineText: string, symbol: string): number | undefined {
// use word boundary matching to avoid partial matches
const pattern = new RegExp(`\\b${escapeRegExpCharacters(symbol)}\\b`);
const match = pattern.exec(lineText);
if (match) {
return match.index + 1; // 1-based column
}
return undefined;
}
private _classifyReference(ref: LocationLink, definitions: LocationLink[], implementations: LocationLink[]): string {
if (definitions.some(d => this._overlaps(ref, d))) {
return 'definition';
}
if (implementations.some(d => this._overlaps(ref, d))) {
return 'implementation';
}
return 'reference';
}
private _overlaps(a: LocationLink, b: LocationLink): boolean {
if (a.uri.toString() !== b.uri.toString()) {
return false;
}
return Range.areIntersectingOrTouching(a.range, b.range);
}
private _errorResult(message: string): IToolResult {
const result = createToolSimpleTextResult(message);
result.toolResultMessage = new MarkdownString(message);
return result;
}
}
export class UsagesToolContribution extends Disposable implements IWorkbenchContribution {
static readonly ID = 'chat.usagesTool';
constructor(
@ILanguageModelToolsService toolsService: ILanguageModelToolsService,
@IInstantiationService instantiationService: IInstantiationService,
) {
super();
const usagesTool = this._store.add(instantiationService.createInstance(UsagesTool));
let registration: IDisposable | undefined;
const registerUsagesTool = () => {
registration?.dispose();
toolsService.flushToolUpdates();
const toolData = usagesTool.getToolData();
registration = toolsService.registerTool(toolData, usagesTool);
};
registerUsagesTool();
this._store.add(usagesTool.onDidUpdateToolData(registerUsagesTool));
this._store.add({ dispose: () => registration?.dispose() });
}
}

View File

@@ -327,6 +327,7 @@ export class LanguageModelToolsExtensionPointHandler implements IWorkbenchContri
const tools: IToolData[] = [];
const toolSets: IToolSet[] = [];
const missingToolNames: string[] = [];
for (const toolName of toolSet.tools) {
const toolObj = languageModelToolsService.getToolByName(toolName);
@@ -339,7 +340,7 @@ export class LanguageModelToolsExtensionPointHandler implements IWorkbenchContri
toolSets.push(toolSetObj);
continue;
}
extension.collector.warn(`Tool set '${toolSet.name}' CANNOT find tool or tool set by name: ${toolName}`);
missingToolNames.push(toolName);
}
if (toolSets.length === 0 && tools.length === 0) {
@@ -373,6 +374,30 @@ export class LanguageModelToolsExtensionPointHandler implements IWorkbenchContri
toolSets.forEach(toolSet => store.add(obj.addToolSet(toolSet, tx)));
});
// Listen for late-registered tools that weren't available at contribution time
if (missingToolNames.length > 0) {
const pending = new Set(missingToolNames);
const listener = store.add(languageModelToolsService.onDidChangeTools(() => {
for (const toolName of pending) {
const toolObj = languageModelToolsService.getToolByName(toolName);
if (toolObj) {
store.add(obj.addTool(toolObj));
pending.delete(toolName);
} else {
const toolSetObj = languageModelToolsService.getToolSetByName(toolName);
if (toolSetObj) {
store.add(obj.addToolSet(toolSetObj));
pending.delete(toolName);
}
}
}
if (pending.size === 0) {
// done
store.delete(listener);
}
}));
}
this._registrationDisposables.set(toToolSetKey(extension.description.identifier, toolSet.name), store);
}
}

View File

@@ -0,0 +1,320 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
import { CancellationToken } from '../../../../../../base/common/cancellation.js';
import { DisposableStore } from '../../../../../../base/common/lifecycle.js';
import { URI } from '../../../../../../base/common/uri.js';
import { Range } from '../../../../../../editor/common/core/range.js';
import { DefinitionProvider, ImplementationProvider, Location, ReferenceProvider } from '../../../../../../editor/common/languages.js';
import { ITextModel } from '../../../../../../editor/common/model.js';
import { LanguageFeaturesService } from '../../../../../../editor/common/services/languageFeaturesService.js';
import { IModelService } from '../../../../../../editor/common/services/model.js';
import { ITextModelService } from '../../../../../../editor/common/services/resolverService.js';
import { createTextModel } from '../../../../../../editor/test/common/testTextModel.js';
import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js';
import { FileMatch, ISearchComplete, ISearchService, ITextQuery, OneLineRange, TextSearchMatch } from '../../../../../services/search/common/search.js';
import { UsagesTool, UsagesToolId } from '../../../browser/tools/usagesTool.js';
import { IToolInvocation, IToolResult, IToolResultTextPart, ToolProgress } from '../../../common/tools/languageModelToolsService.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js';
function getTextContent(result: IToolResult): string {
const part = result.content.find((p): p is IToolResultTextPart => p.kind === 'text');
return part?.value ?? '';
}
suite('UsagesTool', () => {
const disposables = new DisposableStore();
let langFeatures: LanguageFeaturesService;
const testUri = URI.parse('file:///test/file.ts');
const testContent = [
'import { MyClass } from "./myClass";',
'',
'function doSomething() {',
'\tconst instance = new MyClass();',
'\tinstance.run();',
'}',
].join('\n');
function createMockModelService(models?: ITextModel[]): IModelService {
return {
_serviceBrand: undefined,
getModel: (uri: URI) => models?.find(m => m.uri.toString() === uri.toString()) ?? null,
} as unknown as IModelService;
}
function createMockSearchService(searchImpl?: (query: ITextQuery) => ISearchComplete): ISearchService {
return {
_serviceBrand: undefined,
textSearch: async (query: ITextQuery) => searchImpl?.(query) ?? { results: [], messages: [] },
} as unknown as ISearchService;
}
function createMockTextModelService(model: ITextModel): ITextModelService {
return {
_serviceBrand: undefined,
createModelReference: async () => ({
object: { textEditorModel: model },
dispose: () => { },
}),
registerTextModelContentProvider: () => ({ dispose: () => { } }),
canHandleResource: () => false,
} as unknown as ITextModelService;
}
function createMockWorkspaceService(): IWorkspaceContextService {
const folderUri = URI.parse('file:///test');
const folder = {
uri: folderUri,
toResource: (relativePath: string) => URI.parse(`file:///test/${relativePath}`),
} as unknown as IWorkspaceFolder;
return {
_serviceBrand: undefined,
getWorkspace: () => ({ folders: [folder] }),
getWorkspaceFolder: (uri: URI) => {
if (uri.toString().startsWith(folderUri.toString())) {
return folder;
}
return null;
},
} as unknown as IWorkspaceContextService;
}
function createInvocation(parameters: Record<string, unknown>): IToolInvocation {
return { parameters } as unknown as IToolInvocation;
}
const noopCountTokens = async () => 0;
const noopProgress: ToolProgress = { report() { } };
function createTool(textModelService: ITextModelService, workspaceService: IWorkspaceContextService, options?: { modelService?: IModelService; searchService?: ISearchService }): UsagesTool {
return new UsagesTool(langFeatures, options?.modelService ?? createMockModelService(), options?.searchService ?? createMockSearchService(), textModelService, workspaceService);
}
setup(() => {
langFeatures = new LanguageFeaturesService();
});
teardown(() => {
disposables.clear();
});
ensureNoDisposablesAreLeakedInTestSuite();
suite('getToolData', () => {
test('reports no providers when none registered', () => {
const tool = disposables.add(createTool(createMockTextModelService(null!), createMockWorkspaceService()));
const data = tool.getToolData();
assert.strictEqual(data.id, UsagesToolId);
assert.ok(data.modelDescription.includes('No languages currently have reference providers'));
});
test('lists registered language ids', () => {
const model = disposables.add(createTextModel('', 'typescript', undefined, testUri));
const tool = disposables.add(createTool(createMockTextModelService(model), createMockWorkspaceService()));
disposables.add(langFeatures.referenceProvider.register('typescript', { provideReferences: () => [] }));
const data = tool.getToolData();
assert.ok(data.modelDescription.includes('typescript'));
});
test('reports all languages for wildcard', () => {
const tool = disposables.add(createTool(createMockTextModelService(null!), createMockWorkspaceService()));
disposables.add(langFeatures.referenceProvider.register('*', { provideReferences: () => [] }));
const data = tool.getToolData();
assert.ok(data.modelDescription.includes('all languages'));
});
});
suite('invoke', () => {
test('returns error when no uri or filePath provided', async () => {
const tool = disposables.add(createTool(createMockTextModelService(null!), createMockWorkspaceService()));
const result = await tool.invoke(
createInvocation({ symbol: 'MyClass', lineContent: 'MyClass' }),
noopCountTokens, noopProgress, CancellationToken.None
);
assert.ok(getTextContent(result).includes('Provide either'));
});
test('returns error when line content not found', async () => {
const model = disposables.add(createTextModel(testContent, 'typescript', undefined, testUri));
disposables.add(langFeatures.referenceProvider.register('typescript', { provideReferences: () => [] }));
const tool = disposables.add(createTool(createMockTextModelService(model), createMockWorkspaceService()));
const result = await tool.invoke(
createInvocation({ symbol: 'MyClass', uri: testUri.toString(), lineContent: 'nonexistent line' }),
noopCountTokens, noopProgress, CancellationToken.None
);
assert.ok(getTextContent(result).includes('Could not find line content'));
});
test('returns error when symbol not found in line', async () => {
const model = disposables.add(createTextModel(testContent, 'typescript', undefined, testUri));
disposables.add(langFeatures.referenceProvider.register('typescript', { provideReferences: () => [] }));
const tool = disposables.add(createTool(createMockTextModelService(model), createMockWorkspaceService()));
const result = await tool.invoke(
createInvocation({ symbol: 'NotHere', uri: testUri.toString(), lineContent: 'function doSomething' }),
noopCountTokens, noopProgress, CancellationToken.None
);
assert.ok(getTextContent(result).includes('Could not find symbol'));
});
test('finds references and classifies them with usage tags', async () => {
const model = disposables.add(createTextModel(testContent, 'typescript', undefined, testUri));
const otherUri = URI.parse('file:///test/other.ts');
const refProvider: ReferenceProvider = {
provideReferences: (_model: ITextModel): Location[] => [
{ uri: testUri, range: new Range(1, 10, 1, 17) },
{ uri: testUri, range: new Range(4, 23, 4, 30) },
{ uri: otherUri, range: new Range(5, 1, 5, 8) },
]
};
const defProvider: DefinitionProvider = {
provideDefinition: () => [{ uri: testUri, range: new Range(1, 10, 1, 17) }]
};
const implProvider: ImplementationProvider = {
provideImplementation: () => [{ uri: otherUri, range: new Range(5, 1, 5, 8) }]
};
disposables.add(langFeatures.referenceProvider.register('typescript', refProvider));
disposables.add(langFeatures.definitionProvider.register('typescript', defProvider));
disposables.add(langFeatures.implementationProvider.register('typescript', implProvider));
// Model is open for testUri so IModelService returns it; otherUri needs search
const searchCalled: ITextQuery[] = [];
const searchService = createMockSearchService(query => {
searchCalled.push(query);
const fileMatch = new FileMatch(otherUri);
fileMatch.results = [new TextSearchMatch(
'export class MyClass implements IMyClass {',
new OneLineRange(4, 0, 7) // 0-based line 4 = 1-based line 5
)];
return { results: [fileMatch], messages: [] };
});
const modelService = createMockModelService([model]);
const tool = disposables.add(createTool(createMockTextModelService(model), createMockWorkspaceService(), { modelService, searchService }));
const result = await tool.invoke(
createInvocation({ symbol: 'MyClass', uri: testUri.toString(), lineContent: 'import { MyClass }' }),
noopCountTokens, noopProgress, CancellationToken.None
);
const text = getTextContent(result);
// Check overall structure
assert.ok(text.includes('3 usages of `MyClass`'));
// Check usage tag format
assert.ok(text.includes(`<usage type="definition" uri="${testUri.toString()}" line="1">`));
assert.ok(text.includes(`<usage type="reference" uri="${testUri.toString()}" line="4">`));
assert.ok(text.includes(`<usage type="implementation" uri="${otherUri.toString()}" line="5">`));
// Check that previews from open model are included (testUri lines)
assert.ok(text.includes('import { MyClass } from "./myClass"'));
assert.ok(text.includes('const instance = new MyClass()'));
// Check that preview from search service is included (otherUri)
assert.ok(text.includes('export class MyClass implements IMyClass {'));
// Check closing tags
assert.ok(text.includes('</usage>'));
// Verify search service was called for the non-open file
assert.strictEqual(searchCalled.length, 1);
assert.ok(searchCalled[0].contentPattern.pattern.includes('MyClass'));
assert.ok(searchCalled[0].contentPattern.isWordMatch);
});
test('uses self-closing tag when no preview available', async () => {
const model = disposables.add(createTextModel(testContent, 'typescript', undefined, testUri));
const otherUri = URI.parse('file:///test/other.ts');
disposables.add(langFeatures.referenceProvider.register('typescript', {
provideReferences: (): Location[] => [
{ uri: otherUri, range: new Range(10, 5, 10, 12) },
]
}));
// Search returns no results for this file (symbol renamed/aliased)
const searchService = createMockSearchService(() => ({ results: [], messages: [] }));
const tool = disposables.add(createTool(createMockTextModelService(model), createMockWorkspaceService(), { searchService }));
const result = await tool.invoke(
createInvocation({ symbol: 'MyClass', uri: testUri.toString(), lineContent: 'import { MyClass }' }),
noopCountTokens, noopProgress, CancellationToken.None
);
const text = getTextContent(result);
assert.ok(text.includes(`<usage type="reference" uri="${otherUri.toString()}" line="10" />`));
});
test('does not call search service for files already open in model service', async () => {
const model = disposables.add(createTextModel(testContent, 'typescript', undefined, testUri));
disposables.add(langFeatures.referenceProvider.register('typescript', {
provideReferences: (): Location[] => [
{ uri: testUri, range: new Range(1, 10, 1, 17) },
]
}));
let searchCalled = false;
const searchService = createMockSearchService(() => {
searchCalled = true;
return { results: [], messages: [] };
});
const modelService = createMockModelService([model]);
const tool = disposables.add(createTool(createMockTextModelService(model), createMockWorkspaceService(), { modelService, searchService }));
const result = await tool.invoke(
createInvocation({ symbol: 'MyClass', uri: testUri.toString(), lineContent: 'import { MyClass }' }),
noopCountTokens, noopProgress, CancellationToken.None
);
assert.ok(getTextContent(result).includes('1 usages'));
assert.strictEqual(searchCalled, false, 'search service should not be called when all files are open');
});
test('handles whitespace normalization in lineContent', async () => {
const content = 'function doSomething(x: number) {}';
const model = disposables.add(createTextModel(content, 'typescript', undefined, testUri));
disposables.add(langFeatures.referenceProvider.register('typescript', {
provideReferences: (): Location[] => [
{ uri: testUri, range: new Range(1, 12, 1, 23) },
]
}));
const tool = disposables.add(createTool(createMockTextModelService(model), createMockWorkspaceService()));
const result = await tool.invoke(
createInvocation({ symbol: 'doSomething', uri: testUri.toString(), lineContent: 'function doSomething(x: number)' }),
noopCountTokens, noopProgress, CancellationToken.None
);
assert.ok(getTextContent(result).includes('1 usages'));
});
test('resolves filePath via workspace folders', async () => {
const fileUri = URI.parse('file:///test/src/file.ts');
const model = disposables.add(createTextModel(testContent, 'typescript', undefined, fileUri));
disposables.add(langFeatures.referenceProvider.register('typescript', {
provideReferences: (): Location[] => [
{ uri: fileUri, range: new Range(1, 10, 1, 17) },
]
}));
const tool = disposables.add(createTool(createMockTextModelService(model), createMockWorkspaceService()));
const result = await tool.invoke(
createInvocation({ symbol: 'MyClass', filePath: 'src/file.ts', lineContent: 'import { MyClass }' }),
noopCountTokens, noopProgress, CancellationToken.None
);
assert.ok(getTextContent(result).includes('1 usages'));
});
});
});