Path: blob/main/src/vs/workbench/contrib/chat/browser/tools/renameTool.ts
13406 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { CancellationToken } from '../../../../../base/common/cancellation.js';6import { Codicon } from '../../../../../base/common/codicons.js';7import { Emitter, Event } from '../../../../../base/common/event.js';8import { MarkdownString } from '../../../../../base/common/htmlContent.js';9import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js';10import { ResourceMap, ResourceSet } from '../../../../../base/common/map.js';11import { ThemeIcon } from '../../../../../base/common/themables.js';12import { Position } from '../../../../../editor/common/core/position.js';13import { TextEdit } from '../../../../../editor/common/languages.js';14import { IBulkEditService, ResourceTextEdit } from '../../../../../editor/browser/services/bulkEditService.js';15import { ILanguageFeaturesService } from '../../../../../editor/common/services/languageFeatures.js';16import { ITextModelService } from '../../../../../editor/common/services/resolverService.js';17import { ILanguageService } from '../../../../../editor/common/languages/language.js';18import { rename } from '../../../../../editor/contrib/rename/browser/rename.js';19import { localize } from '../../../../../nls.js';20import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';21import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js';22import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';23import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js';24import { IWorkbenchContribution } from '../../../../common/contributions.js';25import { IChatService } from '../../common/chatService/chatService.js';26import { ChatConfiguration } from '../../common/constants.js';27import { ChatModel } from '../../common/model/chatModel.js';28import { CountTokensCallback, ILanguageModelToolsService, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolResult, ToolDataSource, ToolProgress } from '../../common/tools/languageModelToolsService.js';29import { createToolSimpleTextResult } from '../../common/tools/builtinTools/toolHelpers.js';30import { errorResult, findLineNumber, findSymbolColumn, ISymbolToolInput, resolveToolUri } from './toolHelpers.js';3132export const RenameToolId = 'vscode_renameSymbol';3334interface IRenameToolInput extends ISymbolToolInput {35newName: string;36}3738const BaseModelDescription = `Rename a code symbol across the workspace using the language server's rename functionality. This performs a precise, semantics-aware rename that updates all references.3940Input:41- "symbol": The exact current name of the symbol to rename.42- "newName": The new name for the symbol.43- "uri": A full URI (e.g. "file:///path/to/file.ts") of a file where the symbol appears. Provide either "uri" or "filePath".44- "filePath": A workspace-relative file path (e.g. "src/utils/helpers.ts") of a file where the symbol appears. Provide either "uri" or "filePath".45- "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.4647IMPORTANT: 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.4849If 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.`;5051/**52* Static description used when the {@link ChatConfiguration.SymbolToolsCacheStable}53* experiment is enabled. Identical to {@link BaseModelDescription} plus a single54* sentence describing the unsupported-language behavior. Crucially, this string55* does NOT depend on the set of registered rename providers, so it stays56* byte-stable across requests as language extensions activate during a turn.57*/58const StaticModelDescription = BaseModelDescription + `5960If the file's language has no rename provider registered, the tool returns an error.`;6162export class RenameTool extends Disposable implements IToolImpl {6364private readonly _onDidUpdateToolData = this._store.add(new Emitter<void>());65readonly onDidUpdateToolData = this._onDidUpdateToolData.event;6667constructor(68@ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService,69@ILanguageService private readonly _languageService: ILanguageService,70@ITextModelService private readonly _textModelService: ITextModelService,71@IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService,72@IChatService private readonly _chatService: IChatService,73@IBulkEditService private readonly _bulkEditService: IBulkEditService,74@IConfigurationService private readonly _configurationService: IConfigurationService,75) {76super();7778// In cache-stable mode the tool's wire bytes don't depend on the set79// of registered rename providers, so we don't need to re-fire the80// update event on provider changes. Skipping this subscription81// avoids unnecessary tool re-registration churn as well.82if (!this._isCacheStable()) {83this._store.add(Event.debounce(84this._languageFeaturesService.renameProvider.onDidChange,85() => { },86200087)((() => this._onDidUpdateToolData.fire())));88}89}9091private _isCacheStable(): boolean {92return this._configurationService.getValue<boolean>(ChatConfiguration.SymbolToolsCacheStable) === true;93}9495getToolData(): IToolData | undefined {96if (this._isCacheStable()) {97return this._getStaticToolData();98}99100const languageIds = this._languageFeaturesService.renameProvider.registeredLanguageIds;101102if (languageIds.size === 0) {103return undefined;104}105106let modelDescription = BaseModelDescription;107let userDescription: string;108if (languageIds.has('*')) {109modelDescription += '\n\nSupported for all languages.';110userDescription = localize('tool.rename.userDescription', 'Rename a symbol across the workspace');111} else {112const sorted = [...languageIds].sort();113modelDescription += `\n\nCurrently supported for: ${sorted.join(', ')}.`;114const niceNames = sorted.map(id => this._languageService.getLanguageName(id) ?? id);115userDescription = localize('tool.rename.userDescriptionWithLanguages', 'Rename a symbol across the workspace ({0})', niceNames.join(', '));116}117return this._buildToolData(modelDescription, userDescription);118}119120private _getStaticToolData(): IToolData {121return this._buildToolData(122StaticModelDescription,123localize('tool.rename.userDescription', 'Rename a symbol across the workspace'),124);125}126127private _buildToolData(modelDescription: string, userDescription: string): IToolData {128return {129id: RenameToolId,130toolReferenceName: 'rename',131canBeReferencedInPrompt: false,132icon: ThemeIcon.fromId(Codicon.rename.id),133displayName: localize('tool.rename.displayName', 'Rename Symbol'),134userDescription,135modelDescription,136source: ToolDataSource.Internal,137when: ContextKeyExpr.has('config.chat.tools.renameTool.enabled'),138inputSchema: {139type: 'object',140properties: {141symbol: {142type: 'string',143description: 'The exact current name of the symbol to rename.'144},145newName: {146type: 'string',147description: 'The new name for the symbol.'148},149uri: {150type: 'string',151description: 'A full URI of a file where the symbol appears (e.g. "file:///path/to/file.ts"). Provide either "uri" or "filePath".'152},153filePath: {154type: 'string',155description: 'A workspace-relative file path where the symbol appears (e.g. "src/utils/helpers.ts"). Provide either "uri" or "filePath".'156},157lineContent: {158type: 'string',159description: 'A substring of the line of code where the symbol appears. Used to locate the exact position. Must be actual text from the file.'160}161},162required: ['symbol', 'newName', 'lineContent']163}164};165}166167async prepareToolInvocation(context: IToolInvocationPreparationContext, _token: CancellationToken): Promise<IPreparedToolInvocation | undefined> {168const input = context.parameters as IRenameToolInput;169return {170invocationMessage: localize('tool.rename.invocationMessage', 'Renaming `{0}` to `{1}`', input.symbol, input.newName),171};172}173174async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, token: CancellationToken): Promise<IToolResult> {175const input = invocation.parameters as IRenameToolInput;176177// --- resolve URI ---178const uri = resolveToolUri(input, this._workspaceContextService);179if (!uri) {180return errorResult('Provide either "uri" (a full URI) or "filePath" (a workspace-relative path) to identify the file.');181}182183// --- open text model ---184const ref = await this._textModelService.createModelReference(uri);185try {186const model = ref.object.textEditorModel;187188if (!this._languageFeaturesService.renameProvider.has(model)) {189return errorResult(`No rename provider available for this file's language. The rename tool may not support this language.`);190}191192// --- find line containing lineContent ---193const lineNumber = findLineNumber(model, input.lineContent);194if (lineNumber === undefined) {195return errorResult(`Could not find line content "${input.lineContent}" in ${uri.toString()}. Provide the exact text from the line where the symbol appears.`);196}197198// --- find symbol in that line ---199const lineText = model.getLineContent(lineNumber);200const column = findSymbolColumn(lineText, input.symbol);201if (column === undefined) {202return errorResult(`Could not find symbol "${input.symbol}" in the matched line. Ensure the symbol name is correct and appears in the provided line content.`);203}204205const position = new Position(lineNumber, column);206207// --- perform rename ---208const renameResult = await rename(this._languageFeaturesService.renameProvider, model, position, input.newName);209210if (renameResult.rejectReason) {211return errorResult(`Rename rejected: ${renameResult.rejectReason}`);212}213214if (renameResult.edits.length === 0) {215return errorResult(`Rename produced no edits.`);216}217218// --- apply edits via chat response stream ---219if (invocation.context) {220const chatModel = this._chatService.getSession(invocation.context.sessionResource) as ChatModel | undefined;221const request = chatModel?.getRequests().at(-1);222223if (chatModel && request) {224// Group text edits by URI225const editsByUri = new ResourceMap<TextEdit[]>();226for (const edit of renameResult.edits) {227if (ResourceTextEdit.is(edit)) {228let edits = editsByUri.get(edit.resource);229if (!edits) {230edits = [];231editsByUri.set(edit.resource, edits);232}233edits.push(edit.textEdit);234}235}236237// Push edits through the chat response stream238for (const [editUri, edits] of editsByUri) {239chatModel.acceptResponseProgress(request, {240kind: 'textEdit',241uri: editUri,242edits: [],243});244chatModel.acceptResponseProgress(request, {245kind: 'textEdit',246uri: editUri,247edits,248});249chatModel.acceptResponseProgress(request, {250kind: 'textEdit',251uri: editUri,252edits: [],253done: true,254});255}256257return this._successResult(input, editsByUri.size, renameResult.edits.length);258}259}260261// Fallback: apply via bulk edit service when no chat context is available262await this._bulkEditService.apply(renameResult);263const fileCount = new ResourceSet(renameResult.edits.filter(ResourceTextEdit.is).map(e => e.resource)).size;264return this._successResult(input, fileCount, renameResult.edits.length);265266} finally {267ref.dispose();268}269}270271private _successResult(input: IRenameToolInput, fileCount: number, editCount: number): IToolResult {272const text = editCount === 1273? localize('tool.rename.oneEdit', "Renamed `{0}` to `{1}` - 1 edit in {2} file.", input.symbol, input.newName, fileCount)274: localize('tool.rename.edits', "Renamed `{0}` to `{1}` - {2} edits across {3} files.", input.symbol, input.newName, editCount, fileCount);275const result = createToolSimpleTextResult(text);276result.toolResultMessage = new MarkdownString(text);277return result;278}279280}281282283284export class RenameToolContribution extends Disposable implements IWorkbenchContribution {285286static readonly ID = 'chat.renameTool';287288constructor(289@ILanguageModelToolsService toolsService: ILanguageModelToolsService,290@IInstantiationService instantiationService: IInstantiationService,291) {292super();293294const renameTool = this._store.add(instantiationService.createInstance(RenameTool));295296let registration: IDisposable | undefined;297const registerRenameTool = () => {298registration?.dispose();299registration = undefined;300toolsService.flushToolUpdates();301const toolData = renameTool.getToolData();302if (toolData) {303registration = toolsService.registerTool(toolData, renameTool);304}305};306registerRenameTool();307this._store.add(renameTool.onDidUpdateToolData(registerRenameTool));308this._store.add({309dispose: () => {310registration?.dispose();311}312});313}314}315316317