Path: blob/main/extensions/copilot/src/extension/renameSuggestions/node/renameSuggestionsProvider.ts
13399 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 type * as vscode from 'vscode';6import { IAuthenticationService } from '../../../platform/authentication/common/authentication';7import { ChatFetchResponseType, ChatLocation } from '../../../platform/chat/common/commonTypes';8import { IInteractionService } from '../../../platform/chat/common/interactionService';9import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';10import { TextDocumentSnapshot } from '../../../platform/editing/common/textDocumentSnapshot';11import { IEndpointProvider } from '../../../platform/endpoint/common/endpointProvider';12import { IIgnoreService } from '../../../platform/ignore/common/ignoreService';13import { IChatEndpoint } from '../../../platform/networking/common/networking';14import { INotificationService } from '../../../platform/notification/common/notificationService';15import { ISimulationTestContext } from '../../../platform/simulationTestContext/common/simulationTestContext';16import { ITelemetryService } from '../../../platform/telemetry/common/telemetry';17import { StopWatch } from '../../../util/vs/base/common/stopwatch';18import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation';19import { NewSymbolName, NewSymbolNameTag, NewSymbolNameTriggerKind } from '../../../vscodeTypes';20import { PromptRenderer } from '../../prompts/node/base/promptRenderer';21import { enforceNamingConvention, guessNamingConvention, NamingConvention } from '../common/namingConvention';22import { RenameSuggestionsPrompt } from './renameSuggestionsPrompt';2324/**25* The format of the reply from the model.26*/27type ReplyFormat =28/** When the reply was a JSON array of strings as instructed in the prompt */29| 'jsonStringArray'3031/** When there were multiple JSON array's matched by the regex */32| 'multiJsonStringArray'3334/** When the reply was an ordered or unordered list */35| 'list'3637/** When we couldn't parse the response */38| 'unknown'39;4041enum ProvideCallCancellationReason {42None = '',43AfterEnablementCheck = 'afterEnablementCheck',44AfterRunParametersFetch = 'afterRunParametersFetch',45AfterPromptCompute = 'afterPromptCompute',46AfterDelay = 'afterDelay',47AfterFetchStarted = 'afterFetchStarted',48}4950export class RenameSuggestionsProvider implements vscode.NewSymbolNamesProvider {5152public readonly supportsAutomaticTriggerKind: Promise<boolean>;5354constructor(55@IInstantiationService private readonly _instaService: IInstantiationService,56@IIgnoreService private readonly _ignoreService: IIgnoreService,57@ITelemetryService private readonly _telemetryService: ITelemetryService,58@IConfigurationService private readonly _configurationService: IConfigurationService,59@IEndpointProvider private readonly _endpointProvider: IEndpointProvider,60@ISimulationTestContext private readonly _simulationTestContext: ISimulationTestContext,61@IAuthenticationService private readonly _authService: IAuthenticationService,62@INotificationService private readonly _notificationService: INotificationService,63@IInteractionService private readonly _interactionService: IInteractionService64) {6566this.supportsAutomaticTriggerKind = Promise.resolve(this.isEnabled(NewSymbolNameTriggerKind.Automatic));67}6869protected isEnabled(triggerKind: NewSymbolNameTriggerKind) {70if (triggerKind === NewSymbolNameTriggerKind.Invoke) {71return true;72} else if (this._authService.copilotToken?.isFreeUser || this._authService.copilotToken?.isNoAuthUser) {73return false;74} else {75return this._configurationService.getConfig(ConfigKey.AutomaticRenameSuggestions);76}77}7879/**80* @throws {Error} with `message = 'CopilotFeatureUnavailableOrDisabled' if the feature is not available81* @throws {Error} with `message = 'CopilotIgnoredDocument' if the document is Copilot-ignored82*/83async provideNewSymbolNames(_document: vscode.TextDocument, range: vscode.Range, triggerKind: NewSymbolNameTriggerKind, token: vscode.CancellationToken): Promise<NewSymbolName[] | null> {84const document = TextDocumentSnapshot.create(_document);8586let cancellationReason: ProvideCallCancellationReason = ProvideCallCancellationReason.None;8788const beforeDelaySW = new StopWatch();8990// @ulugbekna: capture the symbol name that is being renamed before an await to avoid document being changed under us91const currentSymbolName = document.getText(range);9293if (!this.isEnabled(triggerKind)) {94throw new Error('CopilotFeatureUnavailableOrDisabled');95}9697if (await this._ignoreService.isCopilotIgnored(document.uri)) {98throw new Error('CopilotIgnoredDocument');99}100101const languageId = document.languageId;102103let expectedDelayBeforeFetch: number | undefined;104let timeElapsedBeforeDelay: number | undefined;105106if (token.isCancellationRequested) {107cancellationReason = ProvideCallCancellationReason.AfterEnablementCheck;108} else {109const endpoint = await this._endpointProvider.getChatEndpoint('copilot-fast');110expectedDelayBeforeFetch = this.delayBeforeFetchMs;111112if (token.isCancellationRequested) {113cancellationReason = ProvideCallCancellationReason.AfterRunParametersFetch;114} else {115116const sw = new StopWatch(false);117118sw.reset();119const promptRenderResult = await this._computePrompt(document, range, endpoint, token);120const promptConstructionTime = sw.elapsed();121122if (token.isCancellationRequested) {123cancellationReason = ProvideCallCancellationReason.AfterPromptCompute;124} else {125126timeElapsedBeforeDelay = beforeDelaySW.elapsed();127128let actualDelayBeforeFetch: number | undefined;129if (triggerKind === NewSymbolNameTriggerKind.Automatic) {130actualDelayBeforeFetch = expectedDelayBeforeFetch ? Math.max(0, expectedDelayBeforeFetch - timeElapsedBeforeDelay) : undefined;131if (actualDelayBeforeFetch !== undefined && actualDelayBeforeFetch > 0) {132await new Promise(resolve => setTimeout(resolve, actualDelayBeforeFetch));133}134}135136if (token.isCancellationRequested) {137cancellationReason = ProvideCallCancellationReason.AfterDelay;138} else {139140sw.reset();141this._interactionService.startInteraction();142const fetchResult = await endpoint.makeChatRequest(143'renameSuggestionsProvider',144promptRenderResult.messages,145undefined, // TODO@ulugbekna: should we terminate on `]` (closing for JSON array that we expect to receive from the model)146token,147ChatLocation.Other,148undefined,149{150top_p: undefined,151temperature: undefined152},153true154);155const fetchTime = sw.elapsed();156157if (fetchResult.type === ChatFetchResponseType.QuotaExceeded || (fetchResult.type === ChatFetchResponseType.RateLimited && this._authService.copilotToken?.isNoAuthUser)) {158await this._notificationService.showQuotaExceededDialog({ isNoAuthUser: this._authService.copilotToken?.isNoAuthUser ?? false });159}160161if (token.isCancellationRequested) {162cancellationReason = ProvideCallCancellationReason.AfterFetchStarted;163}164165switch (fetchResult.type) {166case ChatFetchResponseType.Success: {167const reply = fetchResult.value;168const { replyFormat, symbolNames, redundantCharCount: responseUnusedCharCount } = RenameSuggestionsProvider.parseResponse(reply);169if (replyFormat === 'unknown') {170this._sendInternalTelemetry({ languageId, reply });171}172this._sendPublicTelemetry({173triggerKind,174languageId,175cancellationReason,176fetchResultType: fetchResult.type,177promptConstructionTime,178promptTokenCount: promptRenderResult.tokenCount,179expectedDelayBeforeFetch,180actualDelayBeforeFetch,181timeElapsedBeforeDelay,182successResponseCharCount: reply.length,183responseUnusedCharCount,184fetchTime,185replyFormat,186symbolNamesCount: symbolNames.length,187});188189const processedSymbolNames = RenameSuggestionsProvider.preprocessSymbolNames({ currentSymbolName, newSymbolNames: symbolNames, languageId });190return processedSymbolNames.map(symbolName => new NewSymbolName(symbolName, [NewSymbolNameTag.AIGenerated]));191}192default: {193this._sendPublicTelemetry({194triggerKind,195languageId,196cancellationReason,197fetchResultType: fetchResult.type,198promptConstructionTime,199promptTokenCount: promptRenderResult.tokenCount,200expectedDelayBeforeFetch,201actualDelayBeforeFetch,202timeElapsedBeforeDelay,203fetchTime,204});205return null;206}207}208}209}210}211}212213this._sendPublicTelemetry({214triggerKind,215languageId,216cancellationReason,217expectedDelayBeforeFetch,218timeElapsedBeforeDelay,219});220return null;221}222223/**224* The delay before fetching from the model.225*/226private get delayBeforeFetchMs() {227if (this._simulationTestContext.isInSimulationTests) {228return 0;229} else {230const DELAY_BEFORE_FETCH = 250 /* milliseconds */;231return DELAY_BEFORE_FETCH;232}233}234235// @ulugbekna: notes:236// - FIXME: currently, we fail with very large definitions such as big classes or functions -- we need summarization by category, e.g., remove method implementations if we're renaming a class237// - idea: include hover info (i.e., usually type info & corresponding document) of the symbol being renamed in the prompt238// - idea: include usages of the symbol being renamed in the prompt239// - idea: include peer symbols (e.g., other methods in the same class) in the prompt for copilot to see conventions in the code240private _computePrompt(document: TextDocumentSnapshot, range: vscode.Range, chatEndpoint: IChatEndpoint, token: vscode.CancellationToken) {241const promptRenderer = PromptRenderer.create(242this._instaService,243chatEndpoint,244RenameSuggestionsPrompt,245{246document,247range248}249);250return promptRenderer.render(undefined, token);251}252253public static preprocessSymbolNames({ currentSymbolName, newSymbolNames, languageId }: { currentSymbolName: string; newSymbolNames: string[]; languageId: string }): string[] {254255const currentNameConvention = guessNamingConvention(currentSymbolName);256257let targetNamingConvention: NamingConvention;258switch (currentNameConvention) {259case NamingConvention.LowerCase:260if (languageId === 'python') {261targetNamingConvention = NamingConvention.SnakeCase;262} else {263targetNamingConvention = NamingConvention.CamelCase;264}265break;266case NamingConvention.Uppercase:267case NamingConvention.CamelCase:268case NamingConvention.PascalCase:269case NamingConvention.SnakeCase:270case NamingConvention.ScreamingSnakeCase:271case NamingConvention.CapitalSnakeCase:272case NamingConvention.KebabCase:273case NamingConvention.Capitalized:274case NamingConvention.Unknown:275targetNamingConvention = currentNameConvention;276break;277default: {278const _exhaustiveCheck: never = currentNameConvention;279return _exhaustiveCheck;280}281}282283if (targetNamingConvention === NamingConvention.Unknown) {284return newSymbolNames;285}286287return newSymbolNames.map(newSymbolName => enforceNamingConvention(newSymbolName, targetNamingConvention));288}289290public static parseResponse(reply: string): { replyFormat: ReplyFormat; redundantCharCount: number; symbolNames: string[] } {291292const parsedAsJSONStringArray = RenameSuggestionsProvider._parseReplyAsJSONStringArray(reply);293if (parsedAsJSONStringArray !== undefined) {294return parsedAsJSONStringArray;295}296297const parsedAsList = RenameSuggestionsProvider._parseReplyAsList(reply);298if (parsedAsList !== undefined) {299return parsedAsList;300}301302return { replyFormat: 'unknown', symbolNames: [], redundantCharCount: reply.length };303}304305/** try extracting from JSON string array */306private static _parseReplyAsJSONStringArray(reply: string) {307308const jsonArrayRe = /\[.*?\]/gs; // `s` regex flag allows matching newlines using `.`309310const matches = [...reply.matchAll(jsonArrayRe)];311312for (let i = 0; i < matches.length; i++) {313const match = matches[i];314try {315const parsedJSONArray: unknown = JSON.parse(match[0]);316317if (Array.isArray(parsedJSONArray)) {318319const symbolNames = parsedJSONArray.filter(v => typeof v === 'string');320321if (symbolNames.length > 0) {322const replyFormat: ReplyFormat = i === 0 ? 'jsonStringArray' : 'multiJsonStringArray';323const redundantCharCount = reply.length - match[0].length;324return { replyFormat, redundantCharCount, symbolNames: symbolNames.map(s => s.trim()) } as const;325}326}327} catch (error) {328}329}330}331332private static _parseReplyAsList(reply: string) {333// try extracting from an ordered or unordered list334const listLineRe = /(?:\d+[\.|\)]|[\*\-])\s*(.*)/g;335const matches = reply.matchAll(listLineRe);336337const symbolNames: string[] = [];338for (const match of matches) {339let symbolName = match[1].trim();340const punctuation = ['\'', '"', '`'];341if (punctuation.includes(symbolName[0])) {342symbolName = symbolName.slice(1);343}344if (punctuation.includes(symbolName[symbolName.length - 1])) {345symbolName = symbolName.slice(0, -1);346}347if (symbolName) {348symbolNames.push(symbolName);349}350}351352if (symbolNames.length === 0) {353return;354}355356const redundantCharCount = reply.length - symbolNames.reduce((acc, name) => acc + name.length, 0);357358return { replyFormat: 'list' satisfies ReplyFormat, redundantCharCount, symbolNames } as const;359}360361private _sendPublicTelemetry({362triggerKind,363languageId,364cancellationReason,365fetchResultType,366timeElapsedBeforeDelay,367promptConstructionTime,368promptTokenCount,369expectedDelayBeforeFetch,370actualDelayBeforeFetch,371successResponseCharCount,372responseUnusedCharCount,373fetchTime,374replyFormat,375symbolNamesCount376}: {377triggerKind: NewSymbolNameTriggerKind;378languageId: string;379cancellationReason: ProvideCallCancellationReason;380fetchResultType?: ChatFetchResponseType;381timeElapsedBeforeDelay?: number;382promptConstructionTime?: number;383promptTokenCount?: number;384fetchTime?: number;385expectedDelayBeforeFetch?: number;386actualDelayBeforeFetch?: number;387successResponseCharCount?: number;388responseUnusedCharCount?: number;389replyFormat?: ReplyFormat;390symbolNamesCount?: number;391}) {392/* __GDPR__393"provideRenameSuggestions" : {394"owner": "ulugbekna",395"comment": "Telemetry for rename suggestions provided",396"languageId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Language ID of the document." },397"cancellationReason": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Specify when exactly during the provider call the cancellation happened. Empty string if the cancellation didn't happen." },398"fetchResultType": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Result of a fetch to endpoint" },399"replyFormat": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Copilot reply format: 'jsonStringArray' | 'multiJsonStringArray' | 'list' | 'unknown'" },400"triggerKind": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Rename suggestion trigger kind - 'automatic' | 'manual'" },401"promptConstructionTime": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Time it took to construct the prompt", "isMeasurement": true },402"timeElapsedBeforeDelay": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Time elapsed before delay starts", "isMeasurement": true },403"promptTokenCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Token count of the prompt", "isMeasurement": true },404"expectedDelayBeforeFetch": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Expected delay before fetch dictated by the experiment 'renameSuggestionsDelayBeforeFetch'", "isMeasurement": true },405"actualDelayBeforeFetch": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Actual delay before fetch computed as 'expectedDelay - promptComputationTime'", "isMeasurement": true },406"successResponseCharCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Character count in model response (for response.type == 'success')", "isMeasurement": true },407"responseUnusedCharCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Character count in model response that was unused, e.g., rename explanations, response format overhead", "isMeasurement": true },408"fetchTime": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Time it took to fetch from endpoint", "isMeasurement": true },409"symbolNamesCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of suggested names", "isMeasurement": true }410}411*/412this._telemetryService.sendMSFTTelemetryEvent(413'provideRenameSuggestions',414{415languageId,416cancellationReason,417fetchResultType,418replyFormat,419triggerKind: triggerKind === NewSymbolNameTriggerKind.Automatic ? 'automatic' : 'manual',420},421{422promptConstructionTime,423promptTokenCount,424expectedDelayBeforeFetch,425actualDelayBeforeFetch,426timeElapsedBeforeFetch: timeElapsedBeforeDelay,427fetchTime,428successResponseCharCount,429responseUnusedCharCount,430symbolNamesCount,431}432);433}434435private _sendInternalTelemetry({ languageId, reply }: { languageId: string; reply: string }) {436this._telemetryService.sendMSFTTelemetryEvent(437'provideRenameSuggestionsIncorrectFormatResponse',438{439languageId,440reply441}442);443}444445public static _determinePrefix(name: string): string | undefined {446const prefix = name.match(/^([\\.\\$\\_]+)/)?.[0];447return prefix;448}449}450451452