diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 294c767c075e58..4f377ddbea272c 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -3879,7 +3879,18 @@ "default": true } }, - "github.copilot.chat.languageContext.typescript.items": { + "github.copilot.chat.languageContext.typescript7.enabled": { + "type": "boolean", + "default": false, + "scope": "resource", + "tags": [ + "experimental" + ], + "markdownDescription": "%github.copilot.chat.languageContext.typescript7.enabled%", + "agentsWindow": { + "default": false + } + }, "github.copilot.chat.languageContext.typescript.items": { "type": "string", "enum": [ "minimal", diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json index a26edcc115607e..32e331e931b9f2 100644 --- a/extensions/copilot/package.nls.json +++ b/extensions/copilot/package.nls.json @@ -253,6 +253,7 @@ "github.copilot.walkthrough.sparkle.media.altText": "The video shows the sparkle icon in the source control input box being clicked, triggering GitHub Copilot to generate a commit message automatically", "github.copilot.chat.completionContext.typescript.mode": "The execution mode of the TypeScript Copilot context provider.", "github.copilot.chat.languageContext.typescript.enabled": "Enables the TypeScript language context provider for inline suggestions", + "github.copilot.chat.languageContext.typescript7.enabled": "Enables the TypeScript language context provider for inline suggestions when using TS7 language services", "github.copilot.chat.languageContext.typescript.items": "Controls which kind of items are included in the TypeScript language context provider.", "github.copilot.chat.languageContext.typescript.includeDocumentation": "Controls whether to include documentation comments in the generated code snippets.", "github.copilot.chat.languageContext.typescript.cacheTimeout": "The cache population timeout for the TypeScript language context provider in milliseconds. The default is 500 milliseconds.", diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/languageContextService.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/languageContextService.ts index 1b68fa36c0b575..30d94eff4d5034 100644 --- a/extensions/copilot/src/extension/typescriptContext/vscode-node/languageContextService.ts +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/languageContextService.ts @@ -5,7 +5,6 @@ import * as vscode from 'vscode'; -import { LRUCache } from 'lru-cache'; import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; import { Copilot } from '../../../platform/inlineCompletions/common/api'; import { ILanguageContextProviderService, ProviderTarget } from '../../../platform/languageContextProvider/common/languageContextProviderService'; @@ -14,1595 +13,122 @@ import { ILogService } from '../../../platform/log/common/logService'; import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService'; import { ITelemetryService } from '../../../platform/telemetry/common/telemetry'; import { Queue } from '../../../util/vs/base/common/async'; -import { CancellationToken } from '../../../util/vs/base/common/cancellation'; import { DisposableStore } from '../../../util/vs/base/common/lifecycle'; import { generateUuid } from '../../../util/vs/base/common/uuid'; -import * as protocol from '../common/serverProtocol'; import { InspectorDataProvider } from './inspector'; import { ThrottledDebouncer } from './throttledDebounce'; -import { ContextItemResultBuilder, ContextItemSummary, ResolvedRunnableResult, type OnCachePopulatedEvent, type OnContextComputedEvent, type OnContextComputedOnTimeoutEvent } from './types'; - -const currentTokenBudget: number = 8 * 1024; - -enum ExecutionTarget { - Semantic, - Syntax -} - -type ExecConfig = { - readonly lowPriority?: boolean; - readonly nonRecoverable?: boolean; - readonly cancelOnResourceChange?: vscode.Uri; - readonly executionTarget?: ExecutionTarget; -}; - -enum ErrorLocation { - Client = 'client', - Server = 'server' -} - -enum ErrorPart { - ServerPlugin = 'server-plugin', - TypescriptPlugin = 'typescript-plugin', - CopilotExtension = 'copilot-extension' -} - -interface TypeScriptServerError extends Error { - response: { - type: 'response'; - command: string; - message: string; - }; - version: { - displayName: string; - }; -} -namespace TypeScriptServerError { - export function is(value: Error): value is TypeScriptServerError { - const candidate = value as TypeScriptServerError; - return candidate instanceof Error && candidate.response !== undefined && candidate.version !== undefined && typeof candidate.version.displayName === 'string'; - } -} - -namespace RequestContext { - export function getSampleTelemetry(context: RequestContext): number { - return Math.max(1, Math.min(100, context.sampleTelemetry ?? 1)); - } -} - -class TelemetrySender { - - private readonly telemetryService: ITelemetryService; - private readonly logService: ILogService; - private sendRequestTelemetryCounter: number; - private sendSpeculativeRequestTelemetryCounter: number; - - constructor(telemetryService: ITelemetryService, logService: ILogService) { - this.telemetryService = telemetryService; - this.logService = logService; - this.sendRequestTelemetryCounter = 0; - this.sendSpeculativeRequestTelemetryCounter = 0; - } - - public sendSpeculativeRequestTelemetry(context: RequestContext, originalRequestId: string, numberOfItems: number): void { - const sampleTelemetry = RequestContext.getSampleTelemetry(context); - const shouldSendTelemetry = sampleTelemetry === 1 || this.sendSpeculativeRequestTelemetryCounter % sampleTelemetry === 0; - this.sendSpeculativeRequestTelemetryCounter++; - - if (shouldSendTelemetry) { - /* __GDPR__ - "typescript-context-plugin.completion-context.speculative" : { - "owner": "dirkb", - "comment": "Telemetry for copilot inline completion context", - "requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The request correlation id" }, - "source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The source of the request" }, - "originalRequestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The original request id for which this is a speculative request" }, - "numberOfItems": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of items in the speculative request", "isMeasurement": true }, - "sampleTelemetry": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The sampling rate for telemetry. A value of 1 means every request is logged, a value of 5 means every 5th request is logged, etc.", "isMeasurement": true } - } - */ - this.telemetryService.sendMSFTTelemetryEvent( - 'typescript-context-plugin.completion-context.speculative', - { - requestId: context.requestId, - source: context.source ?? KnownSources.unknown, - originalRequestId: originalRequestId - }, - { - numberOfItems: numberOfItems, - sampleTelemetry: sampleTelemetry - } - ); - } - this.logService.debug(`TypeScript Copilot context speculative request: [${context.requestId} - ${originalRequestId}, numberOfItems: ${numberOfItems}]`); - } - - public willLogRequestTelemetry(context: RequestContext): boolean { - const sampleTelemetry = RequestContext.getSampleTelemetry(context); - return sampleTelemetry === 1 || this.sendRequestTelemetryCounter % sampleTelemetry === 0; - } - - public sendRequestTelemetry(document: vscode.TextDocument, position: vscode.Position, context: RequestContext, data: ContextItemSummary, timeTaken: number, cacheState: { before: CacheState; after: CacheState } | undefined, cacheRequest: string | undefined): void { - const stats = data.stats; - const nodePath = data?.path ? JSON.stringify(data.path) : JSON.stringify([0]); - const items = stats.items; - const totalSize = stats.totalSize; - const fileSize = document.getText().length; - - const sampleTelemetry = RequestContext.getSampleTelemetry(context); - const shouldSendTelemetry = sampleTelemetry === 1 || this.sendRequestTelemetryCounter % sampleTelemetry === 0; - this.sendRequestTelemetryCounter++; - if (shouldSendTelemetry) { - /* __GDPR__ - "typescript-context-plugin.completion-context.request" : { - "owner": "dirkb", - "comment": "Telemetry for copilot inline completion context", - "requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The request correlation id" }, - "opportunityId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The opportunity id" }, - "source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The source of the request" }, - "trigger": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The trigger kind of the request" }, - "cacheRequest": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The cache request that was used to populate the cache" }, - "nodePath": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The syntax kind path to the AST node the position resolved to." }, - "cancelled": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the request got cancelled on the client side" }, - "timedOut": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the request timed out on the server side" }, - "tokenBudgetExhausted": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the token budget was exhausted" }, - "serverTime": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Time taken on the server side", "isMeasurement": true }, - "contextComputeTime": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Time taken on the server side to compute the context", "isMeasurement": true }, - "timeTaken": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Time taken to provide the completion", "isMeasurement": true }, - "total": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Total number of context items", "isMeasurement": true }, - "snippets": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of code snippets", "isMeasurement": true }, - "traits": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of traits", "isMeasurement": true }, - "yielded": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of yielded items", "isMeasurement": true }, - "items": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Detailed information about each context item delivered." }, - "totalSize": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Total size of all context items", "isMeasurement": true }, - "fileSize": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The size of the file", "isMeasurement": true }, - "cachedItems": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of cache items", "isMeasurement": true }, - "referencedItems": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of referenced items", "isMeasurement": true }, - "isSpeculative": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the request was speculative" }, - "beforeCacheState": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The cache state before the request was sent" }, - "afterCacheState": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The cache state after the request was sent" }, - "fromCache": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the context was fully provided from cache" }, - "sampleTelemetry": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The sampling rate for telemetry. A value of 1 means every request is logged, a value of 5 means every 5th request is logged, etc.", "isMeasurement": true } - } - */ - this.telemetryService.sendMSFTTelemetryEvent( - 'typescript-context-plugin.completion-context.request', - { - requestId: context.requestId, - opportunityId: context.opportunityId ?? 'unknown', - source: context.source ?? KnownSources.unknown, - trigger: context.trigger ?? TriggerKind.unknown, - cacheRequest: cacheRequest ?? 'unknown', - nodePath: nodePath, - cancelled: data.cancelled.toString(), - timedOut: data.timedOut.toString(), - tokenBudgetExhausted: data.tokenBudgetExhausted.toString(), - items: JSON.stringify(items), - isSpeculative: (context.proposedEdits !== undefined && context.proposedEdits.length > 0 ? true : false).toString(), - beforeCacheState: cacheState?.before.toString(), - afterCacheState: cacheState?.after.toString(), - fromCache: data.fromCache.toString(), - }, - { - serverTime: data.serverTime, - contextComputeTime: data.contextComputeTime, - timeTaken, - total: stats.total, - snippets: stats.snippets, - traits: stats.traits, - yielded: stats.yielded, - totalSize: totalSize, - fileSize: fileSize, - cachedItems: data.cachedItems, - referencedItems: data.referencedItems, - sampleTelemetry: sampleTelemetry - } - ); - } - this.logService.debug(`TypeScript Copilot context: [${context.requestId}, ${context.source ?? KnownSources.unknown}, ${JSON.stringify(position, undefined, 0)}, ${JSON.stringify(nodePath, undefined, 0)}, ${JSON.stringify(stats, undefined, 0)}, cacheItems:${data.cachedItems}, cacheState:${JSON.stringify(cacheState, undefined, 0)}, budgetExhausted:${data.tokenBudgetExhausted}, cancelled:${data.cancelled}, timedOut:${data.timedOut}, fileSize:${fileSize}] in [${timeTaken},${data.serverTime},${data.contextComputeTime}]ms.${data.timedOut ? ' Timed out.' : ''}`); - if (data.errorData !== undefined && data.errorData.length > 0) { - const errorData = data.errorData; - for (const error of errorData) { - /* __GDPR__ - "typescript-context-plugin.completion-context.error" : { - "owner": "dirkb", - "comment": "Telemetry for copilot inline completion context errors", - "requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The request correlation id" }, - "source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The source of the request" }, - "code": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The failure code", "isMeasurement": true }, - "message": { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "The failure message" } - } - */ - this.telemetryService.sendMSFTTelemetryEvent( - 'typescript-context-plugin.completion-context.error', - { - requestId: context.requestId, - source: context.source ?? KnownSources.unknown, - message: error.message - }, - { - code: error.code - } - ); - this.logService.error('Error computing context:', `${error.message} [${error.code}]`); - } - } - } - - public sendRequestOnTimeoutTelemetry(context: RequestContext, data: ContextItemSummary, cacheState: CacheState): void { - const stats = data.stats; - const items = stats.items; - const totalSize = stats.totalSize; - /* __GDPR__ - "typescript-context-plugin.completion-context.on-timeout" : { - "owner": "dirkb", - "comment": "Telemetry for copilot inline completion context on timeout", - "requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The request correlation id" }, - "opportunityId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The opportunity id" }, - "source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The source of the request" }, - "total": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Total number of context items", "isMeasurement": true }, - "snippets": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of code snippets", "isMeasurement": true }, - "traits": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of traits", "isMeasurement": true }, - "yielded": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of yielded items", "isMeasurement": true }, - "items": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Detailed information about each context item delivered." }, - "totalSize": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Total size of all context items", "isMeasurement": true }, - "cacheState": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The cache state for the onTimeout request" } - } - */ - this.telemetryService.sendMSFTTelemetryEvent( - 'typescript-context-plugin.completion-context.on-timeout', - { - requestId: context.requestId, - opportunityId: context.opportunityId ?? 'unknown', - source: context.source ?? KnownSources.unknown, - items: JSON.stringify(items), - cacheState: cacheState.toString() - }, - { - total: stats.total, - snippets: stats.snippets, - traits: stats.traits, - yielded: stats.yielded, - totalSize: totalSize - } - ); - this.logService.debug(`TypeScript Copilot context on timeout: [${context.requestId}, ${JSON.stringify(stats, undefined, 0)}]`); - } - - public sendRequestFailureTelemetry(context: RequestContext, data: { error: protocol.ErrorCode; message: string; stack?: string }): void { - /* __GDPR__ - "typescript-context-plugin.completion-context.failed" : { - "owner": "dirkb", - "comment": "Telemetry for copilot inline completion context in failure case", - "requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The request correlation id" }, - "opportunityId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The opportunity id" }, - "source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The source of the request" }, - "code:": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The failure code" }, - "message": { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "The failure message" }, - "stack": { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "The failure stack" } - } - */ - this.telemetryService.sendMSFTTelemetryEvent( - 'typescript-context-plugin.completion-context.failed', - { - requestId: context.requestId, - opportunityId: context.opportunityId ?? 'unknown', - source: context.source ?? KnownSources.unknown, - code: data.error, - message: data.message, - stack: data.stack ?? 'Not available' - } - ); - } - - public sendRequestCancelledTelemetry(context: RequestContext, timeTaken: number): void { - /* __GDPR__ - "typescript-context-plugin.completion-context.cancelled" : { - "owner": "dirkb", - "comment": "Telemetry for copilot inline completion context in cancellation case", - "requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The request correlation id" }, - "opportunityId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The opportunity id" }, - "source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The source of the request" }, - "timeTaken": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Time taken to provide the completion", "isMeasurement": true } - } - */ - this.telemetryService.sendMSFTTelemetryEvent( - 'typescript-context-plugin.completion-context.cancelled', - { - requestId: context.requestId, - opportunityId: context.opportunityId ?? 'unknown', - source: context.source ?? KnownSources.unknown - }, - { - timeTaken: timeTaken - } - ); - this.logService.debug(`TypeScript Copilot context request ${context.requestId} got cancelled.`); - } - - public sendActivationTelemetry(response: protocol.PingResponse | undefined, error: unknown | undefined): void { - if (response !== undefined) { - const body: protocol.PingResponse['body'] | undefined = response?.body; - if (body?.kind === 'ok') { - /* __GDPR__ - "typescript-context-plugin.activation.ok" : { - "owner": "dirkb", - "comment": "Telemetry for TypeScript server plugin", - "session": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the TypeScript server had a session" }, - "supported": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the TypeScript server version is supported" }, - "version": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The version of the TypeScript server" } - } - */ - this.telemetryService.sendMSFTTelemetryEvent( - 'typescript-context-plugin.activation.ok', - { - session: body.session.toString(), - supported: body.supported.toString(), - version: body.version ?? 'unknown' - } - ); - } else if (body?.kind === 'error') { - this.sendActivationFailedTelemetry(ErrorLocation.Server, ErrorPart.ServerPlugin, body.message, body.stack); - } else { - this.sendUnknownPingResponseTelemetry(ErrorLocation.Server, ErrorPart.ServerPlugin, response); - } - } else if (error !== undefined) { - const isError = error instanceof Error; - if (isError && TypeScriptServerError.is(error)) { - this.sendActivationFailedTelemetry(ErrorLocation.Server, ErrorPart.ServerPlugin, error.response.message ?? error.message, undefined, error.version.displayName); - } else if (isError) { - this.sendActivationFailedTelemetry(ErrorLocation.Client, ErrorPart.ServerPlugin, error.message, error.stack); - } else { - this.sendActivationFailedTelemetry(ErrorLocation.Client, ErrorPart.ServerPlugin, 'Unknown error', undefined); - } - } else { - this.sendActivationFailedTelemetry(ErrorLocation.Client, ErrorPart.ServerPlugin, 'Neither response nor error received.', undefined); - } - } - - public sendActivationFailedTelemetry(location: ErrorLocation, part: ErrorPart, message: string, stack?: string | undefined, version?: string | undefined): void { - /* __GDPR__ - "typescript-context-plugin.activation.failed" : { - "owner": "dirkb", - "comment": "Telemetry for TypeScript server plugin", - "location": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The location of the failure" }, - "part": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The part that errored" }, - "message": { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "The failure message" }, - "stack": { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "The failure stack" }, - "version": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The version" } - } - */ - this.telemetryService.sendMSFTTelemetryEvent( - 'typescript-context-plugin.activation.failed', - { - location: location, - part: part, - message: message, - stack: stack ?? 'Not available', - version: version ?? 'Not specified' - } - ); - } - - private sendUnknownPingResponseTelemetry(location: ErrorLocation, part: ErrorPart, response: object): void { - /* __GDPR__ - "typescript-context-plugin.activation.unknown-ping-response" : { - "owner": "dirkb", - "comment": "Telemetry for TypeScript server plugin", - "location": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The location of the failure" }, - "part": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The part that errored" }, - "response": { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "The response literal" } - } - */ - this.telemetryService.sendMSFTTelemetryEvent( - 'typescript-context-plugin.activation.unknown-ping-response', - { - location: location, - part: part, - response: JSON.stringify(response, undefined, 0) - } - ); - } - - public sendIntegrationTelemetry(requestId: string, document: string, versionMismatch?: string): void { - /* __GDPR__ - "typescript-context-plugin.integration.failed" : { - "owner": "dirkb", - "comment": "Telemetry for Copilot inline chat integration.", - "requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The request correlation id" }, - "document": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The document for which the integration failed" }, - "versionMismatch": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The version mismatch" } - } - */ - this.telemetryService.sendMSFTTelemetryEvent( - 'typescript-context-plugin.integration.failed', - { - requestId: requestId, - document: document, - versionMismatch: versionMismatch - } - ); - } - - public sendInlineCompletionProviderTelemetry(source: KnownSources, registered: boolean): void { - if (registered) { - /* __GDPR__ - "typescript-context-plugin.inline-completion-provider.registered" : { - "owner": "dirkb", - "comment": "Telemetry for Copilot inline completions", - "source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The source of the request" } - } - */ - this.telemetryService.sendMSFTTelemetryEvent( - 'typescript-context-plugin.inline-completion-provider.registered', - { - source: source - } - ); - } else { - /* __GDPR__ - "typescript-context-plugin.inline-completion-provider.unregistered" : { - "owner": "dirkb", - "comment": "Telemetry for Copilot inline completions", - "source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The source of the request" } - } - */ - this.telemetryService.sendMSFTTelemetryEvent( - 'typescript-context-plugin.inline-completion-provider.unregistered', - { - source: source - } - ); - } - } -} - -type RequestInfo = { - readonly document: string; - readonly version: number; - readonly languageId: string; - readonly position: vscode.Position; - readonly requestId: string; - readonly path: number[]; -}; - -type ContextRequestState = { - client: readonly ResolvedRunnableResult[]; - clientOnTimeout: readonly ResolvedRunnableResult[]; - server: readonly protocol.CachedContextRunnableResult[]; - resultMap: Map; - itemMap: Map; -}; - -type CacheInfo = { - version: number; - state: CacheState; -}; - -enum CacheState { - NotPopulated = 'NotPopulated', - PartiallyPopulated = 'PartiallyPopulated', - FullyPopulated = 'FullyPopulated' -} - -type ManagerUpdateResult = { - resolved: ResolvedRunnableResult[]; - serverComputed: Set; - cached: number; - referenced: number; -}; - -class RunnableResultManager implements vscode.Disposable { - - private readonly disposables = new DisposableStore(); - private requestInfo: RequestInfo | undefined; - - private cacheInfo: CacheInfo; - private results: Map; - private readonly withInRangeRunnableResults: { resultId: protocol.ContextRunnableResultId; range: vscode.Range }[]; - private readonly outsideRangeRunnableResults: { resultId: protocol.ContextRunnableResultId; ranges: vscode.Range[] }[] = []; - private readonly neighborFileRunnableResults: { resultId: protocol.ContextRunnableResultId }[]; - - constructor() { - this.requestInfo = undefined; - this.results = new Map(); - - this.cacheInfo = { - version: 0, - state: CacheState.NotPopulated - }; - this.withInRangeRunnableResults = []; - this.outsideRangeRunnableResults = []; - this.neighborFileRunnableResults = []; - - this.disposables.add(vscode.workspace.onDidChangeTextDocument((event: vscode.TextDocumentChangeEvent) => { - if (this.requestInfo === undefined || event.contentChanges.length === 0) { - return; - } - if (event.document.uri.toString() !== this.requestInfo.document) { - if (this.affectsTypeScript(event)) { - this.clear(); - } - } else { - for (const change of event.contentChanges) { - const changeRange = change.range; - for (let i = 0; i < this.withInRangeRunnableResults.length;) { - const entry = this.withInRangeRunnableResults[i]; - if (entry.range.contains(changeRange)) { - entry.range = this.applyTextContentChangeEventToWithinRange(change, entry.range); - i++; - } else { - const id = entry.resultId; - this.results.delete(id); - this.withInRangeRunnableResults.splice(i, 1); - } - } - for (let i = 0; i < this.outsideRangeRunnableResults.length;) { - const entry = this.outsideRangeRunnableResults[i]; - const ranges = this.applyTextContentChangeEventToOutsideRanges(change, entry.ranges); - if (ranges === undefined) { - const id = entry.resultId; - this.results.delete(id); - this.outsideRangeRunnableResults.splice(i, 1); - } else { - entry.ranges = ranges; - i++; - } - } - this.cacheInfo.version = event.document.version; - } - } - })); - this.disposables.add(vscode.workspace.onDidCloseTextDocument((document: vscode.TextDocument) => { - if (this.requestInfo?.document === document.uri.toString()) { - this.clear(); - } - })); - this.disposables.add(vscode.window.onDidChangeActiveTextEditor(() => { - this.clear(); - })); - this.disposables.add(vscode.window.tabGroups.onDidChangeTabs((event: vscode.TabChangeEvent) => { - if (event.closed.length === 0 && event.opened.length === 0) { - return; - } - for (const item of this.neighborFileRunnableResults) { - this.results.delete(item.resultId); - } - this.neighborFileRunnableResults.length = 0; - })); - } - - public clear(): void { - this.requestInfo = undefined; - this.results.clear(); - - this.cacheInfo = { - version: 0, - state: CacheState.NotPopulated - }; - this.withInRangeRunnableResults.length = 0; - this.outsideRangeRunnableResults.length = 0; - this.neighborFileRunnableResults.length = 0; - } - - public getCacheState(): CacheState { - return this.cacheInfo.state; - } - - public update(document: vscode.TextDocument, version: number, position: vscode.Position, context: RequestContext, body: protocol.ComputeContextResponse.OK, requestState: ContextRequestState | undefined): ManagerUpdateResult { - const itemMap = requestState?.itemMap ?? new Map(); - const usedResults = requestState?.resultMap ?? new Map(); - - this.withInRangeRunnableResults.length = 0; - this.outsideRangeRunnableResults.length = 0; - this.neighborFileRunnableResults.length = 0; - this.results.clear(); - this.cacheInfo = { - version: version, - state: CacheState.NotPopulated - }; - - let cachedItems = 0; - let referencedItems = 0; - const serverComputed: Set = new Set(); - this.requestInfo = { - document: document.uri.toString(), - version: version, - languageId: document.languageId, - position: position, - requestId: context.requestId, - path: body.path ?? [0] - }; - - if (body.runnableResults === undefined || body.runnableResults.length === 0 || body.path === undefined || body.path.length === 0 || body.path[0] === 0) { - return { resolved: [], cached: cachedItems, referenced: referencedItems, serverComputed: serverComputed }; - } - - const serverItems: Set = new Set(); - // Add new client side context items to the item map. - if (body.contextItems !== undefined && body.contextItems.length > 0) { - for (const item of body.contextItems) { - if (protocol.ContextItem.hasKey(item)) { - itemMap.set(item.key, item); - serverItems.add(item.key); - } - } - } - const updateRunnableResult = (resultItem: protocol.ContextRunnableResultTypes): ResolvedRunnableResult | undefined => { - let result: ResolvedRunnableResult | undefined; - if (resultItem.kind === protocol.ContextRunnableResultKind.ComputedResult) { - serverComputed.add(resultItem.id); - const items: protocol.FullContextItem[] = []; - for (const contextItem of resultItem.items) { - if (contextItem.kind === protocol.ContextKind.Reference) { - const referenced: protocol.FullContextItem | undefined = itemMap.get(contextItem.key); - if (referenced !== undefined) { - referencedItems++; - items.push(referenced); - if (!serverItems.has(contextItem.key)) { - cachedItems++; - } - } - } else { - items.push(contextItem); - } - } - result = ResolvedRunnableResult.from(resultItem, items); - } else if (resultItem.kind === protocol.ContextRunnableResultKind.Reference) { - result = usedResults.get(resultItem.id); - if (result !== undefined) { - cachedItems += result.items.length; - } - } - if (result === undefined) { - return; - } - this.results.set(result.id, result); - if (result.cache !== undefined) { - if (result.cache.scope.kind === protocol.CacheScopeKind.WithinRange) { - const scopeRange = result.cache.scope.range; - const range = new vscode.Range(scopeRange.start.line, scopeRange.start.character, scopeRange.end.line, scopeRange.end.character); - this.withInRangeRunnableResults.push({ range, resultId: result.id }); - } else if (result.cache.scope.kind === protocol.CacheScopeKind.NeighborFiles) { - this.neighborFileRunnableResults.push({ resultId: result.id }); - } else if (result.cache.scope.kind === protocol.CacheScopeKind.OutsideRange) { - const ranges: vscode.Range[] = []; - for (const scopeRange of result.cache.scope.ranges) { - ranges.push(new vscode.Range(scopeRange.start.line, scopeRange.start.character, scopeRange.end.line, scopeRange.end.character)); - } - this.outsideRangeRunnableResults.push({ resultId: result.id, ranges }); - } - } - this.updateCacheState(result.state); - return result; - }; - - const results: ResolvedRunnableResult[] = []; - for (const runnableResult of body.runnableResults) { - const result = updateRunnableResult(runnableResult); - if (result !== undefined) { - results.push(result); - } - } - return { resolved: results, cached: cachedItems, referenced: referencedItems, serverComputed: serverComputed }; - } - - private updateCacheState(state: protocol.ContextRunnableState): void { - switch (this.cacheInfo.state) { - case CacheState.NotPopulated: - switch (state) { - case protocol.ContextRunnableState.Finished: - this.cacheInfo.state = CacheState.FullyPopulated; - break; - case protocol.ContextRunnableState.IsFull: - case protocol.ContextRunnableState.InProgress: - this.cacheInfo.state = CacheState.PartiallyPopulated; - break; - default: - this.cacheInfo.state = CacheState.NotPopulated; - } - break; - case CacheState.PartiallyPopulated: - // If the cache is partially populated we can only stay in that state. - break; - case CacheState.FullyPopulated: - switch (state) { - case protocol.ContextRunnableState.Finished: - // If the cache is fully populated we can only stay in that state. - break; - case protocol.ContextRunnableState.IsFull: - case protocol.ContextRunnableState.InProgress: - this.cacheInfo.state = CacheState.PartiallyPopulated; - break; - default: - this.cacheInfo.state = CacheState.NotPopulated; - } - break; - } - } - - public getRequestId(): string | undefined { - return this.requestInfo?.requestId; - } - - public getNodePath(): number[] { - return this.requestInfo?.path ?? [0]; - } - - public getRunnableResult(id: protocol.ContextRunnableResultId): ResolvedRunnableResult | undefined { - return this.results.get(id); - } - - public getCachedRunnableResults(document: vscode.TextDocument, position: vscode.Position, emitMode?: protocol.EmitMode): ResolvedRunnableResult[] { - const results: ResolvedRunnableResult[] = []; - if (this.requestInfo?.document !== document.uri.toString()) { - return results; - } - if (this.cacheInfo.version !== document.version || this.cacheInfo.state === CacheState.NotPopulated || this.requestInfo.path.length === 0 || this.requestInfo.path[0] === 0) { - return results; - } - for (const item of this.results.values()) { - if (emitMode !== undefined && item.cache?.emitMode === emitMode) { - continue; - } - const scope = item.cache?.scope; - if (scope === undefined || scope.kind !== protocol.CacheScopeKind.WithinRange) { - results.push(item); - } else { - const r = scope.range; - const range = new vscode.Range(r.start.line, r.start.character, r.end.line, r.end.character); - if (range.contains(position)) { - results.push(item); - } - } - } - // Sort them by priority so that the most important items are emitted first if they - // are contained in more than one runnable result. - return results.sort((a, b) => { - return a.priority < b.priority ? 1 : a.priority > b.priority ? -1 : 0; - }); - } - - public getContextRequestState(document: vscode.TextDocument, position: vscode.Position): ContextRequestState | undefined { - if (this.requestInfo?.document !== document.uri.toString()) { - return undefined; - } - if (this.cacheInfo.version !== document.version || this.cacheInfo.state === CacheState.NotPopulated || this.requestInfo.path.length === 0 || this.requestInfo.path[0] === 0) { - return undefined; - } - const items: Map = new Map(); - const client: ResolvedRunnableResult[] = []; - const clientOnTimeout: ResolvedRunnableResult[] = []; - const server: protocol.CachedContextRunnableResult[] = []; - if (this.isCacheFullyUpToDate(document, position)) { - for (const item of this.results.values()) { - client.push(item); - } - } else { - const canSkipItems = (rr: ResolvedRunnableResult, cache: protocol.CacheInfo): boolean => { - if (rr.state === protocol.ContextRunnableState.Finished) { - return true; - } - if (rr.state === protocol.ContextRunnableState.IsFull) { - const kind = cache.scope.kind; - return kind === protocol.CacheScopeKind.WithinRange || kind === protocol.CacheScopeKind.NeighborFiles || kind === protocol.CacheScopeKind.File; - } - return false; - }; - const handleRunnableResult = (id: string, rr: ResolvedRunnableResult) => { - const cache = rr.cache; - const cachedResult: protocol.CachedContextRunnableResult = { - id: id, - kind: protocol.ContextRunnableResultKind.CacheEntry, - state: rr.state, - items: [] - }; - let skipItems = false; - if (cache !== undefined) { - cachedResult.cache = cache; - const emitMode = cache.emitMode; - if (emitMode === protocol.EmitMode.ClientBased) { - client.push(rr); - skipItems = canSkipItems(rr, cache); - } else if (emitMode === protocol.EmitMode.ClientBasedOnTimeout) { - clientOnTimeout.push(rr); - } - } - server.push(cachedResult); - - if (skipItems) { - return; - } - - // Add cached context items to the result; - for (const item of rr.items) { - if (!protocol.ContextItem.hasKey(item)) { - continue; - } - const key = item.key; - let size: number | undefined = undefined; - switch (item.kind) { - case protocol.ContextKind.Snippet: - size = protocol.CodeSnippet.sizeInChars(item); - break; - case protocol.ContextKind.Trait: - size = protocol.Trait.sizeInChars(item); - break; - default: - } - cachedResult.items.push(protocol.CachedContextItem.create(key, size)); - items.set(key, item); - } - }; - // We don't need to sort by priority here since the data is used for the next cache request. - for (const [id, item] of this.results.entries()) { - const scope = item.cache?.scope; - if (scope === undefined || scope.kind !== protocol.CacheScopeKind.WithinRange) { - handleRunnableResult(id, item); - } else { - const r = scope.range; - const range = new vscode.Range(r.start.line, r.start.character, r.end.line, r.end.character); - if (range.contains(position)) { - handleRunnableResult(id, item); - } - } - } - } - return { client, clientOnTimeout, server, itemMap: items, resultMap: new Map(this.results) }; - } - - private isCacheFullyUpToDate(document: vscode.TextDocument, position: vscode.Position): boolean { - if (this.requestInfo === undefined) { - return false; - } - if (this.requestInfo.document !== document.uri.toString()) { - return false; - } - - // Same document, version and position. Cache can be full used. - if (this.requestInfo.version === document.version && this.requestInfo.position.isEqual(position)) { - return true; - } - - // Document is older than cached request. Not up to date. - if (this.requestInfo.version > document.version) { - return false; - } - - // if the position is not contained in all ranges return false. - for (const runnable of this.withInRangeRunnableResults) { - if (!runnable.range.contains(position)) { - return false; - } - } - - const range = position.isBefore(this.requestInfo.position) ? new vscode.Range(position, this.requestInfo.position) : new vscode.Range(this.requestInfo.position, position); - const text = document.getText(range); - return text.trim().length === 0; - } - - public dispose(): void { - this.clear(); - this.disposables.dispose(); - } - - private affectsTypeScript(event: vscode.TextDocumentChangeEvent): boolean { - const languageId = event.document.languageId; - return languageId === 'typescript' || languageId === 'typescriptreact' || languageId === 'javascript' || languageId === 'javascriptreact' || languageId === 'json'; - } - - private applyTextContentChangeEventToWithinRange(event: vscode.TextDocumentContentChangeEvent, range: vscode.Range): vscode.Range { - // The start stays untouched since the change range is contained in the range. - const eventRange = event.range; - const eventText = event.text; - - // Calculate how many lines the new text adds or removes - const linesDelta = (eventText.match(/\n/g) || []).length - (eventRange.end.line - eventRange.start.line); - - // Calculate the new end position - const endLine = range.end.line + linesDelta; - - let endCharacter = range.end.character; - if (eventRange.end.line === range.end.line) { - // Calculate the character delta for the last line of the change - const lastNewLineIndex = eventText.lastIndexOf('\n'); - const newTextLength = lastNewLineIndex !== -1 ? eventText.length - lastNewLineIndex - 1 : eventText.length; - const oldTextLength = eventRange.end.character - (eventRange.end.line > eventRange.start.line ? 0 : eventRange.start.character); - const charDelta = newTextLength - oldTextLength; - endCharacter += charDelta; - } - return new vscode.Range(range.start, new vscode.Position(endLine, endCharacter)); - } - - private applyTextContentChangeEventToOutsideRanges(event: vscode.TextDocumentContentChangeEvent, ranges: vscode.Range[]): vscode.Range[] | undefined { - if (ranges.length === 0) { - return ranges; - } - const changeRange = event.range; - const eventText = event.text; - - // Quick optimization: if change is completely after last range, no ranges need adjustment - const lastRange = ranges[ranges.length - 1]; - if (changeRange.start.isAfter(lastRange.end)) { - return ranges; - } - // Calculate how many lines the new text adds or removes - const linesDelta = (eventText.match(/\n/g) || []).length - (changeRange.end.line - changeRange.start.line); - const adjustedRanges: vscode.Range[] = []; - - for (const range of ranges) { - if (range.end.isBefore(changeRange.start)) { - // Range is completely before change, no adjustment needed - adjustedRanges.push(range); - } else if (range.start.isAfter(changeRange.end)) { - // Range is completely after change, adjust by lines delta - if (linesDelta === 0) { - adjustedRanges.push(range); - } else { - adjustedRanges.push(new vscode.Range( - new vscode.Position(range.start.line + linesDelta, range.start.character), - new vscode.Position(range.end.line + linesDelta, range.end.character) - )); - } - } else { - - // The range intersects with the range with will invalidate the cache entry. - return undefined; - } - } - - return adjustedRanges; - } -} - -namespace TextDocuments { - export function consider(document: vscode.TextDocument): boolean { - return document.uri.scheme === 'file' && (document.languageId === 'typescript' || document.languageId === 'typescriptreact'); - } -} - -class NeighborFileModel implements vscode.Disposable { - - private static readonly MAX_ITEMS = 12; - - private readonly disposables; - private readonly visible: LRUCache; - private readonly notVisible: LRUCache; - - constructor() { - this.disposables = new DisposableStore(); - this.visible = new LRUCache({ max: NeighborFileModel.MAX_ITEMS }); - this.notVisible = new LRUCache({ max: NeighborFileModel.MAX_ITEMS }); - this.disposables.add(vscode.window.onDidChangeActiveTextEditor((editor: vscode.TextEditor | undefined) => { - if (editor === undefined) { - return; - } - const document = editor.document; - if (TextDocuments.consider(document)) { - const uri = document.uri.toString(); - this.visible.set(uri, document.uri.fsPath); - this.notVisible.delete(uri); - } - })); - this.disposables.add(vscode.workspace.onDidCloseTextDocument((document: vscode.TextDocument) => { - const uri = document.uri.toString(); - if (TextDocuments.consider(document)) { - this.visible.delete(uri); - this.notVisible.delete(uri); - } - })); - this.disposables.add(vscode.window.tabGroups.onDidChangeTabs((e: vscode.TabChangeEvent) => { - // We don't track open tabs here to ensure we only track documents that are - // actually focused. Otherwise opening multiple tabs at once would cause too much churn. - for (const tab of e.closed) { - if (tab.input instanceof vscode.TabInputText) { - const uri = tab.input.uri.toString(); - const isVisible = this.visible.has(uri); - if (isVisible) { - this.visible.delete(uri); - this.notVisible.set(uri, tab.input.uri.fsPath); - } - } - } - })); - const textDocumentsToConsider: Map = new Map(); - for (const document of vscode.workspace.textDocuments) { - if (TextDocuments.consider(document)) { - textDocumentsToConsider.set(document.uri.toString(), document.uri); - } - } - for (const group of vscode.window.tabGroups.all) { - for (const tab of group.tabs) { - const uri = tab.input instanceof vscode.TabInputText ? tab.input.uri : undefined; - if (uri !== undefined && textDocumentsToConsider.has(uri.toString())) { - this.visible.set(uri.toString(), uri.fsPath); - textDocumentsToConsider.delete(uri.toString()); - } - } - } - for (const [key, uri] of textDocumentsToConsider.entries()) { - this.notVisible.set(key, uri.fsPath); - } - if (vscode.window.activeTextEditor !== undefined) { - const document = vscode.window.activeTextEditor.document; - if (TextDocuments.consider(document)) { - const uri = document.uri.toString(); - this.visible.set(uri, document.uri.fsPath); - this.notVisible.delete(uri); - } - } - } - - public getNeighborFiles(currentDocument: vscode.TextDocument): string[] { - const result: string[] = []; - const currentUri = currentDocument.uri.toString(); - for (const [key, value] of this.visible.entries()) { - if (key === currentUri) { - continue; - } - result.push(value); - } - if (result.length < NeighborFileModel.MAX_ITEMS) { - for (const [key, value] of this.notVisible.entries()) { - if (key === currentUri) { - continue; - } - result.push(value); - if (result.length >= NeighborFileModel.MAX_ITEMS) { - break; - } - } - } - return result; - } - - public dispose(): void { - this.disposables.dispose(); - } -} - -type ComputeContextRequestArgs = Omit & { - file: vscode.Uri; - line: number; - offset: number; - $traceId?: string; -}; -namespace ComputeContextRequestArgs { - export function create(document: vscode.TextDocument, position: vscode.Position, context: RequestContext, startTime: number, timeBudget: number, willLogRequestTelemetry: boolean, neighborFiles: readonly string[] | undefined, clientSideRunnableResults: readonly protocol.CachedContextRunnableResult[] | undefined, includeDocumentation: boolean): ComputeContextRequestArgs { - return { - file: vscode.Uri.file(document.fileName), - line: position.line + 1, - offset: position.character + 1, - startTime: startTime, - timeBudget: timeBudget, - primaryCharacterBudget: (context.tokenBudget ?? 7 * 1024) * 4, - secondaryCharacterBudget: (currentTokenBudget * 4), - includeDocumentation: includeDocumentation, - neighborFiles: neighborFiles !== undefined && neighborFiles.length > 0 ? neighborFiles : undefined, - clientSideRunnableResults: clientSideRunnableResults, - $traceId: willLogRequestTelemetry ? context.requestId : undefined - }; - } -} - -class PendingRequestInfo { - - public readonly document: string; - public readonly version: number; - public readonly position: vscode.Position; - public readonly context: RequestContext; - - constructor(document: vscode.TextDocument, position: vscode.Position, context: RequestContext) { - this.document = document.uri.toString(); - this.version = document.version; - this.position = position; - this.context = context; - } -} - -class InflightRequestInfo { - - public readonly document: string; - public readonly position: vscode.Position; - public readonly requestId: string; - public readonly source: KnownSources | string; - public readonly serverPromise: Thenable; - - private readonly tokenSource: vscode.CancellationTokenSource; - - constructor(document: vscode.TextDocument, position: vscode.Position, context: RequestContext, tokenSource: vscode.CancellationTokenSource, serverPromise: Thenable) { - this.document = document.uri.toString(); - this.position = position; - this.requestId = context.requestId; - this.source = context.source ?? KnownSources.unknown; - this.tokenSource = tokenSource; - this.serverPromise = serverPromise; - } - - public matches(document: vscode.TextDocument, position: vscode.Position): boolean { - return this.document === document.uri.toString() && this.position.isEqual(position); - } - - public matchesDocument(document: vscode.TextDocument): boolean { - return this.document === document.uri.toString(); - } - - public cancel(): void { - this.tokenSource.cancel(); - } -} - -class OnTimeoutData { - - private readonly document: string; - private readonly version: number; - private readonly position: vscode.Position; - - public readonly runnableResults: ResolvedRunnableResult[] = []; - public resultBuilder: ContextItemResultBuilder | undefined; - - constructor(document: vscode.TextDocument, position: vscode.Position) { - this.document = document.uri.toString(); - this.version = document.version; - this.position = position; - } - - addRunnableResult(result: ResolvedRunnableResult): void { - this.runnableResults.push(result); - } - - addRunnableResults(results: readonly ResolvedRunnableResult[]): void { - this.runnableResults.push(...results); - } - - matches(document: vscode.TextDocument, position: vscode.Position): boolean { - return this.document === document.uri.toString() && this.version === document.version && this.position.isEqual(position); - } -} - -enum ContextItemUsageMode { - minimal = 'minimal', - double = 'double', - fillHalf = 'fillHalf', - fill = 'fill' -} -namespace ContextItemUsageMode { - export function fromString(value: string): ContextItemUsageMode { - switch (value) { - case 'minimal': return ContextItemUsageMode.minimal; - case 'double': return ContextItemUsageMode.double; - case 'fillHalf': return ContextItemUsageMode.fillHalf; - case 'fill': return ContextItemUsageMode.fill; - default: return ContextItemUsageMode.minimal; - } - } -} - -class CharacterBudget { - - public readonly overall: number; - private mandatory: number; - private optional: number; - private start: { mandatory: number; optional: number }; - - constructor(mandatory: number, optional: number) { - this.overall = mandatory; - this.mandatory = mandatory; - this.optional = optional; - this.start = { mandatory, optional }; - } - - spend(chars: number): void { - this.mandatory -= chars; - this.optional -= chars; - } - - isExhausted(): boolean { - return this.mandatory <= 0; - } - - isOptionalExhausted(): boolean { - return this.optional <= 0; - } - - public fresh(): CharacterBudget { - return new CharacterBudget(this.start.mandatory, this.start.optional); - } -} +import { ContextItemSummary, ErrorLocation, ErrorPart, type OnCachePopulatedEvent, type OnContextComputedEvent, type OnContextComputedOnTimeoutEvent } from './types'; +import { TS6LanguageContextService } from './tsc6/tsContextService'; +import { TS7LanguageContextService } from './ts7/tsContextService'; +import { currentTokenBudget, NullTSLanguageContextService, type TSLanguageContextService } from './tsContextService'; +import { TypeScript } from './tsService'; +import { TelemetrySender } from './telemetrySender'; export class LanguageContextServiceImpl implements ILanguageContextService, vscode.Disposable { - private static readonly defaultCachePopulationBudget: number = 500; - private static readonly defaultCachePopulationRaceTimeout: number = 20; - private static readonly ExecConfig: ExecConfig = { executionTarget: ExecutionTarget.Semantic }; - readonly _serviceBrand: undefined; private readonly disposables: DisposableStore; + private readonly serviceListeners: DisposableStore; - private readonly isDebugging: boolean; - private _isActivated: Promise | undefined; - private telemetrySender: TelemetrySender; - - private readonly runnableResultManager: RunnableResultManager; - private readonly neighborFileModel: NeighborFileModel; - - private pendingRequest: PendingRequestInfo | undefined; - private inflightCachePopulationRequest: InflightRequestInfo | undefined; - private onTimeoutData: OnTimeoutData | undefined; - private cachePopulationTimeout: number; - private usageMode: ContextItemUsageMode; - private includeDocumentation: boolean; + private readonly _onCachePopulated: vscode.EventEmitter; + private readonly _onContextComputed: vscode.EventEmitter; + private readonly _onContextComputedOnTimeout: vscode.EventEmitter; - private _onCachePopulated: vscode.EventEmitter; - public readonly onCachePopulated: vscode.Event; - - private _onContextComputed: vscode.EventEmitter; - public readonly onContextComputed: vscode.Event; - - private _onContextComputedOnTimeout: vscode.EventEmitter; - public readonly onContextComputedOnTimeout: vscode.Event; + private tsLanguageContextService: TSLanguageContextService; constructor( - @ITelemetryService telemetryService: ITelemetryService, + @ITelemetryService private readonly telemetryService: ITelemetryService, @IConfigurationService private readonly configurationService: IConfigurationService, @IExperimentationService private readonly experimentationService: IExperimentationService, @ILogService private readonly logService: ILogService ) { - this.isDebugging = process.execArgv.some((arg) => /^--(?:inspect|debug)(?:-brk)?(?:=\d+)?$/i.test(arg)); - this.telemetrySender = new TelemetrySender(telemetryService, logService); - this.runnableResultManager = new RunnableResultManager(); - this.neighborFileModel = new NeighborFileModel(); - this.pendingRequest = undefined; - this.inflightCachePopulationRequest = undefined; - this.onTimeoutData = undefined; - this.cachePopulationTimeout = this.getCachePopulationBudget(); - this.usageMode = this.getUsageMode(); - this.includeDocumentation = this.getIncludeDocumentation(); - this.disposables = new DisposableStore(); - this.disposables.add(this.configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(ConfigKey.TypeScriptLanguageContextMode.fullyQualifiedId)) { - this.usageMode = this.getUsageMode(); - } else if (e.affectsConfiguration(ConfigKey.TypeScriptLanguageContextCacheTimeout.fullyQualifiedId)) { - this.cachePopulationTimeout = this.getCachePopulationBudget(); - } else if (e.affectsConfiguration(ConfigKey.TypeScriptLanguageContextIncludeDocumentation.fullyQualifiedId)) { - this.includeDocumentation = this.getIncludeDocumentation(); - } - })); - + this.serviceListeners = this.disposables.add(new DisposableStore()); this._onCachePopulated = this.disposables.add(new vscode.EventEmitter()); - this.onCachePopulated = this._onCachePopulated.event; - this._onContextComputed = this.disposables.add(new vscode.EventEmitter()); - this.onContextComputed = this._onContextComputed.event; - this._onContextComputedOnTimeout = this.disposables.add(new vscode.EventEmitter()); - this.onContextComputedOnTimeout = this._onContextComputedOnTimeout.event; + const runsTS7 = TypeScript.runsVersion7(); + const enableTS7 = TypeScript.isVersion7SupportEnabled(this.configurationService); + this.tsLanguageContextService = runsTS7 + ? enableTS7 + ? new TS7LanguageContextService(this.telemetryService, this.configurationService, this.experimentationService, this.logService) + : new NullTSLanguageContextService() + : new TS6LanguageContextService(this.telemetryService, this.configurationService, this.experimentationService, this.logService); + this.bindEvents(); + this.disposables.add(this.configurationService.onDidChangeConfiguration((e) => { + if (TypeScript.affectsVersion(e) || e.affectsConfiguration(ConfigKey.TypeScript7LanguageContext.fullyQualifiedId)) { + this.updateTSLanguageContextService(); + } + })); } public dispose(): void { - this.runnableResultManager.dispose(); - this.neighborFileModel.dispose(); - this.inflightCachePopulationRequest = undefined; + this.tsLanguageContextService.dispose(); + this.disposables.dispose(); } - async isActivated(documentOrLanguageId: vscode.TextDocument | string): Promise { - const languageId = typeof documentOrLanguageId === 'string' ? documentOrLanguageId : documentOrLanguageId.languageId; - if (languageId !== 'typescript' && languageId !== 'typescriptreact') { - return false; - } - if (this._isActivated === undefined) { - this._isActivated = this.doIsTypeScriptActivated(languageId); - } - return this._isActivated; + public get onCachePopulated() { + return this._onCachePopulated.event; } - private async doIsTypeScriptActivated(languageId: string): Promise { - - let activated = false; - - try { - // Check that the TypeScript extension is installed and runs in the same extension host. - const typeScriptExtension = vscode.extensions.getExtension('vscode.typescript-language-features'); - if (typeScriptExtension === undefined) { - return false; - } - - // Make sure the TypeScript extension is activated. - await typeScriptExtension.activate(); + public get onContextComputed() { + return this._onContextComputed.event; + } - // Send a ping request to see if the TS server plugin got installed correctly. - const response: protocol.PingResponse | undefined = await vscode.commands.executeCommand('typescript.tsserverRequest', '_.copilot.ping', LanguageContextServiceImpl.ExecConfig, CancellationToken.None); - this.telemetrySender.sendActivationTelemetry(response, undefined); - if (response !== undefined) { - if (response.body?.kind === 'ok') { - this.logService.info('TypeScript server plugin activated.'); - activated = true; - } else { - this.logService.error('TypeScript server plugin not activated:', response.body?.message ?? 'Message not provided.'); - } - } else { - this.logService.error('TypeScript server plugin not activated:', 'No ping response received.'); - } - } catch (error) { - this.telemetrySender.sendActivationTelemetry(undefined, error); - this.logService.error('Error pinging TypeScript server plugin:', error); - } + public get onContextComputedOnTimeout() { + return this._onContextComputedOnTimeout.event; + } - return activated; + async isActivated(documentOrLanguageId: vscode.TextDocument | string): Promise { + return this.tsLanguageContextService.isActivated(documentOrLanguageId); } async populateCache(document: vscode.TextDocument, position: vscode.Position, context: RequestContext): Promise { - if (document.languageId !== 'typescript' && document.languageId !== 'typescriptreact') { - return; - } - if (this.inflightCachePopulationRequest !== undefined) { - if (!this.inflightCachePopulationRequest.matches(document, position)) { - // We have a request running. Do not issue another cache request but remember the pending request. - this.pendingRequest = new PendingRequestInfo(document, position, context); - } - return; - } - const startTime = Date.now(); - const contextRequestState = this.runnableResultManager.getContextRequestState(document, position); - if (contextRequestState !== undefined && contextRequestState.server.length === 0) { - // There is nothing to do on the server. Cache is up to date. - return; - } - const neighborFiles: string[] = this.neighborFileModel.getNeighborFiles(document); - const timeBudget = this.cachePopulationTimeout; - const willLogRequestTelemetry = this.telemetrySender.willLogRequestTelemetry(context); - const args: ComputeContextRequestArgs = ComputeContextRequestArgs.create( - document, position, context, startTime, timeBudget, willLogRequestTelemetry, - neighborFiles, contextRequestState?.server, this.includeDocumentation - ); - try { - const isDebugging = this.isDebugging; - const forDebugging: ContextItem[] | undefined = isDebugging ? [] : undefined; - const tokenSource = new vscode.CancellationTokenSource(); - const token = tokenSource.token; - const documentVersion = document.version; - const cacheState = this.runnableResultManager.getCacheState(); - let response: protocol.ComputeContextResponse; - let inflightRequest: InflightRequestInfo | undefined = undefined; - try { - const promise: Thenable = vscode.commands.executeCommand('typescript.tsserverRequest', '_.copilot.context', args, LanguageContextServiceImpl.ExecConfig, token); - inflightRequest = new InflightRequestInfo(document, position, context, tokenSource, promise); - this.inflightCachePopulationRequest = inflightRequest; - response = await promise; - } finally { - if (this.inflightCachePopulationRequest === inflightRequest) { - this.inflightCachePopulationRequest = undefined; - } - tokenSource.dispose(); - } - const timeTaken = Date.now() - startTime; - if (protocol.ComputeContextResponse.isCancelled(response)) { - this.telemetrySender.sendRequestCancelledTelemetry(context, timeTaken); - } else if (protocol.ComputeContextResponse.isOk(response)) { - const body: protocol.ComputeContextResponse.OK = response.body; - const contextItemResult = new ContextItemResultBuilder(timeTaken); - const { resolved, cached, referenced, serverComputed } = this.runnableResultManager.update(document, documentVersion, position, context, body, contextRequestState); - contextItemResult.cachedItems += cached; - contextItemResult.referencedItems += referenced; - contextItemResult.serverComputed = serverComputed; - if (resolved.length > 0) { - // Update the stats for telemetry. - for (const runnableResult of resolved) { - for (const converted of contextItemResult.update(runnableResult)) { - forDebugging?.push(converted.item); - } - } - } - contextItemResult.updateResponse(body, token); - this.telemetrySender.sendRequestTelemetry(document, position, context, contextItemResult, timeTaken, { before: cacheState, after: this.runnableResultManager.getCacheState() }, undefined); - // eslint-disable-next-line local/code-no-unused-expressions - isDebugging && forDebugging?.length; - this._onCachePopulated.fire({ document, position, source: context.source, items: resolved, summary: contextItemResult }); - } else if (protocol.ComputeContextResponse.isError(response)) { - this.telemetrySender.sendRequestFailureTelemetry(context, response.body); - console.error('Error populating cache:', response.body.message, response.body.stack); - } - } catch (error) { - this.logService.error(error, `Error populating cache for document: ${document.uri.toString()} at position: ${position.line + 1}:${position.character + 1}`); - } - if (this.pendingRequest !== undefined) { - // We had a pending request. Clear it and try to populate the cache again. - const pendingRequest = this.pendingRequest; - this.pendingRequest = undefined; - const textEditor = vscode.window.activeTextEditor; - if (textEditor !== undefined) { - const document = textEditor.document; - if (document.uri.toString() === pendingRequest.document && document.version === pendingRequest.version && document.validatePosition(pendingRequest.position).isEqual(pendingRequest.position)) { - this.populateCache(document, pendingRequest.position, pendingRequest.context).catch(() => { /* handled in populateCache */ }); - } - } - } + return this.tsLanguageContextService.populateCache(document, position, context); } public async *getContext(document: vscode.TextDocument, position: vscode.Position, context: RequestContext, token: vscode.CancellationToken): AsyncIterable { - this.onTimeoutData = undefined; - if (document.languageId !== 'typescript' && document.languageId !== 'typescriptreact') { - return; - } - - const startTime = Date.now(); - let cacheRequest = 'none'; - const cachePopulationRequestInflight = this.inflightCachePopulationRequest !== undefined && this.inflightCachePopulationRequest.matchesDocument(document); - if (cachePopulationRequestInflight) { - this.onTimeoutData = new OnTimeoutData(document, position); - cacheRequest = 'inflight'; - } - if (token.isCancellationRequested) { - this.telemetrySender.sendRequestCancelledTelemetry(context, Date.now() - startTime); - return; - } - const isDebugging = this.isDebugging; - const forDebugging: ContextItem[] | undefined = isDebugging ? [] : undefined; - const contextItemResult = new ContextItemResultBuilder(Date.now() - startTime); - if (this.onTimeoutData !== undefined) { - this.onTimeoutData.resultBuilder = contextItemResult; - } - const characterBudget = this.getCharacterBudget(context, document); - // We first collect all items to yield so that the state of the cache doesn't change underneath us. - // This could otherwise happen if the cache population request finishes while we are yielding items. - const itemsToYield: ContextItem[] = []; - const { mandatory, optional, onTimeout } = this.getRunnables(document, position, cachePopulationRequestInflight); - if (this.onTimeoutData !== undefined) { - this.onTimeoutData.addRunnableResults(onTimeout); - } - outer: for (const runnableResult of mandatory) { - for (const { item, size } of contextItemResult.update(runnableResult, true)) { - forDebugging?.push(item); - characterBudget.spend(size); - if (characterBudget.isExhausted()) { - break outer; - } - itemsToYield.push(item); - } - } - if (!characterBudget.isOptionalExhausted()) { - outer: for (const runnableResult of optional) { - for (const { item, size } of contextItemResult.update(runnableResult, true)) { - forDebugging?.push(item); - characterBudget.spend(size); - if (characterBudget.isOptionalExhausted()) { - break outer; - } - itemsToYield.push(item); - } - } - } - if (!token.isCancellationRequested) { - for (const item of itemsToYield) { - if (token.isCancellationRequested) { - this.onTimeoutData = undefined; - break; - } - yield item; - } - - // Recheck for an inflight request and join it if it is for the same document and position. - if (this.inflightCachePopulationRequest !== undefined && this.inflightCachePopulationRequest.matchesDocument(document)) { - cacheRequest = 'inflight'; - // We have an inflight request for the same document and position. - // We wait for the server promise to resolve and then see if we can yield items from the - // inflight request. - const timeOut = Math.max(0, Math.min(context.timeBudget ?? LanguageContextServiceImpl.defaultCachePopulationRaceTimeout, LanguageContextServiceImpl.defaultCachePopulationRaceTimeout)); - const result = await Promise.race([this.inflightCachePopulationRequest.serverPromise, new Promise((resolve) => setTimeout(resolve, timeOut)).then(() => 'timedOut')]); - // The server promised resolved first. So the inflight request is done. - if (result !== 'timedOut') { - this.inflightCachePopulationRequest = undefined; - if (this.onTimeoutData !== undefined) { - this.onTimeoutData = undefined; - const runnableResults = this.runnableResultManager.getCachedRunnableResults(document, position, protocol.EmitMode.ClientBasedOnTimeout); - for (const runnableResult of runnableResults) { - for (const { item } of contextItemResult.update(runnableResult)) { - forDebugging?.push(item); - yield item; - } - } - cacheRequest = 'awaited'; - } - } - } - } else { - this.onTimeoutData = undefined; - } - - const isSpeculativeRequest = context.proposedEdits !== undefined; - if (isSpeculativeRequest) { - this.telemetrySender.sendSpeculativeRequestTelemetry(context, this.runnableResultManager.getRequestId() ?? 'unknown', contextItemResult.stats.yielded); - } else { - const cacheState = this.runnableResultManager.getCacheState(); - contextItemResult.path = this.runnableResultManager.getNodePath(); - contextItemResult.cancelled = token.isCancellationRequested; - contextItemResult.serverTime = 0; - contextItemResult.contextComputeTime = 0; - contextItemResult.fromCache = true; - this.telemetrySender.sendRequestTelemetry( - document, position, context, contextItemResult, Date.now() - startTime, - { before: cacheState, after: cacheState }, cacheRequest - ); - // eslint-disable-next-line local/code-no-unused-expressions - isDebugging && forDebugging?.length; - this._onContextComputed.fire({ - document, position, source: context.source, items: itemsToYield, summary: contextItemResult - }); - } - return; + yield* this.tsLanguageContextService.getContext(document, position, context, token); } - private getRunnables(document: vscode.TextDocument, position: vscode.Position, cachePopulationInflight: boolean): { mandatory: readonly ResolvedRunnableResult[]; optional: readonly ResolvedRunnableResult[]; onTimeout: readonly ResolvedRunnableResult[] } { - const mandatory: ResolvedRunnableResult[] = []; - const optional: ResolvedRunnableResult[] = []; - const onTimeout: ResolvedRunnableResult[] = []; - for (const runnable of this.runnableResultManager.getCachedRunnableResults(document, position)) { - if (cachePopulationInflight && runnable.cache?.emitMode === protocol.EmitMode.ClientBasedOnTimeout) { - onTimeout.push(runnable); - } else { - const priority = runnable.priority; - if (priority === protocol.Priorities.Expression || priority === protocol.Priorities.Locals || priority === protocol.Priorities.Inherited || priority === protocol.Priorities.Traits) { - mandatory.push(runnable); - } else { - optional.push(runnable); - } - } - } - return { mandatory, optional, onTimeout }; + public getContextOnTimeout(document: vscode.TextDocument, position: vscode.Position, context: RequestContext): readonly ContextItem[] | undefined { + return this.tsLanguageContextService.getContextOnTimeout(document, position, context); } - public getContextOnTimeout(document: vscode.TextDocument, position: vscode.Position, context: RequestContext): readonly ContextItem[] | undefined { - try { - if (this.onTimeoutData === undefined) { - return []; + private updateTSLanguageContextService(): void { + const runsTS7 = TypeScript.runsVersion7(); + const enableTS7 = TypeScript.isVersion7SupportEnabled(this.configurationService); + const oldService: TSLanguageContextService = this.tsLanguageContextService; + if (runsTS7) { + if (oldService instanceof TS6LanguageContextService) { + oldService.dispose(); + this.tsLanguageContextService = enableTS7 + ? new TS7LanguageContextService(this.telemetryService, this.configurationService, this.experimentationService, this.logService) + : new NullTSLanguageContextService(); + } else if (oldService instanceof TS7LanguageContextService && !enableTS7) { + oldService.dispose(); + this.tsLanguageContextService = new NullTSLanguageContextService(); + } else if (oldService instanceof NullTSLanguageContextService && enableTS7) { + oldService.dispose(); + this.tsLanguageContextService = new TS7LanguageContextService(this.telemetryService, this.configurationService, this.experimentationService, this.logService); } - if (!this.onTimeoutData.matches(document, position) || this.onTimeoutData.resultBuilder === undefined) { - return []; - } - const result: ContextItem[] = []; - const contextItemResult = this.onTimeoutData.resultBuilder; - for (const runnableResult of this.onTimeoutData.runnableResults) { - for (const { item } of contextItemResult.update(runnableResult, true)) { - result.push(item); - } - } - return result; - } finally { - this.onTimeoutData = undefined; + } else if (!runsTS7 && (oldService instanceof TS7LanguageContextService || oldService instanceof NullTSLanguageContextService)) { + oldService.dispose(); + this.tsLanguageContextService = new TS6LanguageContextService(this.telemetryService, this.configurationService, this.experimentationService, this.logService); + } + if (oldService !== this.tsLanguageContextService) { + this.bindEvents(); } } - private getCachePopulationBudget(): number { - const result = this.configurationService.getExperimentBasedConfig(ConfigKey.TypeScriptLanguageContextCacheTimeout, this.experimentationService); - return result ?? LanguageContextServiceImpl.defaultCachePopulationBudget; - } - - private getUsageMode(): ContextItemUsageMode { - const value = this.configurationService.getExperimentBasedConfig(ConfigKey.TypeScriptLanguageContextMode, this.experimentationService); - return ContextItemUsageMode.fromString(value); - } - - private getIncludeDocumentation(): boolean { - return this.configurationService.getExperimentBasedConfig(ConfigKey.TypeScriptLanguageContextIncludeDocumentation, this.experimentationService); + private bindEvents(): void { + this.serviceListeners.clear(); + this.serviceListeners.add(this.tsLanguageContextService.onCachePopulated(e => this._onCachePopulated.fire(e))); + this.serviceListeners.add(this.tsLanguageContextService.onContextComputed(e => this._onContextComputed.fire(e))); + this.serviceListeners.add(this.tsLanguageContextService.onContextComputedOnTimeout(e => this._onContextComputedOnTimeout.fire(e))); } - private getCharacterBudget(context: RequestContext, document: vscode.TextDocument): CharacterBudget { - const chars = (context.tokenBudget ?? currentTokenBudget) * 4; - switch (this.usageMode) { - case ContextItemUsageMode.minimal: - return new CharacterBudget(chars, 0); - case ContextItemUsageMode.double: - return new CharacterBudget(chars, Math.min(chars, document.getText().length)); - case ContextItemUsageMode.fillHalf: - return new CharacterBudget(chars, Math.floor(chars / 2)); - case ContextItemUsageMode.fill: - return new CharacterBudget(chars, chars); - default: - return new CharacterBudget(chars, chars); - } - } } interface TokenBudgetProvider { @@ -1844,7 +370,7 @@ export class InlineCompletionContribution implements vscode.Disposable, TokenBud private typeScriptFileOpen(): void { this.checkRegistration(); this.disposables.add(this.configurationService.onDidChangeConfiguration((e) => { - if (e.affectsConfiguration(ConfigKey.TypeScriptLanguageContext.fullyQualifiedId)) { + if (e.affectsConfiguration(ConfigKey.TypeScriptLanguageContext.fullyQualifiedId) || e.affectsConfiguration(ConfigKey.TypeScript7LanguageContext.fullyQualifiedId) || TypeScript.affectsVersion(e)) { this.checkRegistration(); } })); @@ -1863,6 +389,7 @@ export class InlineCompletionContribution implements vscode.Disposable, TokenBud private async register(): Promise { if (! await this.isTypeScriptRunning()) { + this.unregister(); return; } @@ -1870,6 +397,7 @@ export class InlineCompletionContribution implements vscode.Disposable, TokenBud const logService = this.logService; try { if (! await languageContextService.isActivated('typescript')) { + this.unregister(); return; } @@ -1987,9 +515,12 @@ export class InlineCompletionContribution implements vscode.Disposable, TokenBud private async isTypeScriptRunning(): Promise { // Check that the TypeScript extension is installed and runs in the same extension host. - const typeScriptExtension = vscode.extensions.getExtension('vscode.typescript-language-features'); + const useTypeScript7 = TypeScript.runsVersion7(); + const typeScriptExtension = useTypeScript7 + ? TypeScript.getVersion7Extension() + : vscode.extensions.getExtension('vscode.typescript-language-features'); if (typeScriptExtension === undefined) { - this.telemetrySender.sendActivationFailedTelemetry(ErrorLocation.Client, ErrorPart.TypescriptPlugin, 'TypeScript extension not found', undefined); + this.telemetrySender.sendActivationFailedTelemetry(ErrorLocation.Client, ErrorPart.TypescriptPlugin, 'TypeScript extension not found', useTypeScript7 ? 'ts6' : 'ts7'); this.logService.error('TypeScript extension not found'); return false; } @@ -1998,10 +529,10 @@ export class InlineCompletionContribution implements vscode.Disposable, TokenBud return true; } catch (error) { if (error instanceof Error) { - this.telemetrySender.sendActivationFailedTelemetry(ErrorLocation.Client, ErrorPart.TypescriptPlugin, error.message, error.stack); + this.telemetrySender.sendActivationFailedTelemetry(ErrorLocation.Client, ErrorPart.TypescriptPlugin, error.message, error.stack, useTypeScript7 ? 'ts6' : 'ts7'); this.logService.error('Error checking if TypeScript plugin is installed:', error.message); } else { - this.telemetrySender.sendActivationFailedTelemetry(ErrorLocation.Client, ErrorPart.TypescriptPlugin, 'Unknown error', undefined); + this.telemetrySender.sendActivationFailedTelemetry(ErrorLocation.Client, ErrorPart.TypescriptPlugin, 'Unknown error', undefined, useTypeScript7 ? 'ts6' : 'ts7'); this.logService.error('Error checking if TypeScript plugin is installed: Unknown error'); } return false; diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/nesRenameService.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/nesRenameService.ts index 6201e9bfacc0cc..95d6a71ea163da 100644 --- a/extensions/copilot/src/extension/typescriptContext/vscode-node/nesRenameService.ts +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/nesRenameService.ts @@ -3,78 +3,45 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; import { ILogService } from '../../../platform/log/common/logService'; import { ITelemetryService } from '../../../platform/telemetry/common/telemetry'; -import { CancellationToken } from '../../../util/vs/base/common/cancellation'; import { DisposableStore } from '../../../util/vs/base/common/lifecycle'; import * as protocol from '../common/serverProtocol'; +import { TS7NesRenameService } from './ts7/nesRenameService'; +import { TS6NesRenameService } from './tsc6/nesRenameService'; +import { TypeScript } from './tsService'; -enum ExecutionTarget { - Semantic, - Syntax -} - -type ExecConfig = { - readonly lowPriority?: boolean; - readonly nonRecoverable?: boolean; - readonly cancelOnResourceChange?: vscode.Uri; - readonly executionTarget?: ExecutionTarget; +type TextChange = { + range: protocol.Range; + newText?: string; }; - -type PrepareNesRenameRequestArgs = Omit & { +type RenameGroup = { file: vscode.Uri; - line: number; - offset: number; + changes: TextChange[]; }; -namespace PrepareNesRenameRequestArgs { - export function create(document: vscode.TextDocument, position: vscode.Position, oldName: string, newName: string, lastSymbolRename: vscode.Range | undefined, startTime: number, timeBudget: number): PrepareNesRenameRequestArgs { - return { - file: vscode.Uri.file(document.fileName), - line: position.line + 1, - offset: position.character + 1, - oldName: oldName, - newName: newName, - lastSymbolRename: lastSymbolRename ? { - start: { line: lastSymbolRename.start.line + 1, character: lastSymbolRename.start.character + 1 }, - end: { line: lastSymbolRename.end.line + 1, character: lastSymbolRename.end.character + 1 } - } : undefined, - startTime: startTime, - timeBudget: timeBudget - }; - } +interface NesRenameService extends vscode.Disposable { + isActivated(documentOrLanguageId: vscode.TextDocument | string): Promise; + prepare(document: vscode.TextDocument, position: vscode.Position, oldName: string, newName: string, lastSymbolRename: vscode.Range | undefined, startTime: number, timeBudget: number, token: vscode.CancellationToken): Promise; + postRename(document: vscode.TextDocument, position: vscode.Position, oldName: string, newName: string, lastSymbolRename: vscode.Range | undefined, token: vscode.CancellationToken): Promise; } -type NesRenameRequestArgs = Omit & { - file: vscode.Uri; - line: number; - offset: number; -}; +class NullNesRenameService implements NesRenameService { + public dispose(): void { } -namespace NesRenameRequestArgs { - export function create(document: vscode.TextDocument, position: vscode.Position, oldName: string, newName: string, lastSymbolRename: vscode.Range | undefined): NesRenameRequestArgs { - return { - file: vscode.Uri.file(document.fileName), - line: position.line + 1, - offset: position.character + 1, - oldName: oldName, - newName: newName, - lastSymbolRename: lastSymbolRename ? { - start: { line: lastSymbolRename.start.line + 1, character: lastSymbolRename.start.character + 1 }, - end: { line: lastSymbolRename.end.line + 1, character: lastSymbolRename.end.character + 1 } - } : undefined - }; + public isActivated(): Promise { + return Promise.resolve(false); } -} -type TextChange = { - range: protocol.Range; - newText?: string; -}; -type RenameGroup = { - file: vscode.Uri; - changes: TextChange[]; -}; + public prepare(): Promise { + return Promise.resolve({ canRename: protocol.RenameKind.no, timedOut: false }); + } + + public postRename(): Promise { + return Promise.resolve([]); + } +} class TelemetrySender { @@ -137,18 +104,23 @@ class TelemetrySender { export class NesRenameContribution implements vscode.Disposable { - private _isActivated: Promise | undefined; private readonly disposables: DisposableStore; private readonly telemetrySender: TelemetrySender; - - private static readonly ExecConfig: ExecConfig = { executionTarget: ExecutionTarget.Semantic }; + private nesRenameService: NesRenameService; constructor( @ITelemetryService telemetryService: ITelemetryService, + @IConfigurationService private readonly configurationService: IConfigurationService, @ILogService private readonly logService: ILogService, ) { this.telemetrySender = new TelemetrySender(telemetryService, logService); this.disposables = new DisposableStore(); + this.nesRenameService = this.createNesRenameService(); + this.disposables.add(this.configurationService.onDidChangeConfiguration(e => { + if (TypeScript.affectsVersion(e) || e.affectsConfiguration(ConfigKey.TypeScript7LanguageContext.fullyQualifiedId)) { + this.updateNesRenameService(); + } + })); this.disposables.add(vscode.commands.registerCommand('github.copilot.nes.prepareRename', async (uri: vscode.Uri | undefined, position: vscode.Position | undefined, oldName: string | undefined, newName: string | undefined, requestId: string | undefined, lastSymbolRename: vscode.Range | undefined): Promise => { const no: protocol.PrepareNesRenameResult.No = { canRename: protocol.RenameKind.no, timedOut: false }; const params = this.resolvePrepareParams(uri, position, oldName, newName, requestId); @@ -160,31 +132,7 @@ export class NesRenameContribution implements vscode.Disposable { oldName = params.oldName; newName = params.newName; requestId = params.requestId; - - const activated = await this.isActivated(document); - if (!activated) { - return no; - } - - const startTime = Date.now(); - const args: PrepareNesRenameRequestArgs = PrepareNesRenameRequestArgs.create(document, position, oldName, newName, lastSymbolRename, startTime, 300); - - const tokenSource = new vscode.CancellationTokenSource(); - try { - const result = await vscode.commands.executeCommand('typescript.tsserverRequest', '_.copilot.prepareNesRename', args, NesRenameContribution.ExecConfig, tokenSource.token); - if (protocol.PrepareNesRenameResponse.isError(result)) { - this.telemetrySender.sendPrepareNesRenameFailureTelemetry(requestId, result.body); - return no; - } else if (protocol.PrepareNesRenameResponse.isOk(result)) { - const timedOut = result.body.canRename === protocol.RenameKind.no ? result.body.timedOut : false; - this.telemetrySender.sendPrepareNesRenameTelemetry(requestId, Date.now() - startTime, result.body.canRename, timedOut); - return result.body; - } else { - return no; - } - } finally { - tokenSource.dispose(); - } + return this.prepareRename(document, position, oldName, newName, requestId, lastSymbolRename); })); this.disposables.add(vscode.commands.registerCommand('github.copilot.nes.postRename', async (uri: vscode.Uri | undefined, position: vscode.Position | undefined, oldName: string | undefined, newName: string | undefined, lastSymbolRename: vscode.Range | undefined): Promise => { const params = this.resolveRenameParams(uri, position, oldName, newName); @@ -195,23 +143,7 @@ export class NesRenameContribution implements vscode.Disposable { position = params.position; oldName = params.oldName; newName = params.newName; - const args: NesRenameRequestArgs = NesRenameRequestArgs.create(document, position, oldName, newName, lastSymbolRename); - const tokenSource = new vscode.CancellationTokenSource(); - try { - const result = await vscode.commands.executeCommand('typescript.tsserverRequest', '_.copilot.postNesRename', args, NesRenameContribution.ExecConfig, tokenSource.token); - if (protocol.NesRenameResponse.isError(result)) { - return []; - } else if (protocol.NesRenameResponse.isOk(result)) { - return result.body.groups.map(group => ({ - changes: group.changes, - file: vscode.Uri.file(group.file) - })); - } else { - return []; - } - } finally { - tokenSource.dispose(); - } + return this.postRename(document, position, oldName, newName, lastSymbolRename); })); this.disposables.add(vscode.commands.registerCommand('github.copilot.debug.validateNesRename', async () => { const params = await this.getUserParams(); @@ -225,73 +157,102 @@ export class NesRenameContribution implements vscode.Disposable { return; } - const args: PrepareNesRenameRequestArgs = PrepareNesRenameRequestArgs.create(document, position, oldName, newName, new vscode.Range(1, 7, 1, 13), Date.now(), 300); - const tokenSource = new vscode.CancellationTokenSource(); - try { - const result = await vscode.commands.executeCommand('typescript.tsserverRequest', '_.copilot.prepareNesRename', args, NesRenameContribution.ExecConfig, tokenSource.token); - if (protocol.PrepareNesRenameResponse.isError(result)) { - vscode.window.showErrorMessage(`Prepare NES Rename error: ${result.message}`); - } else if (protocol.PrepareNesRenameResponse.isOk(result)) { - const body = result.body; - if (body.canRename === protocol.RenameKind.yes) { - vscode.window.showInformationMessage(`Prepare NES Rename: Can rename '${oldName}' to '${newName}'.`); - } else if (body.canRename === protocol.RenameKind.maybe) { - vscode.window.showWarningMessage(`Prepare NES Rename: Maybe can rename '${oldName}' to '${newName}'.`); - } else { - vscode.window.showErrorMessage(`Prepare NES Rename: Cannot rename '${oldName}' to '${newName}'. Reason: ${body.reason ?? 'Not provided'}`); - } - } - } finally { - tokenSource.dispose(); + const result = await this.prepareRename(document, position, oldName, newName, 'debug', new vscode.Range(1, 7, 1, 13)); + if (result.canRename === protocol.RenameKind.yes) { + vscode.window.showInformationMessage(`Prepare NES Rename: Can rename '${oldName}' to '${newName}'.`); + } else if (result.canRename === protocol.RenameKind.maybe) { + vscode.window.showWarningMessage(`Prepare NES Rename: Maybe can rename '${oldName}' to '${newName}'.`); + } else { + vscode.window.showErrorMessage(`Prepare NES Rename: Cannot rename '${oldName}' to '${newName}'. Reason: ${result.reason ?? 'Not provided'}`); } })); } public dispose(): void { + this.nesRenameService.dispose(); this.disposables.dispose(); } - private async isActivated(documentOrLanguageId: vscode.TextDocument | string): Promise { - const languageId = typeof documentOrLanguageId === 'string' ? documentOrLanguageId : documentOrLanguageId.languageId; - if (languageId !== 'typescript' && languageId !== 'typescriptreact') { - return false; + private async prepareRename(document: vscode.TextDocument, position: vscode.Position, oldName: string, newName: string, requestId: string, lastSymbolRename: vscode.Range | undefined): Promise { + const no: protocol.PrepareNesRenameResult.No = { canRename: protocol.RenameKind.no, timedOut: false }; + const service = this.nesRenameService; + if (!await service.isActivated(document)) { + return no; } - if (this._isActivated === undefined) { - this._isActivated = this.doIsTypeScriptActivated(languageId); + + const startTime = Date.now(); + const timeBudget = 300; + const tokenSource = new vscode.CancellationTokenSource(); + try { + const body = await service.prepare(document, position, oldName, newName, lastSymbolRename, startTime, timeBudget, tokenSource.token); + if ('error' in body) { + this.telemetrySender.sendPrepareNesRenameFailureTelemetry(requestId, body); + return no; + } + const timedOut = body.canRename === protocol.RenameKind.no ? body.timedOut : false; + this.telemetrySender.sendPrepareNesRenameTelemetry(requestId, Date.now() - startTime, body.canRename, timedOut); + return body; + } catch (error) { + const data: protocol.CustomResponse.Failed = error instanceof Error + ? { error: protocol.ErrorCode.exception, message: error.message, stack: error.stack } + : { error: protocol.ErrorCode.exception, message: 'Unknown error' }; + this.telemetrySender.sendPrepareNesRenameFailureTelemetry(requestId, data); + this.logService.error(`Error preparing TypeScript ${TypeScript.runsVersion7() ? '7' : '6'} NES rename:`, error); + return no; + } finally { + tokenSource.dispose(); } - return this._isActivated; } - private async doIsTypeScriptActivated(languageId: string): Promise { - let activated = false; - + private async postRename(document: vscode.TextDocument, position: vscode.Position, oldName: string, newName: string, lastSymbolRename: vscode.Range | undefined): Promise { + const tokenSource = new vscode.CancellationTokenSource(); try { - // Check that the TypeScript extension is installed and runs in the same extension host. - const typeScriptExtension = vscode.extensions.getExtension('vscode.typescript-language-features'); - if (typeScriptExtension === undefined) { - return false; - } + const groups = await this.nesRenameService.postRename(document, position, oldName, newName, lastSymbolRename, tokenSource.token); + return groups.map(group => ({ + changes: group.changes, + file: vscode.Uri.file(group.file), + })); + } catch (error) { + this.logService.error(`Error computing TypeScript ${TypeScript.runsVersion7() ? '7' : '6'} NES rename edits:`, error); + return []; + } finally { + tokenSource.dispose(); + } + } - // Make sure the TypeScript extension is activated. - await typeScriptExtension.activate(); + private async isActivated(documentOrLanguageId: vscode.TextDocument | string): Promise { + return this.nesRenameService.isActivated(documentOrLanguageId); + } - // Send a ping request to see if the TS server plugin got installed correctly. - const response: protocol.PingResponse | undefined = await vscode.commands.executeCommand('typescript.tsserverRequest', '_.copilot.ping', NesRenameContribution.ExecConfig, CancellationToken.None); - if (response !== undefined) { - if (response.body?.kind === 'ok') { - this.logService.info('TypeScript server plugin activated.'); - activated = true; - } else { - this.logService.error('TypeScript server plugin not activated:', response.body?.message ?? 'Message not provided.'); - } - } else { - this.logService.error('TypeScript server plugin not activated:', 'No ping response received.'); + private updateNesRenameService(): void { + const runsTS7 = TypeScript.runsVersion7(); + const enableTS7 = TypeScript.isVersion7SupportEnabled(this.configurationService); + const oldService = this.nesRenameService; + if (runsTS7) { + if (oldService instanceof TS6NesRenameService) { + this.nesRenameService = enableTS7 + ? new TS7NesRenameService(this.logService) + : new NullNesRenameService(); + } else if (oldService instanceof TS7NesRenameService && !enableTS7) { + this.nesRenameService = new NullNesRenameService(); + } else if (oldService instanceof NullNesRenameService && enableTS7) { + this.nesRenameService = new TS7NesRenameService(this.logService); } - } catch (error) { - this.logService.error('Error pinging TypeScript server plugin:', error); + } else if (!(oldService instanceof TS6NesRenameService)) { + this.nesRenameService = new TS6NesRenameService(this.logService); } + if (oldService !== this.nesRenameService) { + oldService.dispose(); + } + } - return activated; + private createNesRenameService(): NesRenameService { + if (!TypeScript.runsVersion7()) { + return new TS6NesRenameService(this.logService); + } + return TypeScript.isVersion7SupportEnabled(this.configurationService) + ? new TS7NesRenameService(this.logService) + : new NullNesRenameService(); } private resolvePrepareParams(uri: vscode.Uri | undefined, position: vscode.Position | undefined, oldName: string | undefined, newName: string | undefined, requestId: string | undefined): { document: vscode.TextDocument; position: vscode.Position; oldName: string; newName: string; requestId: string } | undefined { diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/telemetrySender.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/telemetrySender.ts new file mode 100644 index 00000000000000..9bec0708232ebe --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/telemetrySender.ts @@ -0,0 +1,423 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; + +import { ILogService } from '../../../platform/log/common/logService'; +import { ITelemetryService } from '../../../platform/telemetry/common/telemetry'; +import { KnownSources, TriggerKind, type RequestContext } from '../../../platform/languageServer/common/languageContextService'; +import { ContextItemSummary, ErrorLocation, ErrorPart, type CacheState } from './types'; + +import * as protocol from '../common/serverProtocol'; + +namespace RequestContext { + export function getSampleTelemetry(context: RequestContext): number { + return Math.max(1, Math.min(100, context.sampleTelemetry ?? 1)); + } +} + +interface TypeScriptServerError extends Error { + response: { + type: 'response'; + command: string; + message: string; + }; + version: { + displayName: string; + }; +} +namespace TypeScriptServerError { + export function is(value: Error): value is TypeScriptServerError { + const candidate = value as TypeScriptServerError; + return candidate instanceof Error && candidate.response !== undefined && candidate.version !== undefined && typeof candidate.version.displayName === 'string'; + } +} + +export class TelemetrySender { + + private readonly telemetryService: ITelemetryService; + private readonly logService: ILogService; + private sendRequestTelemetryCounter: number; + private sendSpeculativeRequestTelemetryCounter: number; + + constructor(telemetryService: ITelemetryService, logService: ILogService) { + this.telemetryService = telemetryService; + this.logService = logService; + this.sendRequestTelemetryCounter = 0; + this.sendSpeculativeRequestTelemetryCounter = 0; + } + + public sendSpeculativeRequestTelemetry(context: RequestContext, originalRequestId: string, numberOfItems: number): void { + const sampleTelemetry = RequestContext.getSampleTelemetry(context); + const shouldSendTelemetry = sampleTelemetry === 1 || this.sendSpeculativeRequestTelemetryCounter % sampleTelemetry === 0; + this.sendSpeculativeRequestTelemetryCounter++; + + if (shouldSendTelemetry) { + /* __GDPR__ + "typescript-context-plugin.completion-context.speculative" : { + "owner": "dirkb", + "comment": "Telemetry for copilot inline completion context", + "requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The request correlation id" }, + "source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The source of the request" }, + "originalRequestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The original request id for which this is a speculative request" }, + "numberOfItems": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of items in the speculative request", "isMeasurement": true }, + "sampleTelemetry": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The sampling rate for telemetry. A value of 1 means every request is logged, a value of 5 means every 5th request is logged, etc.", "isMeasurement": true } + } + */ + this.telemetryService.sendMSFTTelemetryEvent( + 'typescript-context-plugin.completion-context.speculative', + { + requestId: context.requestId, + source: context.source ?? KnownSources.unknown, + originalRequestId: originalRequestId + }, + { + numberOfItems: numberOfItems, + sampleTelemetry: sampleTelemetry + } + ); + } + this.logService.debug(`TypeScript Copilot context speculative request: [${context.requestId} - ${originalRequestId}, numberOfItems: ${numberOfItems}]`); + } + + public willLogRequestTelemetry(context: RequestContext): boolean { + const sampleTelemetry = RequestContext.getSampleTelemetry(context); + return sampleTelemetry === 1 || this.sendRequestTelemetryCounter % sampleTelemetry === 0; + } + + public sendRequestTelemetry(document: vscode.TextDocument, position: vscode.Position, context: RequestContext, data: ContextItemSummary, timeTaken: number, cacheState: { before: CacheState; after: CacheState } | undefined, cacheRequest: string | undefined): void { + const stats = data.stats; + const nodePath = data?.path ? JSON.stringify(data.path) : JSON.stringify([0]); + const items = stats.items; + const totalSize = stats.totalSize; + const fileSize = document.getText().length; + + const sampleTelemetry = RequestContext.getSampleTelemetry(context); + const shouldSendTelemetry = sampleTelemetry === 1 || this.sendRequestTelemetryCounter % sampleTelemetry === 0; + this.sendRequestTelemetryCounter++; + if (shouldSendTelemetry) { + /* __GDPR__ + "typescript-context-plugin.completion-context.request" : { + "owner": "dirkb", + "comment": "Telemetry for copilot inline completion context", + "requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The request correlation id" }, + "opportunityId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The opportunity id" }, + "source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The source of the request" }, + "trigger": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The trigger kind of the request" }, + "cacheRequest": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The cache request that was used to populate the cache" }, + "nodePath": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The syntax kind path to the AST node the position resolved to." }, + "cancelled": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the request got cancelled on the client side" }, + "timedOut": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the request timed out on the server side" }, + "tokenBudgetExhausted": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the token budget was exhausted" }, + "serverTime": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Time taken on the server side", "isMeasurement": true }, + "contextComputeTime": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Time taken on the server side to compute the context", "isMeasurement": true }, + "timeTaken": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Time taken to provide the completion", "isMeasurement": true }, + "total": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Total number of context items", "isMeasurement": true }, + "snippets": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of code snippets", "isMeasurement": true }, + "traits": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of traits", "isMeasurement": true }, + "yielded": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of yielded items", "isMeasurement": true }, + "items": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Detailed information about each context item delivered." }, + "totalSize": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Total size of all context items", "isMeasurement": true }, + "fileSize": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The size of the file", "isMeasurement": true }, + "cachedItems": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of cache items", "isMeasurement": true }, + "referencedItems": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of referenced items", "isMeasurement": true }, + "isSpeculative": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the request was speculative" }, + "beforeCacheState": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The cache state before the request was sent" }, + "afterCacheState": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The cache state after the request was sent" }, + "fromCache": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the context was fully provided from cache" }, + "sampleTelemetry": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The sampling rate for telemetry. A value of 1 means every request is logged, a value of 5 means every 5th request is logged, etc.", "isMeasurement": true } + } + */ + this.telemetryService.sendMSFTTelemetryEvent( + 'typescript-context-plugin.completion-context.request', + { + requestId: context.requestId, + opportunityId: context.opportunityId ?? 'unknown', + source: context.source ?? KnownSources.unknown, + trigger: context.trigger ?? TriggerKind.unknown, + cacheRequest: cacheRequest ?? 'unknown', + nodePath: nodePath, + cancelled: data.cancelled.toString(), + timedOut: data.timedOut.toString(), + tokenBudgetExhausted: data.tokenBudgetExhausted.toString(), + items: JSON.stringify(items), + isSpeculative: (context.proposedEdits !== undefined && context.proposedEdits.length > 0 ? true : false).toString(), + beforeCacheState: cacheState?.before.toString(), + afterCacheState: cacheState?.after.toString(), + fromCache: data.fromCache.toString(), + }, + { + serverTime: data.serverTime, + contextComputeTime: data.contextComputeTime, + timeTaken, + total: stats.total, + snippets: stats.snippets, + traits: stats.traits, + yielded: stats.yielded, + totalSize: totalSize, + fileSize: fileSize, + cachedItems: data.cachedItems, + referencedItems: data.referencedItems, + sampleTelemetry: sampleTelemetry + } + ); + } + this.logService.debug(`TypeScript Copilot context: [${context.requestId}, ${context.source ?? KnownSources.unknown}, ${JSON.stringify(position, undefined, 0)}, ${JSON.stringify(nodePath, undefined, 0)}, ${JSON.stringify(stats, undefined, 0)}, cacheItems:${data.cachedItems}, cacheState:${JSON.stringify(cacheState, undefined, 0)}, budgetExhausted:${data.tokenBudgetExhausted}, cancelled:${data.cancelled}, timedOut:${data.timedOut}, fileSize:${fileSize}] in [${timeTaken},${data.serverTime},${data.contextComputeTime}]ms.${data.timedOut ? ' Timed out.' : ''}`); + if (data.errorData !== undefined && data.errorData.length > 0) { + const errorData = data.errorData; + for (const error of errorData) { + /* __GDPR__ + "typescript-context-plugin.completion-context.error" : { + "owner": "dirkb", + "comment": "Telemetry for copilot inline completion context errors", + "requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The request correlation id" }, + "source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The source of the request" }, + "code": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The failure code", "isMeasurement": true }, + "message": { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "The failure message" } + } + */ + this.telemetryService.sendMSFTTelemetryEvent( + 'typescript-context-plugin.completion-context.error', + { + requestId: context.requestId, + source: context.source ?? KnownSources.unknown, + message: error.message + }, + { + code: error.code + } + ); + this.logService.error('Error computing context:', `${error.message} [${error.code}]`); + } + } + } + + public sendRequestOnTimeoutTelemetry(context: RequestContext, data: ContextItemSummary, cacheState: CacheState): void { + const stats = data.stats; + const items = stats.items; + const totalSize = stats.totalSize; + /* __GDPR__ + "typescript-context-plugin.completion-context.on-timeout" : { + "owner": "dirkb", + "comment": "Telemetry for copilot inline completion context on timeout", + "requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The request correlation id" }, + "opportunityId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The opportunity id" }, + "source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The source of the request" }, + "total": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Total number of context items", "isMeasurement": true }, + "snippets": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of code snippets", "isMeasurement": true }, + "traits": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of traits", "isMeasurement": true }, + "yielded": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of yielded items", "isMeasurement": true }, + "items": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Detailed information about each context item delivered." }, + "totalSize": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Total size of all context items", "isMeasurement": true }, + "cacheState": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The cache state for the onTimeout request" } + } + */ + this.telemetryService.sendMSFTTelemetryEvent( + 'typescript-context-plugin.completion-context.on-timeout', + { + requestId: context.requestId, + opportunityId: context.opportunityId ?? 'unknown', + source: context.source ?? KnownSources.unknown, + items: JSON.stringify(items), + cacheState: cacheState.toString() + }, + { + total: stats.total, + snippets: stats.snippets, + traits: stats.traits, + yielded: stats.yielded, + totalSize: totalSize + } + ); + this.logService.debug(`TypeScript Copilot context on timeout: [${context.requestId}, ${JSON.stringify(stats, undefined, 0)}]`); + } + + public sendRequestFailureTelemetry(context: RequestContext, data: { error: protocol.ErrorCode; message: string; stack?: string }): void { + /* __GDPR__ + "typescript-context-plugin.completion-context.failed" : { + "owner": "dirkb", + "comment": "Telemetry for copilot inline completion context in failure case", + "requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The request correlation id" }, + "opportunityId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The opportunity id" }, + "source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The source of the request" }, + "code:": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The failure code" }, + "message": { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "The failure message" }, + "stack": { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "The failure stack" } + } + */ + this.telemetryService.sendMSFTTelemetryEvent( + 'typescript-context-plugin.completion-context.failed', + { + requestId: context.requestId, + opportunityId: context.opportunityId ?? 'unknown', + source: context.source ?? KnownSources.unknown, + code: data.error, + message: data.message, + stack: data.stack ?? 'Not available' + } + ); + } + + public sendRequestCancelledTelemetry(context: RequestContext, timeTaken: number): void { + /* __GDPR__ + "typescript-context-plugin.completion-context.cancelled" : { + "owner": "dirkb", + "comment": "Telemetry for copilot inline completion context in cancellation case", + "requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The request correlation id" }, + "opportunityId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The opportunity id" }, + "source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The source of the request" }, + "timeTaken": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Time taken to provide the completion", "isMeasurement": true } + } + */ + this.telemetryService.sendMSFTTelemetryEvent( + 'typescript-context-plugin.completion-context.cancelled', + { + requestId: context.requestId, + opportunityId: context.opportunityId ?? 'unknown', + source: context.source ?? KnownSources.unknown + }, + { + timeTaken: timeTaken + } + ); + this.logService.debug(`TypeScript Copilot context request ${context.requestId} got cancelled.`); + } + + public sendActivationTelemetry(response: protocol.PingResponse | undefined, error: unknown | undefined): void { + if (response !== undefined) { + const body: protocol.PingResponse['body'] | undefined = response?.body; + if (body?.kind === 'ok') { + /* __GDPR__ + "typescript-context-plugin.activation.ok" : { + "owner": "dirkb", + "comment": "Telemetry for TypeScript server plugin", + "session": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the TypeScript server had a session" }, + "supported": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the TypeScript server version is supported" }, + "version": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The version of the TypeScript server" } + } + */ + this.telemetryService.sendMSFTTelemetryEvent( + 'typescript-context-plugin.activation.ok', + { + session: body.session.toString(), + supported: body.supported.toString(), + version: body.version ?? 'unknown' + } + ); + } else if (body?.kind === 'error') { + this.sendActivationFailedTelemetry(ErrorLocation.Server, ErrorPart.ServerPlugin, body.message, body.stack); + } else { + this.sendUnknownPingResponseTelemetry(ErrorLocation.Server, ErrorPart.ServerPlugin, response); + } + } else if (error !== undefined) { + const isError = error instanceof Error; + if (isError && TypeScriptServerError.is(error)) { + this.sendActivationFailedTelemetry(ErrorLocation.Server, ErrorPart.ServerPlugin, error.response.message ?? error.message, undefined, error.version.displayName); + } else if (isError) { + this.sendActivationFailedTelemetry(ErrorLocation.Client, ErrorPart.ServerPlugin, error.message, error.stack); + } else { + this.sendActivationFailedTelemetry(ErrorLocation.Client, ErrorPart.ServerPlugin, 'Unknown error', undefined); + } + } else { + this.sendActivationFailedTelemetry(ErrorLocation.Client, ErrorPart.ServerPlugin, 'Neither response nor error received.', undefined); + } + } + + public sendActivationFailedTelemetry(location: ErrorLocation, part: ErrorPart, message: string, stack?: string | undefined, version?: string | undefined): void { + /* __GDPR__ + "typescript-context-plugin.activation.failed" : { + "owner": "dirkb", + "comment": "Telemetry for TypeScript server plugin", + "location": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The location of the failure" }, + "part": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The part that errored" }, + "message": { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "The failure message" }, + "stack": { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "The failure stack" }, + "version": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The version" } + } + */ + this.telemetryService.sendMSFTTelemetryEvent( + 'typescript-context-plugin.activation.failed', + { + location: location, + part: part, + message: message, + stack: stack ?? 'Not available', + version: version ?? 'Not specified' + } + ); + } + + private sendUnknownPingResponseTelemetry(location: ErrorLocation, part: ErrorPart, response: object): void { + /* __GDPR__ + "typescript-context-plugin.activation.unknown-ping-response" : { + "owner": "dirkb", + "comment": "Telemetry for TypeScript server plugin", + "location": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The location of the failure" }, + "part": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The part that errored" }, + "response": { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "The response literal" } + } + */ + this.telemetryService.sendMSFTTelemetryEvent( + 'typescript-context-plugin.activation.unknown-ping-response', + { + location: location, + part: part, + response: JSON.stringify(response, undefined, 0) + } + ); + } + + public sendIntegrationTelemetry(requestId: string, document: string, versionMismatch?: string): void { + /* __GDPR__ + "typescript-context-plugin.integration.failed" : { + "owner": "dirkb", + "comment": "Telemetry for Copilot inline chat integration.", + "requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The request correlation id" }, + "document": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The document for which the integration failed" }, + "versionMismatch": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The version mismatch" } + } + */ + this.telemetryService.sendMSFTTelemetryEvent( + 'typescript-context-plugin.integration.failed', + { + requestId: requestId, + document: document, + versionMismatch: versionMismatch + } + ); + } + + public sendInlineCompletionProviderTelemetry(source: KnownSources, registered: boolean): void { + if (registered) { + /* __GDPR__ + "typescript-context-plugin.inline-completion-provider.registered" : { + "owner": "dirkb", + "comment": "Telemetry for Copilot inline completions", + "source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The source of the request" } + } + */ + this.telemetryService.sendMSFTTelemetryEvent( + 'typescript-context-plugin.inline-completion-provider.registered', + { + source: source + } + ); + } else { + /* __GDPR__ + "typescript-context-plugin.inline-completion-provider.unregistered" : { + "owner": "dirkb", + "comment": "Telemetry for Copilot inline completions", + "source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The source of the request" } + } + */ + this.telemetryService.sendMSFTTelemetryEvent( + 'typescript-context-plugin.inline-completion-provider.unregistered', + { + source: source + } + ); + } + } +} diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/test/tsService.spec.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/test/tsService.spec.ts new file mode 100644 index 00000000000000..d9f92d58a32f84 --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/test/tsService.spec.ts @@ -0,0 +1,44 @@ +/*--------------------------------------------------------------------------------------------- + * 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 'node:assert'; + +import type * as vscode from 'vscode'; +import { suite, test, vi } from 'vitest'; + +vi.mock('vscode', () => ({})); + +import { TypeScript } from '../tsService'; + +suite('TypeScript service', () => { + test('prefers the current TS7 extension and falls back to the legacy extension', () => { + const currentExtensionId = 'typescriptteam.vscode-typescript'; + const legacyExtensionId = 'typescriptteam.native-preview'; + const scenarios = [ + [currentExtensionId, legacyExtensionId], + [legacyExtensionId], + [], + ]; + + const actual = scenarios.map(extensionIds => { + const available = new Map>(); + for (const extensionId of extensionIds) { + available.set(extensionId, { id: extensionId } as vscode.Extension); + } + const lookups: string[] = []; + const extension = TypeScript.getVersion7Extension(extensionId => { + lookups.push(extensionId); + return available.get(extensionId); + }); + return { selected: extension?.id, lookups }; + }); + + assert.deepStrictEqual(actual, [ + { selected: currentExtensionId, lookups: [currentExtensionId] }, + { selected: legacyExtensionId, lookups: [currentExtensionId, legacyExtensionId] }, + { selected: undefined, lookups: [currentExtensionId, legacyExtensionId] }, + ]); + }); +}); diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/api.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/api.ts new file mode 100644 index 00000000000000..b70a2a88e503d4 --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/api.ts @@ -0,0 +1,299 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { API, Project, Snapshot, DocumentIdentifier } from '@typescript/native/unstable/async'; +import { + SyntaxKind, + isArrowFunction, + isClassDeclaration, + isConstructorDeclaration, + isFunctionDeclaration, + isFunctionExpression, + isGetAccessorDeclaration, + isMethodDeclaration, + isModuleDeclaration, + isSetAccessorDeclaration, + isSourceFile, + type Node, + type SourceFile, +} from '@typescript/native/unstable/ast'; +import * as protocol from '../../common/serverProtocol'; +import { CompilerOptionsRunnable } from './baseContextProviders'; +import { ClassContextProvider } from './classContextProvider'; +import { ContextProvider, ContextRunnableCollector, type ComputeContextSession, type ContextProviderFactory, type ContextResult, type ContextRunnable, type ProviderComputeContext, type RequestContext } from './contextProvider'; +import { FunctionContextProvider } from './functionContextProvider'; +import { AccessorProvider, ConstructorContextProvider, MethodContextProvider } from './methodContextProvider'; +import { ModuleContextProvider } from './moduleContextProvider'; +import { PrepareNesRenameResult, validateNesRename } from './nesRenameValidator'; +import { SourceFileContextProvider } from './sourceFileContextProvider'; +import { RecoverableError } from './types'; +import tss, { Symbols, type CancellationTokenWithTimer } from './typescripts'; + +class ProviderComputeContextImpl implements ProviderComputeContext { + private firstCallableProvider: ContextProvider | undefined; + + public update(contextProvider: ContextProvider): ContextProvider { + if (this.firstCallableProvider === undefined && contextProvider.isCallableProvider === true) { + this.firstCallableProvider = contextProvider; + } + return contextProvider; + } + + public isFirstCallableProvider(contextProvider: ContextProvider): boolean { + return this.firstCallableProvider === contextProvider; + } +} + +class ContextProviders { + private static readonly Factories = new Map([ + [SyntaxKind.SourceFile, (_node, tokenInfo, computeContext) => new SourceFileContextProvider(tokenInfo, computeContext)], + [SyntaxKind.FunctionDeclaration, (node, tokenInfo, computeContext) => isFunctionDeclaration(node) ? new FunctionContextProvider(node, tokenInfo, computeContext) : undefined], + [SyntaxKind.ArrowFunction, (node, tokenInfo, computeContext) => isArrowFunction(node) ? new FunctionContextProvider(node, tokenInfo, computeContext) : undefined], + [SyntaxKind.FunctionExpression, (node, tokenInfo, computeContext) => isFunctionExpression(node) ? new FunctionContextProvider(node, tokenInfo, computeContext) : undefined], + [SyntaxKind.GetAccessor, (node, tokenInfo, computeContext) => isGetAccessorDeclaration(node) ? new AccessorProvider(node, tokenInfo, computeContext) : undefined], + [SyntaxKind.SetAccessor, (node, tokenInfo, computeContext) => isSetAccessorDeclaration(node) ? new AccessorProvider(node, tokenInfo, computeContext) : undefined], + [SyntaxKind.ClassDeclaration, (node, tokenInfo) => isClassDeclaration(node) ? ClassContextProvider.create(node, tokenInfo) : undefined], + [SyntaxKind.Constructor, (node, tokenInfo, computeContext) => isConstructorDeclaration(node) ? new ConstructorContextProvider(node, tokenInfo, computeContext) : undefined], + [SyntaxKind.MethodDeclaration, (node, tokenInfo, computeContext) => isMethodDeclaration(node) ? new MethodContextProvider(node, tokenInfo, computeContext) : undefined], + [SyntaxKind.ModuleDeclaration, (node, tokenInfo, computeContext) => isModuleDeclaration(node) ? new ModuleContextProvider(node, tokenInfo, computeContext) : undefined], + ]); + + private readonly tokenInfo: tss.TokenInfo; + private readonly computeInfo: ProviderComputeContextImpl = new ProviderComputeContextImpl(); + + constructor(tokenInfo: tss.TokenInfo) { + this.tokenInfo = tokenInfo; + } + + public async execute(result: ContextResult, session: ComputeContextSession, project: Project, token: CancellationTokenWithTimer): Promise { + const collector = await this.getContextRunnables(session, project, result.context, token); + result.addPath(tss.StableSyntaxKinds.getPath(this.tokenInfo.touching ?? this.tokenInfo.token)); + for (const runnable of collector.entries()) { + runnable.initialize(result); + } + await this.executeRunnables(collector.getPrimaryRunnables(), result, token); + await this.executeRunnables(collector.getSecondaryRunnables(), result, token); + await this.executeRunnables(collector.getTertiaryRunnables(), result, token); + result.done(); + } + + private async executeRunnables(runnables: ContextRunnable[], result: ContextResult, token: CancellationTokenWithTimer): Promise { + for (const runnable of runnables) { + token.throwIfCancellationRequested(); + try { + await runnable.compute(token); + } catch (error) { + if (error instanceof RecoverableError) { + result.addErrorData(error); + } else { + throw error; + } + } + } + } + + private async getContextRunnables(session: ComputeContextSession, project: Project, context: RequestContext, token: CancellationTokenWithTimer): Promise { + const result = new ContextRunnableCollector(context.clientSideRunnableResults); + result.addPrimary(new CompilerOptionsRunnable(session, project, context, this.tokenInfo.token.getSourceFile())); + for (const provider of this.computeProviders()) { + await provider.provide(result, session, project, context, token); + } + return result; + } + + private computeProviders(): ContextProvider[] { + const result: ContextProvider[] = []; + let token: Node | undefined = this.tokenInfo.touching; + if (token === undefined) { + token = this.tokenInfo.token.kind === SyntaxKind.EndOfFile ? this.tokenInfo.previous : this.tokenInfo.token; + } + if (token === undefined || token.kind === SyntaxKind.EndOfFile) { + return result; + } + let current: Node | undefined = token; + while (current !== undefined) { + const factory = ContextProviders.Factories.get(current.kind); + const provider = factory?.(current, this.tokenInfo, this.computeInfo); + if (provider !== undefined) { + result.push(this.computeInfo.update(provider)); + } + if (isSourceFile(current)) { + break; + } + current = current.parent; + } + return result; + } +} + +export async function computeContext(result: ContextResult, session: ComputeContextSession, project: Project, document: SourceFile, position: number, token: CancellationTokenWithTimer): Promise { + const sourceFile = await project.program.getSourceFile(document.fileName); + if (sourceFile === undefined) { + result.addErrorData(new RecoverableError('No source file found for document', RecoverableError.NoSourceFile)); + return; + } + const tokenInfo = tss.getRelevantTokens(sourceFile, position); + await new ContextProviders(tokenInfo).execute(result, session, project, token); +} + +export async function prepareNesRename(result: PrepareNesRenameResult, api: API, snapshot: Snapshot, project: Project, document: SourceFile, position: number, oldName: string | undefined, newName: string | undefined, lastSymbolRename: protocol.Range | undefined, token: CancellationTokenWithTimer): Promise { + if (typeof oldName !== 'string' || oldName.length === 0) { + result.setCanRename(protocol.RenameKind.no, 'No old name provided'); + return; + } + if (typeof newName !== 'string' || newName.length === 0) { + result.setCanRename(protocol.RenameKind.no, 'No new name provided'); + return; + } + + const state = await doPrepareNesRename(result, project, document, position, oldName, newName, token); + if (state !== PrepareState.unavailable || lastSymbolRename === undefined) { + return; + } + + const [oldText, oldPosition] = getOldText(document, position, oldName, newName, lastSymbolRename); + await runWithTemporaryFileUpdate(api, snapshot, document.fileName, oldText, async updatedSnapshot => { + const updatedProject = await getUpdatedProject(updatedSnapshot, project, document.fileName); + const updatedSourceFile = await updatedProject?.program.getSourceFile(document.fileName); + if (updatedProject === undefined || updatedSourceFile === undefined) { + result.setCanRename(protocol.RenameKind.no, 'No source file found for document'); + return; + } + const updatedState = await doPrepareNesRename(result, updatedProject, updatedSourceFile, oldPosition, oldName, newName, token); + if (updatedState === PrepareState.prepared && (result.getCanRename() === protocol.RenameKind.maybe || result.getCanRename() === protocol.RenameKind.yes)) { + result.setOnOldState(true); + } + }); +} + +export async function nesRename(api: API, snapshot: Snapshot, project: Project, document: SourceFile, position: number, oldName: string | undefined, newName: string | undefined, lastSymbolRename: protocol.Range | undefined, token: CancellationTokenWithTimer): Promise { + if (oldName === undefined || newName === undefined || lastSymbolRename === undefined) { + return []; + } + + const [oldText, oldPosition] = getOldText(document, position, oldName, newName, lastSymbolRename); + const groups = new Map(); + const seen = new Set(); + await runWithTemporaryFileUpdate(api, snapshot, document.fileName, oldText, async updatedSnapshot => { + const updatedProject = await getUpdatedProject(updatedSnapshot, project, document.fileName); + const updatedSourceFile = await updatedProject?.program.getSourceFile(document.fileName); + if (updatedProject === undefined || updatedSourceFile === undefined) { + return; + } + const renameTarget = getRenameTarget(updatedSourceFile, oldPosition, oldName); + if (renameTarget.node.getText(updatedSourceFile) !== oldName) { + return; + } + const symbols = new Symbols(updatedProject, token); + const referencedSymbols = await updatedProject.checker.getReferencedSymbolsForNode(renameTarget.node, renameTarget.position); + for (const referencedSymbol of referencedSymbols) { + const definition = await referencedSymbol.definition.resolve(updatedProject); + if (definition === undefined || await symbols.isSourceFileFromLibrary(definition.getSourceFile())) { + return; + } + } + for (const referencedSymbol of referencedSymbols) { + for (const reference of referencedSymbol.references) { + token.throwIfCancellationRequested(); + const node = await reference.resolve(updatedProject); + if (node === undefined) { + continue; + } + const sourceFile = node.getSourceFile(); + if (await symbols.isSourceFileFromLibrary(sourceFile)) { + continue; + } + const startPosition = node.getStart(sourceFile); + const endPosition = node.getEnd(); + const key = `${sourceFile.path}:${startPosition}:${endPosition}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + const start = sourceFile.getLineAndCharacterOfPosition(startPosition); + const end = sourceFile.getLineAndCharacterOfPosition(endPosition); + const delta = newName.length - oldName.length; + if ( + sourceFile.fileName === document.fileName && + start.line === lastSymbolRename.start.line && start.character === lastSymbolRename.start.character && + end.line === lastSymbolRename.end.line && end.character === lastSymbolRename.end.character - delta + ) { + continue; + } + let group = groups.get(sourceFile.fileName); + if (group === undefined) { + group = { file: sourceFile.fileName, changes: [] }; + groups.set(sourceFile.fileName, group); + } + group.changes.push({ + range: { + start: { line: start.line, character: start.character }, + end: { line: end.line, character: end.character }, + }, + }); + } + } + }); + return Array.from(groups.values()); +} + +function runWithTemporaryFileUpdate(api: API, baseSnapshot: Snapshot, file: DocumentIdentifier, newText: string, cb: (newSnapshot: Snapshot) => void | Promise): Promise { + interface ApiWithTemporaryFileUpdate { + runWithTemporaryFileUpdate(baseSnapshot: Snapshot, file: DocumentIdentifier, newText: string, cb: (newSnapshot: Snapshot) => void | Promise): Promise; + } + if (typeof (api as unknown as ApiWithTemporaryFileUpdate).runWithTemporaryFileUpdate === 'function') { + return (api as unknown as ApiWithTemporaryFileUpdate).runWithTemporaryFileUpdate(baseSnapshot, file, newText, cb); + } + return Promise.resolve(); +} + +const enum PrepareState { + prepared, + unavailable, + mismatch, +} + +async function doPrepareNesRename(result: PrepareNesRenameResult, project: Project, sourceFile: SourceFile, position: number, oldName: string, newName: string, token: CancellationTokenWithTimer): Promise { + const renameTarget = getRenameTarget(sourceFile, position, oldName); + const tokenText = renameTarget.node.getText(sourceFile); + if (tokenText !== oldName) { + result.setCanRename(protocol.RenameKind.no, `Old name '${oldName}' does not match symbol name '${tokenText}'`); + return PrepareState.mismatch; + } + token.throwIfCancellationRequested(); + if (await project.checker.getSymbolAtLocation(renameTarget.node) === undefined) { + result.setCanRename(protocol.RenameKind.no, 'No symbol found at location'); + return PrepareState.unavailable; + } + result.setCanRename(protocol.RenameKind.maybe, oldName); + await validateNesRename(result, project, renameTarget.node, oldName, newName, token); + return PrepareState.prepared; +} + +function getRenameTarget(sourceFile: SourceFile, position: number, oldName: string): { node: Node; position: number } { + const token = tss.getRelevantTokens(sourceFile, position).token; + if (token.getText(sourceFile) === oldName) { + return { node: token, position }; + } + let current: Node | undefined = token.parent; + while (current !== undefined && !isSourceFile(current)) { + if (isFunctionDeclaration(current) && current.name?.getText(sourceFile) === oldName) { + return { node: current.name, position: current.name.getStart(sourceFile) }; + } + current = current.parent; + } + return { node: token, position }; +} + +async function getUpdatedProject(snapshot: Snapshot, project: Project, fileName: string): Promise { + return snapshot.getProject(project.configFileName) ?? await snapshot.getDefaultProjectForFile(fileName); +} + +function getOldText(sourceFile: SourceFile, position: number, oldName: string, newName: string, lastSymbolRename: protocol.Range): [string, number] { + const startPosition = sourceFile.getPositionOfLineAndCharacter(lastSymbolRename.start.line, lastSymbolRename.start.character); + const endPosition = sourceFile.getPositionOfLineAndCharacter(lastSymbolRename.end.line, lastSymbolRename.end.character); + const oldText = sourceFile.text.substring(0, startPosition) + oldName + sourceFile.text.substring(endPosition); + return [oldText, position < startPosition ? position : position - (newName.length - oldName.length)]; +} diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/baseContextProviders.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/baseContextProviders.ts new file mode 100644 index 00000000000000..ffcebb908ce03a --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/baseContextProviders.ts @@ -0,0 +1,523 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createHash } from 'node:crypto'; + +import { version } from '@typescript/native'; +import { ModuleKind, SignatureKind, SymbolFlags, type Project, type Symbol as NativeSymbol } from '@typescript/native/unstable/async'; +import { + ScriptTarget, + SyntaxKind, + isArrowFunction, + isBlock, + isCallExpression, + isElementAccessExpression, + isFunctionDeclaration, + isFunctionExpression, + isIdentifier, + isImportDeclaration, + isIntersectionTypeNode, + isNamedImports, + isNamespaceImport, + isPropertyAccessExpression, + isTypeLiteralNode, + isTypeReferenceNode, + isUnionTypeNode, + type FunctionLikeDeclaration, + type ImportDeclaration, + type Node, + type SourceFile, + type TypeNode, + type VariableDeclaration, +} from '@typescript/native/unstable/ast'; +import * as protocol from '../../common/serverProtocol'; +import { + AbstractContextRunnable, + CacheScopes, + ComputeCost, + ContextProvider, + SnippetLocation, + type ComputeContextSession, + type ContextResult, + type ContextRunnableCollector, + type ProviderComputeContext, + type RequestContext, + type RunnableResult, + type SymbolData, +} from './contextProvider'; +import tss, { type CancellationTokenWithTimer } from './typescripts'; + +export class CompilerOptionsRunnable extends AbstractContextRunnable { + private readonly sourceFile: SourceFile; + + constructor(session: ComputeContextSession, project: Project, context: RequestContext, sourceFile: SourceFile) { + super(session, project, context, 'CompilerOptionsRunnable', SnippetLocation.Primary, protocol.Priorities.Traits, ComputeCost.Low); + this.sourceFile = sourceFile; + } + + public override getActiveSourceFile(): SourceFile { + return this.sourceFile; + } + + protected override createRunnableResult(result: ContextResult): RunnableResult { + const cacheInfo: protocol.CacheInfo = { emitMode: protocol.EmitMode.ClientBased, scope: { kind: protocol.CacheScopeKind.File } }; + return result.createRunnableResult(this.id, this.priority, protocol.SpeculativeKind.emit, cacheInfo); + } + + protected override async run(result: RunnableResult): Promise { + const compilerOptions = this.getProject().program.getCompilerOptions(); + this.addTrait(result, protocol.TraitKind.Version, 'The TypeScript version used in this project is ', version); + this.addTrait(result, protocol.TraitKind.Module, 'The TypeScript module system used in this project is ', compilerOptions.module === undefined ? undefined : ModuleKind[compilerOptions.module]); + this.addTrait(result, protocol.TraitKind.ModuleResolution, 'The TypeScript module resolution strategy used in this project is ', compilerOptions.moduleResolution === undefined ? undefined : this.moduleResolutionName(compilerOptions.moduleResolution)); + this.addTrait(result, protocol.TraitKind.Target, 'The target version of JavaScript for this project is ', compilerOptions.target === undefined ? undefined : ScriptTarget[compilerOptions.target]); + this.addTrait(result, protocol.TraitKind.Lib, 'Library files that should be included in TypeScript compilation are ', compilerOptions.lib?.toString()); + } + + private addTrait(result: RunnableResult, kind: protocol.TraitKind, name: string, value: string | undefined): void { + if (value === undefined) { + return; + } + const key = protocol.Trait.createContextItemKey(kind); + if (!result.addFromKnownItems(key)) { + result.addTrait(kind, name, value, key); + } + } + + private moduleResolutionName(value: number): string { + switch (value) { + case 1: return 'Classic'; + case 2: return 'Node10'; + case 3: return 'Node16'; + case 99: return 'NodeNext'; + case 100: return 'Bundler'; + default: return 'Unknown'; + } + } +} + +export abstract class FunctionLikeContextRunnable extends AbstractContextRunnable { + protected readonly declaration: T; + protected readonly sourceFile: SourceFile; + + constructor(session: ComputeContextSession, project: Project, context: RequestContext, id: string, declaration: T, priority: number, cost: ComputeCost) { + super(session, project, context, id, SnippetLocation.Primary, priority, cost); + this.declaration = declaration; + this.sourceFile = declaration.getSourceFile(); + } + + public override getActiveSourceFile(): SourceFile { + return this.sourceFile; + } + + protected getCacheScope(): protocol.CacheScope | undefined { + return this.declaration.body === undefined || !isBlock(this.declaration.body) + ? undefined + : this.createCacheScope(this.declaration.body, this.sourceFile); + } +} + +export class SignatureRunnable extends FunctionLikeContextRunnable { + constructor(session: ComputeContextSession, project: Project, context: RequestContext, declaration: FunctionLikeDeclaration, priority: number = protocol.Priorities.Locals) { + super(session, project, context, SignatureRunnable.computeId(session, declaration), declaration, priority, ComputeCost.Low); + } + + protected override createRunnableResult(result: ContextResult): RunnableResult { + const scope = this.getCacheScope(); + const cacheInfo = scope === undefined ? undefined : { emitMode: protocol.EmitMode.ClientBased, scope }; + return result.createRunnableResult(this.id, this.priority, protocol.SpeculativeKind.emit, cacheInfo); + } + + protected override async run(_result: RunnableResult, token: CancellationTokenWithTimer): Promise { + for (const parameter of this.declaration.parameters) { + token.throwIfCancellationRequested(); + if (parameter.type !== undefined) { + await this.processType(parameter.type, token); + } + } + if (this.declaration.type !== undefined) { + token.throwIfCancellationRequested(); + await this.processType(this.declaration.type, token); + } + } + + private async processType(type: TypeNode, token: CancellationTokenWithTimer): Promise { + for (const symbolEmitData of await this.getSymbolsForTypeNode(type)) { + token.throwIfCancellationRequested(); + await this.handleSymbol(symbolEmitData.symbol, symbolEmitData.name); + } + } + + private static computeId(_session: ComputeContextSession, declaration: FunctionLikeDeclaration): string { + const end = declaration.type?.end ?? declaration.parameters.end; + const hash = createHash('md5'); // CodeQL [SM04514] Used only as a compact cache key, not for security. + hash.update(declaration.getSourceFile().fileName); + hash.update(`[${declaration.parameters.pos},${end}]`); + return `SignatureRunnable:${hash.digest('base64')}`; + } +} + +export class TypeOfLocalsRunnable extends AbstractContextRunnable { + private readonly tokenInfo: tss.TokenInfo; + private readonly excludes: Set; + private readonly cacheScope: protocol.CacheScope | undefined; + private runnableResult: RunnableResult | undefined; + + constructor(session: ComputeContextSession, project: Project, context: RequestContext, tokenInfo: tss.TokenInfo, excludes: Set, cacheScope: protocol.CacheScope | undefined, priority: number = protocol.Priorities.Locals) { + super(session, project, context, 'TypeOfLocalsRunnable', SnippetLocation.Primary, priority, ComputeCost.Medium); + this.tokenInfo = tokenInfo; + this.excludes = excludes; + this.cacheScope = cacheScope; + } + + public override getActiveSourceFile(): SourceFile { + return this.tokenInfo.token.getSourceFile(); + } + + protected override createRunnableResult(result: ContextResult): RunnableResult { + const cacheInfo = this.cacheScope === undefined ? undefined : { emitMode: protocol.EmitMode.ClientBasedOnTimeout, scope: this.cacheScope }; + this.runnableResult = result.createRunnableResult(this.id, this.priority, protocol.SpeculativeKind.emit, cacheInfo); + return this.runnableResult; + } + + protected override async run(_result: RunnableResult, token: CancellationTokenWithTimer): Promise { + const anchor = this.tokenInfo.previous ?? this.tokenInfo.token ?? this.tokenInfo.touching; + const symbols = this.symbols; + const checker = symbols.getTypeChecker(); + const sourceFile = anchor.getSourceFile(); + // The AST navigation helpers hand out synthesized token nodes, so the checker can only resolve them via a document position. + const inScope = await symbols.getSymbolsInScope({ document: sourceFile.fileName, position: anchor.getStart(sourceFile) }, SymbolFlags.BlockScopedVariable); + if (inScope.length === 0) { + return; + } + + // When we try to capture locals outside of a callable (e.g. top level in a source file) we capture the declarations as + // scope. If we are inside the body of the callable defines the scope. + const cacheNodes = this.cacheScope === undefined ? new Set() : undefined; + // The symbols are block scope variables. We try to find the type of the variable + // to include it in the context. + for (const symbol of inScope) { + token.throwIfCancellationRequested(); + if (this.excludes.has(symbol)) { + continue; + } + const declaration: VariableDeclaration | undefined = await symbols.getDeclaration(symbol, SyntaxKind.VariableDeclaration); + if (declaration === undefined) { + continue; + } + let symbolsToEmit: SymbolData[] | undefined = undefined; + if (declaration.type !== undefined) { + symbolsToEmit = await this.getSymbolsForTypeNode(declaration.type); + } else { + const type = await checker.getTypeAtLocation(declaration.type ?? declaration); + if (type !== undefined) { + symbolsToEmit = await this.getSymbolsToEmitForType(type); + } + } + if (symbolsToEmit === undefined || symbolsToEmit.length === 0) { + continue; + } + for (const { symbol, name } of symbolsToEmit) { + token.throwIfCancellationRequested(); + await this.handleSymbol(symbol, name); + } + + + if (cacheNodes !== undefined) { + const declarationList = tss.Nodes.getParentOfKind(declaration, SyntaxKind.VariableDeclarationList); + if (declarationList !== undefined) { + cacheNodes.add(declarationList); + } + } + } + if (cacheNodes !== undefined && cacheNodes.size > 0 && this.runnableResult !== undefined) { + this.runnableResult.setCacheInfo({ emitMode:protocol.EmitMode.ClientBasedOnTimeout, scope: CacheScopes.createOutsideCacheScope(cacheNodes, sourceFile) }); + } + } +} + +export class TypesOfNeighborFilesRunnable extends AbstractContextRunnable { + private readonly tokenInfo: tss.TokenInfo; + private static readonly SymbolsToInclude: number = SymbolFlags.Class | SymbolFlags.Interface | SymbolFlags.TypeAlias | SymbolFlags.RegularEnum | SymbolFlags.ConstEnum | SymbolFlags.Function; + + constructor(session: ComputeContextSession, project: Project, context: RequestContext, tokenInfo: tss.TokenInfo, priority: number = protocol.Priorities.NeighborFiles) { + super(session, project, context, 'TypesOfNeighborFilesRunnable', SnippetLocation.Secondary, priority, ComputeCost.Medium); + this.tokenInfo = tokenInfo; + } + + public override getActiveSourceFile(): SourceFile { + return this.tokenInfo.token.getSourceFile(); + } + + protected override createRunnableResult(result: ContextResult): RunnableResult { + return result.createRunnableResult(this.id, this.priority, protocol.SpeculativeKind.emit, { emitMode: protocol.EmitMode.ClientBased, scope: { kind: protocol.CacheScopeKind.NeighborFiles } }); + } + + protected override async run(result: RunnableResult, token: CancellationTokenWithTimer): Promise { + for (const neighborFile of this.context.neighborFiles) { + token.throwIfCancellationRequested(); + if (result.isSecondaryBudgetExhausted()) { + return; + } + const neighborSourceFile = await this.getProject().program.getSourceFile(neighborFile); + if (neighborSourceFile === undefined || await this.skipSourceFile(neighborSourceFile)) { + continue; + } + const sourceFileSymbol = await this.symbols.getLeafSymbolAtLocation(neighborSourceFile); + if (sourceFileSymbol === undefined) { + continue; + } + for (const [name, member] of await sourceFileSymbol.getExports()) { + if ((member.flags & TypesOfNeighborFilesRunnable.SymbolsToInclude) !== 0 && !await this.handleSymbol(member, name, true)) { + return; + } + } + } + } +} + +type ImportBlock = { before: Node | undefined; imports: ImportDeclaration[]; after: Node | undefined }; + +export class ImportsRunnable extends AbstractContextRunnable { + private readonly tokenInfo: tss.TokenInfo; + private readonly excludes: Set; + private cacheInfo: protocol.CacheInfo | undefined; + private runnableResult: RunnableResult | undefined; + + private static readonly CacheNodes = new Set([ + SyntaxKind.FunctionDeclaration, + SyntaxKind.ArrowFunction, + SyntaxKind.FunctionExpression, + SyntaxKind.Constructor, + SyntaxKind.MethodDeclaration, + SyntaxKind.ClassDeclaration, + SyntaxKind.ModuleDeclaration, + ]); + + constructor(session: ComputeContextSession, project: Project, context: RequestContext, tokenInfo: tss.TokenInfo, excludes: Set, priority: number = protocol.Priorities.Imports) { + super(session, project, context, 'ImportsRunnable', SnippetLocation.Secondary, priority, ComputeCost.Medium); + this.tokenInfo = tokenInfo; + this.excludes = excludes; + const scopeNode = this.getCacheScopeNode(); + this.cacheInfo = scopeNode === undefined ? undefined : { emitMode: protocol.EmitMode.ClientBased, scope: this.createCacheScope(scopeNode) }; + } + + public override getActiveSourceFile(): SourceFile { + return this.tokenInfo.token.getSourceFile(); + } + + public override useCachedResult(cached: protocol.CachedContextRunnableResult): boolean { + if (cached.cache?.emitMode === protocol.EmitMode.ClientBased && cached.state === protocol.ContextRunnableState.Finished) { + if (cached.cache.scope.kind === protocol.CacheScopeKind.WithinRange) { + return true; + } + if (cached.cache.scope.kind === protocol.CacheScopeKind.OutsideRange) { + return this.cacheInfo === undefined; + } + } + return super.useCachedResult(cached); + } + + protected override createRunnableResult(result: ContextResult): RunnableResult { + this.runnableResult = result.createRunnableResult(this.id, this.priority, protocol.SpeculativeKind.emit, this.cacheInfo); + return this.runnableResult; + } + + protected override async run(_result: RunnableResult, token: CancellationTokenWithTimer): Promise { + const sourceFile = this.getActiveSourceFile(); + const importBlocks = this.getImportBlocks(sourceFile); + const importedSymbols: { symbol: NativeSymbol; name: string }[] = []; + const outsideRanges: protocol.Range[] = []; + for (const block of importBlocks) { + for (const statement of block.imports) { + token.throwIfCancellationRequested(); + const importClause = statement.importClause; + if (importClause?.name !== undefined) { + await this.addImportedSymbol(importedSymbols, importClause.name); + } + const bindings = importClause?.namedBindings; + if (bindings !== undefined) { + if (isNamespaceImport(bindings)) { + await this.addImportedSymbol(importedSymbols, bindings.name); + } else if (isNamedImports(bindings)) { + for (const element of bindings.elements) { + await this.addImportedSymbol(importedSymbols, element.name); + } + } + } + } + if (this.cacheInfo === undefined && block.imports.length > 0) { + outsideRanges.push({ + start: block.before === undefined ? CacheScopes.createRange(block.imports[0], sourceFile).start : CacheScopes.createRange(block.before, sourceFile).end, + end: block.after === undefined ? CacheScopes.createRange(block.imports.at(-1) ?? block.imports[0], sourceFile).end : CacheScopes.createRange(block.after, sourceFile).start, + }); + } + } + for (const { symbol, name } of importedSymbols) { + if ((symbol.flags & (SymbolFlags.Class | SymbolFlags.Interface | SymbolFlags.TypeAlias | SymbolFlags.RegularEnum | SymbolFlags.ConstEnum | SymbolFlags.Alias | SymbolFlags.ValueModule)) !== 0 && !await this.handleSymbol(symbol, name, true)) { + break; + } + } + if (this.cacheInfo === undefined && outsideRanges.length > 0) { + this.runnableResult?.setCacheInfo({ emitMode: protocol.EmitMode.ClientBased, scope: { kind: protocol.CacheScopeKind.OutsideRange, ranges: outsideRanges } }); + } + } + + private async addImportedSymbol(result: { symbol: NativeSymbol; name: string }[], node: Node): Promise { + const symbol = await this.symbols.getLeafSymbolAtLocation(node); + if (symbol !== undefined && !this.excludes.has(symbol)) { + result.push({ symbol, name: node.getText() }); + } + } + + private getImportBlocks(sourceFile: SourceFile): ImportBlock[] { + if (this.cacheInfo !== undefined) { + return [{ before: undefined, imports: sourceFile.statements.filter(isImportDeclaration), after: undefined }]; + } + const result: ImportBlock[] = []; + let before: Node | undefined; + let imports: ImportDeclaration[] = []; + for (const node of sourceFile.statements) { + if (isImportDeclaration(node)) { + imports.push(node); + } else if (imports.length === 0) { + before = node; + } else { + result.push({ before, imports, after: node }); + before = undefined; + imports = []; + } + } + if (imports.length > 0) { + result.push({ before, imports, after: undefined }); + } + return result; + } + + private getCacheScopeNode(): Node | undefined { + let current: Node | undefined = this.tokenInfo.touching ?? this.tokenInfo.token; + let result: Node | undefined; + while (current !== undefined && current.kind !== SyntaxKind.SourceFile) { + if (ImportsRunnable.CacheNodes.has(current.kind)) { + result = current; + } + current = current.parent; + } + return result; + } +} + +export class TypeOfExpressionRunnable extends AbstractContextRunnable { + private readonly expression: Node; + + constructor(session: ComputeContextSession, project: Project, context: RequestContext, expression: Node, priority: number = protocol.Priorities.Expression) { + super(session, project, context, 'TypeOfExpressionRunnable', SnippetLocation.Primary, priority, ComputeCost.Low); + this.expression = expression; + } + + public override getActiveSourceFile(): SourceFile { + return this.expression.getSourceFile(); + } + + public static create(session: ComputeContextSession, project: Project, context: RequestContext, tokenInfo: tss.TokenInfo, _token: CancellationTokenWithTimer): TypeOfExpressionRunnable | undefined { + const previous = tokenInfo.previous; + if (previous !== undefined && (isIdentifier(previous) || previous.kind === SyntaxKind.DotToken) && isPropertyAccessExpression(previous.parent)) { + const identifier = this.getRightMostIdentifier(previous.parent.expression, 0); + if (identifier !== undefined) { + return new TypeOfExpressionRunnable(session, project, context, identifier); + } + } + return undefined; + } + + protected override createRunnableResult(result: ContextResult): RunnableResult { + return result.createRunnableResult(this.id, this.priority, protocol.SpeculativeKind.ignore); + } + + protected override async run(_result: RunnableResult, token: CancellationTokenWithTimer): Promise { + const expressionSymbol = await this.symbols.getLeafSymbolAtLocation(this.expression); + if (expressionSymbol === undefined) { + return; + } + const checker = this.getProject().checker; + const type = await checker.getTypeOfSymbolAtLocation(expressionSymbol, this.expression); + for (const signature of [ + ...await checker.getSignaturesOfType(type, SignatureKind.Construct), + ...await checker.getSignaturesOfType(type, SignatureKind.Call), + ]) { + token.throwIfCancellationRequested(); + const returnType = await checker.getReturnTypeOfSignature(signature); + if (returnType === undefined) { + continue; + } + for (const symbol of await this.symbols.getTypeSymbols(returnType)) { + await this.handleSymbol(symbol, symbol.name); + } + } + for (const symbol of await this.symbols.getTypeSymbols(type)) { + await this.handleSymbol(symbol, symbol.name); + } + } + + private static getRightMostIdentifier(node: Node, count: number): Node | undefined { + if (count === 32) { + return undefined; + } + if (isIdentifier(node)) { + return node; + } + if (isPropertyAccessExpression(node)) { + return this.getRightMostIdentifier(node.name, count + 1); + } + if (isElementAccessExpression(node)) { + return node.argumentExpression === undefined ? undefined : this.getRightMostIdentifier(node.argumentExpression, count + 1); + } + if (isCallExpression(node)) { + return this.getRightMostIdentifier(node.expression, count + 1); + } + return undefined; + } +} + +export abstract class FunctionLikeContextProvider extends ContextProvider { + protected readonly functionLikeDeclaration: FunctionLikeDeclaration; + protected readonly tokenInfo: tss.TokenInfo; + protected readonly computeContext: ProviderComputeContext; + public override readonly isCallableProvider: boolean = true; + + constructor(declaration: FunctionLikeDeclaration, tokenInfo: tss.TokenInfo, computeContext: ProviderComputeContext) { + super(); + this.functionLikeDeclaration = declaration; + this.tokenInfo = tokenInfo; + this.computeContext = computeContext; + } + + public override async provide(result: ContextRunnableCollector, session: ComputeContextSession, project: Project, context: RequestContext, token: CancellationTokenWithTimer): Promise { + token.throwIfCancellationRequested(); + result.addPrimary(new SignatureRunnable(session, project, context, this.functionLikeDeclaration)); + if (!this.computeContext.isFirstCallableProvider(this)) { + return; + } + const excludes = await this.getTypeExcludes(project, context); + result.addPrimary(new TypeOfLocalsRunnable(session, project, context, this.tokenInfo, excludes, CacheScopes.fromDeclaration(this.functionLikeDeclaration))); + const expression = TypeOfExpressionRunnable.create(session, project, context, this.tokenInfo, token); + if (expression !== undefined) { + result.addPrimary(expression); + } + result.addSecondary(new ImportsRunnable(session, project, context, this.tokenInfo, excludes)); + if (context.neighborFiles.length > 0) { + result.addTertiary(new TypesOfNeighborFilesRunnable(session, project, context, this.tokenInfo)); + } + } + + protected abstract getTypeExcludes(project: Project, context: RequestContext): Promise>; +} + +export function isFunctionContextNode(node: Node): node is FunctionLikeDeclaration { + return isFunctionDeclaration(node) || isFunctionExpression(node) || isArrowFunction(node); +} + +export function isCompositeTypeNode(node: TypeNode): boolean { + return isTypeReferenceNode(node) || isTypeLiteralNode(node) || isUnionTypeNode(node) || isIntersectionTypeNode(node); +} diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/classContextProvider.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/classContextProvider.ts new file mode 100644 index 00000000000000..d0e771b938e5d3 --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/classContextProvider.ts @@ -0,0 +1,220 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { Project, Symbol as NativeSymbol } from '@typescript/native/unstable/async'; +import { isClassDeclaration, SyntaxKind, type ClassDeclaration, type Node, type SourceFile, type ExpressionWithTypeArguments } from '@typescript/native/unstable/ast'; +import { CodeSnippetBuilder } from './code'; +import { AbstractContextRunnable, ComputeCost, ContextProvider, Search, SnippetLocation, type ComputeContextSession, type ContextResult, type ContextRunnableCollector, type RequestContext, type RunnableResult } from './contextProvider'; +import * as protocol from '../../common/serverProtocol'; +import tss, { type CancellationTokenWithTimer, Symbols } from './typescripts'; + +export type TypeInfo = { + symbol: NativeSymbol; + type: ExpressionWithTypeArguments; + abstractMembers: number; +}; + +export type SimilarClassDeclaration = { + declaration: ClassDeclaration; + matchesAbstractMembers: number; +}; + +export class ClassBlueprintSearch extends Search { + private readonly classDeclaration: ClassDeclaration; + + public abstractMembers: number = 0; + public extends: TypeInfo | undefined; + public implements: readonly TypeInfo[] | undefined; + + private initialized: boolean = false; + + constructor(project: Project, symbols: Symbols, classDeclaration: ClassDeclaration) { + super(project, symbols); + this.classDeclaration = classDeclaration; + } + + public override with(project: Project, symbols: Symbols): ClassBlueprintSearch { + return project === this.project ? this : new ClassBlueprintSearch(project, symbols, this.classDeclaration); + } + + public *all(): IterableIterator { + if (this.extends !== undefined) { + yield this.extends; + } + if (this.implements !== undefined) { + yield* this.implements; + } + } + + public override async score(_project: Project, _context: RequestContext): Promise { + await this.initialize(); + return this.extends === undefined && this.implements === undefined ? -1 : 1; + } + + public override async run(_context: RequestContext, token: CancellationTokenWithTimer): Promise { + await this.initialize(); + let result: SimilarClassDeclaration | undefined; + const matches = new Map(); + for (const typeInfo of this.all()) { + token.throwIfCancellationRequested(); + // const node = isExpressionWithTypeArguments(typeInfo.type) ? typeInfo.type.expression : typeInfo.type.typeName; + const node = typeInfo.type.expression; + for (const entry of await this.project.checker.getReferencedSymbolsForNode(node, node.getStart())) { + for (const reference of entry.references) { + const node = await reference.resolve(this.project); + const candidate = node === undefined ? undefined : this.getContainingClass(node); + if (candidate === undefined || this.isSame(candidate)) { + continue; + } + matches.set(candidate, (matches.get(candidate) ?? 0) + typeInfo.abstractMembers); + } + } + } + for (const [declaration, matchesAbstractMembers] of matches) { + if (result === undefined || matchesAbstractMembers > result.matchesAbstractMembers) { + result = { declaration, matchesAbstractMembers }; + } + } + return result; + } + + private async initialize(): Promise { + if (this.initialized) { + return; + } + this.initialized = true; + const implemented: TypeInfo[] = []; + for (const heritageClause of this.classDeclaration.heritageClauses ?? []) { + for (const type of heritageClause.types) { + // const symbol = await (isExpressionWithTypeArguments(type) ? this.symbols.getLeafSymbolAtLocation(type.expression) : this.symbols.getLeafSymbolAtLocation(type.typeName)); + const symbol = await this.symbols.getLeafSymbolAtLocation(type.expression); + if (symbol === undefined) { + continue; + } + const abstractMembers = (await symbol.getMembers()).size; + this.abstractMembers += abstractMembers; + const info = { symbol, type, abstractMembers }; + if (heritageClause.token === SyntaxKind.ExtendsKeyword) { + this.extends = info; + } else { + implemented.push(info); + } + } + } + this.implements = implemented.length === 0 ? undefined : implemented.sort((first, second) => second.abstractMembers - first.abstractMembers); + } + + private isSame(other: ClassDeclaration): boolean { + return this.classDeclaration === other || (this.classDeclaration.getSourceFile().path === other.getSourceFile().path && this.classDeclaration.pos === other.pos); + } + + private getContainingClass(node: Node): ClassDeclaration | undefined { + let current: Node | undefined = node; + while (current !== undefined) { + if (isClassDeclaration(current)) { + return current; + } + current = current.parent; + } + return undefined; + } +} + +export class SuperClassRunnable extends AbstractContextRunnable { + private readonly classDeclaration: ClassDeclaration; + + constructor(session: ComputeContextSession, project: Project, context: RequestContext, classDeclaration: ClassDeclaration, priority: number = protocol.Priorities.Inherited) { + super(session, project, context, 'SuperClassRunnable', SnippetLocation.Primary, priority, ComputeCost.Medium); + this.classDeclaration = classDeclaration; + } + + public override getActiveSourceFile(): SourceFile { + return this.classDeclaration.getSourceFile(); + } + + protected override createRunnableResult(result: ContextResult): RunnableResult { + return result.createRunnableResult(this.id, this.priority, protocol.SpeculativeKind.emit, { emitMode: protocol.EmitMode.ClientBased, scope: this.createCacheScope(this.classDeclaration.members, this.classDeclaration.getSourceFile()) }); + } + + protected override async run(_result: RunnableResult): Promise { + const clazz = await this.symbols.getLeafSymbolAtLocation(this.classDeclaration.name ?? this.classDeclaration); + if (!Symbols.isClass(clazz)) { + return; + } + const direct = await this.symbols.getDirectSuperSymbols(clazz); + if (direct?.extends !== undefined) { + await this.handleSymbol(direct.extends.symbol, direct.extends.name); + } + for (const implemented of direct?.implements ?? []) { + await this.handleSymbol(implemented.symbol, implemented.name); + } + } +} + +class SimilarClassRunnable extends AbstractContextRunnable { + private readonly classDeclaration: ClassDeclaration; + + constructor(session: ComputeContextSession, project: Project, context: RequestContext, classDeclaration: ClassDeclaration, priority: number = protocol.Priorities.Blueprints) { + super(session, project, context, 'SimilarClassRunnable', SnippetLocation.Primary, priority, ComputeCost.High); + this.classDeclaration = classDeclaration; + } + + public override getActiveSourceFile(): SourceFile { + return this.classDeclaration.getSourceFile(); + } + + protected override createRunnableResult(result: ContextResult): RunnableResult { + return result.createRunnableResult(this.id, this.priority, protocol.SpeculativeKind.emit); + } + + protected override async run(result: RunnableResult, token: CancellationTokenWithTimer): Promise { + const search = new ClassBlueprintSearch(this.getProject(), this.symbols, this.classDeclaration); + if (await search.score(this.getProject(), this.context) <= 0) { + return; + } + const [project, similarClass] = await this.session.run(search, this.context, token); + if (project === undefined || similarClass === undefined) { + return; + } + const builder = new CodeSnippetBuilder(this.context, this.context.getSymbols(project), this.getActiveSourceFile()); + await builder.addDeclaration(similarClass.declaration); + result.addSnippet(builder, this.location, undefined); + } +} + +export class ClassContextProvider extends ContextProvider { + public static create(declaration: ClassDeclaration, tokenInfo: tss.TokenInfo): ContextProvider { + return declaration.members.length === 0 ? new WholeClassContextProvider(declaration, tokenInfo) : new ClassContextProvider(declaration, tokenInfo); + } + + private readonly classDeclaration: ClassDeclaration; + + constructor(classDeclaration: ClassDeclaration, _tokenInfo: tss.TokenInfo) { + super(); + this.classDeclaration = classDeclaration; + } + + public override async provide(result: ContextRunnableCollector, session: ComputeContextSession, project: Project, context: RequestContext, token: CancellationTokenWithTimer): Promise { + token.throwIfCancellationRequested(); + result.addPrimary(new SuperClassRunnable(session, project, context, this.classDeclaration)); + } +} + +export class WholeClassContextProvider extends ContextProvider { + private readonly classDeclaration: ClassDeclaration; + + constructor(classDeclaration: ClassDeclaration, _tokenInfo: tss.TokenInfo) { + super(); + this.classDeclaration = classDeclaration; + } + + public override async provide(result: ContextRunnableCollector, session: ComputeContextSession, project: Project, context: RequestContext, token: CancellationTokenWithTimer): Promise { + token.throwIfCancellationRequested(); + result.addPrimary(new SuperClassRunnable(session, project, context, this.classDeclaration)); + if (session.enableBlueprintSearch()) { + result.addPrimary(new SimilarClassRunnable(session, project, context, this.classDeclaration)); + } + } +} diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/code.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/code.ts new file mode 100644 index 00000000000000..b1a058cc24fa51 --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/code.ts @@ -0,0 +1,663 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { SymbolFlags, type Project, type Symbol as NativeSymbol } from '@typescript/native/unstable/async'; +import { + ModifierFlags, + getLeadingCommentRanges, + isCallSignatureDeclaration, + isClassDeclaration, + isConstructorDeclaration, + isEnumDeclaration, + isEnumMember, + isFunctionDeclaration, + isGetAccessorDeclaration, + isInterfaceDeclaration, + isMethodDeclaration, + isMethodSignatureDeclaration, + isPropertyDeclaration, + isPropertySignatureDeclaration, + isSetAccessorDeclaration, + isTypeAliasDeclaration, + SyntaxKind, + type CallSignatureDeclaration, + type ConstructorDeclaration, + type FunctionDeclaration, + type GetAccessorDeclaration, + type MethodDeclaration, + type MethodSignatureDeclaration, + type ModifierLike, + type Node, + type NodeArray, + type PropertyDeclaration, + type PropertySignatureDeclaration, + type SetAccessorDeclaration, + type SourceFile, + type TypeParameterDeclaration, +} from '@typescript/native/unstable/ast'; +import * as protocol from '../../common/serverProtocol'; +import type { RequestContext } from './contextProvider'; +import { ProgramContext, type SnippetProvider } from './types'; +import { Symbols } from './typescripts'; + +namespace Nodes { + export function getLines(node: Node, includeDocumentation: boolean, sourceFile: SourceFile = node.getSourceFile()): string[] { + const textStartPosition = node.getStart(sourceFile, includeDocumentation); + const startRange = sourceFile.getLineAndCharacterOfPosition(textStartPosition); + const lines = sourceFile.text.substring(textStartPosition, node.getEnd()).split(/\r?\n/g); + if (startRange.character > 0) { + const lineStartPosition = sourceFile.getPositionOfLineAndCharacter(startRange.line, 0); + const indent = sourceFile.text.substring(lineStartPosition, textStartPosition); + stripIndent(lines, indent); + } + trimLines(lines); + return lines; + } + + export function getDocumentation(node: Node): string[] | undefined { + const fullText = node.getFullText(); + const range = getLeadingCommentRanges(fullText, 0)?.at(-1); + if (range === undefined) { + return undefined; + } + const lines = fullText.substring(range.pos, range.end).trim().split(/\r?\n/); + trimLines(lines); + if (lines.length > 1) { + const match = lines[1].match(/^\s+/); + if (match !== null) { + stripIndent(lines, match[0], 0); + } + } + return lines; + } + + function stripIndent(lines: string[], indent: string, start: number = 1): void { + if (lines.slice(start).every(line => line.startsWith(indent))) { + for (let index = start; index < lines.length; index++) { + lines[index] = lines[index].substring(indent.length); + } + } + } + + function trimLines(lines: string[]): void { + while (lines.length > 0 && lines[0].trim().length === 0) { + lines.shift(); + } + while (lines.length > 0 && lines.at(-1)?.trim().length === 0) { + lines.pop(); + } + } +} + +abstract class AbstractEmitter { + protected readonly context: RequestContext; + + private readonly lines: string[] = []; + private indent: number = 0; + + public readonly source: string; + protected readonly additionalSources: Set = new Set(); + + constructor(context: RequestContext, source: SourceFile) { + this.context = context; + this.source = source.fileName; + } + + public abstract readonly key: string | undefined; + + public async initialize(): Promise { + } + + public abstract emit(currentSourceFile: SourceFile): Promise; + + protected async makeKey(symbols: NativeSymbol | readonly NativeSymbol[]): Promise { + const values = Array.isArray(symbols) ? symbols : [symbols]; + const keys: string[] = []; + for (const symbol of values) { + const key = await this.context.getSymbols(this.context.session.project).createKey(symbol); + if (key === undefined) { + return undefined; + } + keys.push(key); + } + return keys.length === 0 ? undefined : keys.join(';'); + } + + public getLines(): string[] { + return this.lines; + } + + public getAdditionalSources(): Set { + this.additionalSources.delete(this.source); + return this.additionalSources; + } + + protected increaseIndent(): void { + this.indent++; + } + + protected decreaseIndent(): void { + this.indent--; + } + + protected addLine(line: string): void { + this.lines.push(this.indent === 0 ? line : `${'\t'.repeat(this.indent)}${line}`); + } + + protected addLines(lines: readonly string[]): void { + for (const line of lines) { + this.addLine(line); + } + } + + protected addConstructorDeclaration(declaration: ConstructorDeclaration): void { + this.addDocumentation(declaration); + const modifiers = this.getModifiers(declaration.modifiers); + const parameters = declaration.parameters.map(parameter => parameter.getText()).join(', '); + this.addLine(`${modifiers}constructor(${parameters});`); + } + + protected addPropertyDeclaration(declaration: PropertyDeclaration | PropertySignatureDeclaration): void { + this.addLines(Nodes.getLines(declaration, this.context.includeDocumentation)); + } + + protected addMethodDeclaration(declaration: MethodDeclaration | MethodSignatureDeclaration): void { + this.addDocumentation(declaration); + const modifiers = this.getModifiers(declaration.modifiers); + const typeParameters = this.getTypeParameters(declaration.typeParameters); + const parameters = declaration.parameters.map(parameter => parameter.getText()).join(', '); + const returnType = declaration.type === undefined ? '' : `: ${declaration.type.getText()}`; + this.addLine(`${modifiers}${declaration.name.getText()}${typeParameters}(${parameters})${returnType};`); + } + + protected addCallSignatureDeclaration(declaration: CallSignatureDeclaration): void { + this.addDocumentation(declaration); + const typeParameters = this.getTypeParameters(declaration.typeParameters); + const parameters = declaration.parameters.map(parameter => parameter.getText()).join(', '); + const returnType = declaration.type === undefined ? '' : `: ${declaration.type.getText()}`; + this.addLine(`${typeParameters}(${parameters})${returnType};`); + } + + protected addGetAccessorDeclaration(declaration: GetAccessorDeclaration): void { + this.addAccessorDeclaration(declaration, 'get'); + } + + protected addSetAccessorDeclaration(declaration: SetAccessorDeclaration): void { + this.addAccessorDeclaration(declaration, 'set'); + } + + private addAccessorDeclaration(declaration: GetAccessorDeclaration | SetAccessorDeclaration, prefix: 'get' | 'set'): void { + this.addDocumentation(declaration); + const modifiers = this.getModifiers(declaration.modifiers); + const parameters = declaration.parameters.map(parameter => parameter.getText()).join(', '); + const returnType = declaration.type === undefined ? '' : `: ${declaration.type.getText()}`; + this.addLine(`${modifiers}${prefix} ${declaration.name.getText()}(${parameters})${returnType};`); + } + + protected addFunctionDeclaration(declaration: FunctionDeclaration, name?: string, ensureModifier?: string): void { + name ??= declaration.name?.getText() ?? ''; + this.addDocumentation(declaration); + const modifiers = this.getModifiers(declaration.modifiers, ensureModifier, true); + const typeParameters = this.getTypeParameters(declaration.typeParameters); + const parameters = declaration.parameters.map(parameter => parameter.getText()).join(', '); + const returnType = declaration.type === undefined ? '' : `: ${declaration.type.getText()}`; + this.addLine(`${modifiers}function ${name}${typeParameters}(${parameters})${returnType};`); + } + + protected addDocumentation(declaration: Node): void { + if (!this.context.includeDocumentation) { + return; + } + const documentation = Nodes.getDocumentation(declaration); + if (documentation !== undefined) { + this.addLines(documentation); + } + } + + protected getModifiers(modifiers: NodeArray | undefined, prefix?: string, skipFunctionModifiers: boolean = false): string { + const result: string[] = []; + if (prefix !== undefined) { + result.push(prefix); + } + for (const modifier of modifiers ?? []) { + if (skipFunctionModifiers && (modifier.kind === SyntaxKind.AsyncKeyword || modifier.kind === SyntaxKind.DeclareKeyword || modifier.kind === SyntaxKind.ExportKeyword)) { + continue; + } + result.push(modifier.getText()); + } + return result.length === 0 ? '' : `${result.join(' ')} `; + } + + protected getTypeParameters(typeParameters: NodeArray | undefined): string { + return typeParameters === undefined ? '' : `<${typeParameters.map(parameter => parameter.getText()).join(', ')}>`; + } +} + +abstract class TypeEmitter extends AbstractEmitter { + protected readonly symbols: Symbols; + protected readonly type: NativeSymbol; + protected readonly name: string; + + private readonly seen: Set = new Set(); + + constructor(context: RequestContext, symbols: Symbols, source: SourceFile, type: NativeSymbol, name: string) { + super(context, source); + this.symbols = symbols; + this.type = type; + this.name = name; + } + + protected async processMembers(members: ReadonlyMap, includePrivates: boolean = true): Promise { + for (const [name, member] of members) { + if (!this.seen.has(name)) { + this.seen.add(name); + await this.processMember(member, includePrivates); + } + } + } + + protected async processMember(member: NativeSymbol, includePrivates: boolean): Promise { + for (const declaration of await this.symbols.getDeclarations(member)) { + if (!includePrivates && this.hasModifier(declaration, ModifierFlags.Private)) { + continue; + } + if (isPropertyDeclaration(declaration) || isPropertySignatureDeclaration(declaration)) { + this.addPropertyDeclaration(declaration); + this.additionalSources.add(declaration.getSourceFile().fileName); + break; + } else if (isMethodDeclaration(declaration) || isMethodSignatureDeclaration(declaration)) { + this.addMethodDeclaration(declaration); + } else if (isGetAccessorDeclaration(declaration)) { + this.addGetAccessorDeclaration(declaration); + } else if (isSetAccessorDeclaration(declaration)) { + this.addSetAccessorDeclaration(declaration); + } else if (isCallSignatureDeclaration(declaration)) { + this.addCallSignatureDeclaration(declaration); + } else if (isConstructorDeclaration(declaration)) { + this.addConstructorDeclaration(declaration); + } else { + continue; + } + this.additionalSources.add(declaration.getSourceFile().fileName); + } + } + + protected async getTypeParametersFromSymbol(): Promise { + const declaration = (await this.symbols.getDeclarations(this.type))[0]; + if (declaration !== undefined && (isClassDeclaration(declaration) || isInterfaceDeclaration(declaration) || isTypeAliasDeclaration(declaration))) { + return this.getTypeParameters(declaration.typeParameters); + } + return ''; + } + + private hasModifier(node: Node, modifier: ModifierFlags): boolean { + return 'modifierFlags' in node && typeof node.modifierFlags === 'number' && (node.modifierFlags & modifier) !== 0; + } +} + +class ClassEmitter extends TypeEmitter { + private readonly includeSuperClasses: boolean; + private readonly includePrivates: boolean; + private superClasses: readonly NativeSymbol[] | undefined; + + public key: string | undefined; + + constructor(context: RequestContext, symbols: Symbols, source: SourceFile, type: NativeSymbol, name: string, includeSuperClasses: boolean, includePrivates: boolean) { + super(context, symbols, source, type, name); + this.includeSuperClasses = includeSuperClasses; + this.includePrivates = includePrivates; + } + + public override async initialize(): Promise { + if (this.includeSuperClasses) { + this.superClasses = (await this.symbols.getAllSuperSymbols(this.type)).filter(candidate => (candidate.flags & SymbolFlags.Class) !== 0); + this.key = await this.makeKey([this.type, ...this.superClasses]); + } else { + this.key = await this.makeKey(this.type); + } + } + + public async emit(): Promise { + this.addLine(`declare class ${this.name}${await this.getTypeParametersFromSymbol()} {`); + this.increaseIndent(); + await this.processMembers(await this.type.getMembers(), this.includePrivates); + if (this.superClasses !== undefined) { + for (let index = this.superClasses.length - 1; index >= 0; index--) { + await this.processMembers(await this.superClasses[index].getMembers(), false); + } + } + this.decreaseIndent(); + this.addLine('}'); + } +} + +class InterfaceEmitter extends TypeEmitter { + private superTypes: readonly NativeSymbol[] = []; + + public key: string | undefined; + + public override async initialize(): Promise { + this.superTypes = (await this.symbols.getAllSuperSymbols(this.type)).filter(candidate => (candidate.flags & SymbolFlags.Interface) !== 0); + this.key = await this.makeKey([this.type, ...this.superTypes]); + } + + public async emit(): Promise { + this.addLine(`interface ${this.name}${await this.getTypeParametersFromSymbol()} {`); + this.increaseIndent(); + await this.processMembers(await this.type.getMembers()); + for (let index = this.superTypes.length - 1; index >= 0; index--) { + await this.processMembers(await this.superTypes[index].getMembers()); + } + this.decreaseIndent(); + this.addLine('}'); + } +} + +class EnumEmitter extends AbstractEmitter { + private readonly type: NativeSymbol; + private readonly name: string; + private readonly declaration: Node | undefined; + + public key: string | undefined; + + constructor(context: RequestContext, source: SourceFile, type: NativeSymbol, name: string, declaration: Node | undefined) { + super(context, source); + this.type = type; + this.name = name; + this.declaration = declaration; + } + + public override async initialize(): Promise { + this.key = await this.makeKey(this.type); + } + + public async emit(): Promise { + const prefix = (this.type.flags & SymbolFlags.ConstEnum) !== 0 ? 'const ' : ''; + this.addLine(`${prefix}enum ${this.name} {`); + this.increaseIndent(); + if (this.declaration !== undefined && isEnumDeclaration(this.declaration)) { + for (let index = 0; index < this.declaration.members.length; index++) { + const member = this.declaration.members[index]; + if (!isEnumMember(member)) { + continue; + } + const lines = Nodes.getLines(member, this.context.includeDocumentation, this.declaration.getSourceFile()); + if (index < this.declaration.members.length - 1 && lines.length > 0) { + lines[lines.length - 1] += ','; + } + this.addLines(lines); + } + } + this.decreaseIndent(); + this.addLine('}'); + } +} + +class TypeLiteralEmitter extends TypeEmitter { + public key: string | undefined; + + public override async initialize(): Promise { + this.key = await this.makeKey(this.type); + } + + public async emit(): Promise { + this.addLine(`type ${this.name} = {`); + this.increaseIndent(); + await this.processMembers(await this.type.getMembers()); + this.decreaseIndent(); + this.addLine('}'); + } +} + +class FunctionEmitter extends AbstractEmitter { + private readonly symbols: Symbols; + private readonly func: NativeSymbol; + private readonly name: string; + + public readonly key: string | undefined = undefined; + + constructor(context: RequestContext, symbols: Symbols, source: SourceFile, func: NativeSymbol, name?: string) { + super(context, source); + this.symbols = symbols; + this.func = func; + this.name = name ?? func.name; + } + + public async emit(currentSourceFile: SourceFile): Promise { + for (const declaration of await this.symbols.getDeclarations(this.func)) { + if (isFunctionDeclaration(declaration) && declaration.getSourceFile().path !== currentSourceFile.path) { + this.addFunctionDeclaration(declaration, this.name, 'declare'); + this.additionalSources.add(declaration.getSourceFile().fileName); + } + } + } +} + +class ModuleEmitter extends AbstractEmitter { + private readonly symbols: Symbols; + private readonly module: NativeSymbol; + private readonly name: string; + + public readonly key: string | undefined = undefined; + + constructor(context: RequestContext, symbols: Symbols, source: SourceFile, module: NativeSymbol, name?: string) { + super(context, source); + this.symbols = symbols; + this.module = module; + this.name = name ?? module.name; + } + + public async emit(currentSourceFile: SourceFile): Promise { + this.addLine(`declare namespace ${this.name} {`); + this.increaseIndent(); + await this.addExports(await this.module.getExports(), currentSourceFile); + this.decreaseIndent(); + this.addLine('}'); + } + + private async addExports(members: ReadonlyMap, currentSourceFile: SourceFile): Promise { + for (const member of members.values()) { + if ((member.flags & SymbolFlags.Function) === 0) { + continue; + } + for (const declaration of await this.symbols.getDeclarations(member)) { + if (isFunctionDeclaration(declaration) && declaration.getSourceFile().path !== currentSourceFile.path) { + this.addFunctionDeclaration(declaration); + this.additionalSources.add(declaration.getSourceFile().fileName); + } + } + } + } +} + +export class CodeSnippetBuilder extends ProgramContext implements SnippetProvider { + private readonly context: RequestContext; + private readonly symbols: Symbols; + private readonly currentSourceFile: SourceFile; + private readonly lines: string[] = []; + private readonly additionalSources: Set = new Set(); + private source: string | undefined; + private indent: number = 0; + + constructor(context: RequestContext, symbols: Symbols, currentSourceFile: SourceFile) { + super(); + this.context = context; + this.symbols = symbols; + this.currentSourceFile = currentSourceFile; + } + + public isEmpty(): boolean { + return this.lines.length === 0 || this.source === undefined; + } + + public snippet(key: string | undefined): protocol.CodeSnippet { + if (this.source === undefined) { + throw new Error('No source'); + } + this.additionalSources.delete(this.source); + return protocol.CodeSnippet.create(key, this.source, this.additionalSources.size === 0 ? undefined : [...this.additionalSources], this.lines.join('\n')); + } + + public async addDeclaration(declaration: Node): Promise { + const sourceFile = declaration.getSourceFile(); + if (!await this.canUseSourceFile(sourceFile)) { + return; + } + this.addLines(Nodes.getLines(declaration, this.context.includeDocumentation, sourceFile)); + this.addSource(sourceFile.fileName); + } + + public addLines(lines: readonly string[]): void { + this.lines.push(...(this.indent === 0 ? lines : lines.map(line => `${'\t'.repeat(this.indent)}${line}`))); + } + + public async addClassSymbol(clazz: NativeSymbol, name: string, includeSuperClasses: boolean = true, includePrivates: boolean = false): Promise { + if ((clazz.flags & SymbolFlags.Class) === 0) { + return; + } + const info = await this.symbols.getSymbolInfo(clazz, this.currentSourceFile); + if (info !== undefined) { + await this.addEmitter(new ClassEmitter(this.context, this.symbols, info.primary, clazz, name, includeSuperClasses, includePrivates)); + } + } + + public async addTypeLiteralSymbol(type: NativeSymbol, name: string): Promise { + if ((type.flags & SymbolFlags.TypeLiteral) === 0) { + return; + } + const info = await this.symbols.getSymbolInfo(type, this.currentSourceFile); + if (info !== undefined) { + await this.addEmitter(new TypeLiteralEmitter(this.context, this.symbols, info.primary, type, name)); + } + } + + public async addInterfaceSymbol(iface: NativeSymbol, name: string): Promise { + if ((iface.flags & SymbolFlags.Interface) === 0) { + return; + } + const info = await this.symbols.getSymbolInfo(iface, this.currentSourceFile); + if (info !== undefined) { + await this.addEmitter(new InterfaceEmitter(this.context, this.symbols, info.primary, iface, name)); + } + } + + public async addTypeAliasSymbol(_symbol: NativeSymbol, _name: string): Promise { + } + + public async addEnumSymbol(enm: NativeSymbol, name: string): Promise { + if ((enm.flags & (SymbolFlags.RegularEnum | SymbolFlags.ConstEnum)) === 0) { + return; + } + const info = await this.symbols.getSymbolInfo(enm, this.currentSourceFile); + if (info !== undefined) { + await this.addEmitter(new EnumEmitter(this.context, info.primary, enm, name, info.declarations.find(isEnumDeclaration))); + } + } + + public async addFunctionSymbol(func: NativeSymbol, name?: string): Promise { + if ((func.flags & SymbolFlags.Function) === 0) { + return; + } + const info = await this.symbols.getSymbolInfo(func, this.currentSourceFile); + if (info !== undefined) { + await this.addEmitter(new FunctionEmitter(this.context, this.symbols, info.primary, func, name)); + } + } + + public async addModuleSymbol(module: NativeSymbol, name?: string): Promise { + if ((module.flags & SymbolFlags.ValueModule) === 0) { + return; + } + const info = await this.symbols.getSymbolInfo(module, this.currentSourceFile); + if (info !== undefined) { + await this.addEmitter(new ModuleEmitter(this.context, this.symbols, info.primary, module, name)); + } + } + + public async addTypeSymbol(type: NativeSymbol, name?: string): Promise { + if (name === undefined && this.isInternal(type)) { + return; + } + const symbolName = name ?? type.name; + if ((type.flags & SymbolFlags.Class) !== 0) { + await this.addClassSymbol(type, symbolName); + } else if ((type.flags & SymbolFlags.Interface) !== 0) { + await this.addInterfaceSymbol(type, symbolName); + } else if ((type.flags & SymbolFlags.TypeAlias) !== 0) { + await this.addTypeAliasSymbol(type, symbolName); + } else if ((type.flags & (SymbolFlags.RegularEnum | SymbolFlags.ConstEnum)) !== 0) { + await this.addEnumSymbol(type, symbolName); + } else if ((type.flags & SymbolFlags.Function) !== 0) { + await this.addFunctionSymbol(type, symbolName); + } else if ((type.flags & SymbolFlags.ValueModule) !== 0) { + await this.addModuleSymbol(type, symbolName); + } else if ((type.flags & SymbolFlags.TypeLiteral) !== 0) { + await this.addTypeLiteralSymbol(type, symbolName); + } + } + + protected override getProject(): Project { + return this.symbols.getProject(); + } + + protected override getSymbols(): Symbols { + return this.symbols; + } + + private async addEmitter(emitter: AbstractEmitter): Promise { + await emitter.initialize(); + let lines: string[] | undefined; + let source: string | undefined; + let additionalSources: Set | undefined; + if (emitter.key !== undefined) { + const cached = this.context.session.getCachedCode(emitter.key); + if (cached !== undefined) { + lines = cached.value; + source = cached.uri; + additionalSources = cached.additionalUris; + } + } + if (lines === undefined || source === undefined) { + await emitter.emit(this.currentSourceFile); + lines = emitter.getLines(); + source = emitter.source; + additionalSources = emitter.getAdditionalSources(); + if (emitter.key !== undefined) { + this.context.session.cacheCode(emitter.key, { value: lines, uri: source, additionalUris: additionalSources }); + } + } + this.addLines(lines); + this.addSource(source); + this.addAdditionalSource(additionalSources); + } + + private async canUseSourceFile(sourceFile: SourceFile): Promise { + if (sourceFile.path === this.currentSourceFile.path) { + return false; + } + const metadata = await this.symbols.getProject().program.getSourceFileMetadataByPath(sourceFile.path); + return !metadata?.isDefaultLibrary && !metadata?.isFromExternalLibrary; + } + + private isInternal(symbol: NativeSymbol): boolean { + return symbol.name === '__type' || symbol.name === '__class' || symbol.name === '__object'; + } + + private addSource(source: string): void { + if (this.source === undefined) { + this.source = source; + } else if (this.source !== source) { + this.additionalSources.add(source); + } + } + + private addAdditionalSource(sources: Set | undefined): void { + if (sources !== undefined) { + for (const source of sources) { + this.additionalSources.add(source); + } + } + } +} diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/contextProvider.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/contextProvider.ts new file mode 100644 index 00000000000000..4c3e04a4b95dfa --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/contextProvider.ts @@ -0,0 +1,752 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { Project, Symbol as NativeSymbol, Type } from '@typescript/native/unstable/async'; +import { + isIntersectionTypeNode, + isTypeAliasDeclaration, + isTypeLiteralNode, + isTypeReferenceNode, + isUnionTypeNode, + type FunctionLikeDeclaration, + type Node, + type NodeArray, + type SourceFile, + type TypeAliasDeclaration, + type TypeNode, +} from '@typescript/native/unstable/ast'; +import { CodeSnippetBuilder } from './code'; +import * as protocol from '../../common/serverProtocol'; +import { type CodeCacheItem, type EmitterContext, ProgramContext, RecoverableError, type SnippetProvider } from './types'; +import tss, { type CancellationTokenWithTimer, Symbols, Types } from './typescripts'; + +export class RequestContext { + private readonly symbols: Map = new Map(); + private readonly clientSideContextItems: Map = new Map(); + + public readonly neighborFiles: readonly string[]; + public readonly clientSideRunnableResults: Map; + public readonly session: ComputeContextSession; + public readonly includeDocumentation: boolean; + + constructor(session: ComputeContextSession, neighborFiles: readonly string[], clientSideRunnableResults: Map, includeDocumentation: boolean) { + this.session = session; + this.neighborFiles = neighborFiles; + this.clientSideRunnableResults = clientSideRunnableResults; + this.includeDocumentation = includeDocumentation; + for (const runnableResult of clientSideRunnableResults.values()) { + for (const item of runnableResult.items) { + this.clientSideContextItems.set(item.key, item); + } + } + } + + public getSymbols(project: Project): Symbols { + let result = this.symbols.get(project); + if (result === undefined) { + result = new Symbols(project, this.session.token); + this.symbols.set(project, result); + } + return result; + } + + public async getPreferredNeighborFiles(project: Project): Promise { + const result: SourceFile[] = []; + for (const file of this.neighborFiles) { + const sourceFile = await project.program.getSourceFile(file); + if (sourceFile !== undefined) { + result.push(sourceFile); + } + } + return result; + } + + public createContextItemReferenceIfManaged(key: protocol.ContextItemKey): protocol.ContextItemReference | undefined { + const cachedItem = this.clientSideContextItems.get(key); + return cachedItem === undefined ? undefined : protocol.ContextItemReference.create(cachedItem.key); + } + + public clientHasContextItem(key: protocol.ContextItemKey): boolean { + return this.clientSideContextItems.has(key); + } +} + +export abstract class Search extends ProgramContext { + protected readonly project: Project; + protected readonly symbols: Symbols; + + constructor(project: Project, symbols: Symbols) { + super(); + if (project !== symbols.getProject()) { + throw new Error('Project and symbols project must match'); + } + this.project = project; + this.symbols = symbols; + } + + public getSymbols(): Symbols { + return this.symbols; + } + + protected getProject(): Project { + return this.project; + } + + public abstract with(project: Project, symbols: Symbols): Search; + public abstract score(project: Project, context: RequestContext): Promise; + public abstract run(context: RequestContext, token: CancellationTokenWithTimer): Promise; +} + +export class ComputeContextSession implements EmitterContext { + public readonly project: Project; + public readonly token: CancellationTokenWithTimer; + + private readonly codeCache: Map = new Map(); + + constructor(project: Project, token: CancellationTokenWithTimer) { + this.project = project; + this.token = token; + } + + public async run(search: Search, context: RequestContext, token: CancellationTokenWithTimer): Promise<[Project | undefined, R | undefined]> { + const symbols = context.getSymbols(this.project); + const projectSearch = search.with(this.project, symbols); + if (await projectSearch.score(this.project, context) <= 0) { + return [undefined, undefined]; + } + const result = await projectSearch.run(context, token); + return result === undefined ? [undefined, undefined] : [this.project, result]; + } + + public getCachedCode(key: string): CodeCacheItem | undefined { + return this.codeCache.get(key); + } + + public cacheCode(key: string, code: CodeCacheItem): void { + this.codeCache.set(key, code); + } + + public enableBlueprintSearch(): boolean { + return false; + } +} + +export interface RunnableResultContext { + createContextItemReference(key: protocol.ContextItemKey): protocol.ContextItemReference | undefined; + manageContextItem(item: protocol.FullContextItem): protocol.ContextItem; +} + +export enum SnippetLocation { + Primary, + Secondary, +} + +export class RunnableResult { + private readonly id: string; + private readonly runnableResultContext: RunnableResultContext; + private readonly primaryBudget: CharacterBudget; + private readonly secondaryBudget: CharacterBudget; + private state: protocol.ContextRunnableState; + private speculativeKind: protocol.SpeculativeKind; + private cache: protocol.CacheInfo | undefined; + + public readonly priority: number; + public readonly items: Map; + public debugPath: string | undefined; + + constructor(id: protocol.ContextRunnableResultId, priority: number, runnableResultContext: RunnableResultContext, primaryBudget: CharacterBudget, secondaryBudget: CharacterBudget, speculativeKind: protocol.SpeculativeKind, cache?: protocol.CacheInfo) { + this.id = id; + this.priority = priority; + this.runnableResultContext = runnableResultContext; + this.primaryBudget = primaryBudget; + this.secondaryBudget = secondaryBudget; + this.state = protocol.ContextRunnableState.Created; + this.speculativeKind = speculativeKind; + this.cache = cache; + this.items = new Map(); + } + + public isPrimaryBudgetExhausted(): boolean { + if (this.primaryBudget.isExhausted()) { + this.state = protocol.ContextRunnableState.IsFull; + return true; + } + return false; + } + + public isSecondaryBudgetExhausted(): boolean { + return this.secondaryBudget.isExhausted(); + } + + public done(): void { + if (this.state === protocol.ContextRunnableState.Created || this.state === protocol.ContextRunnableState.InProgress) { + this.state = protocol.ContextRunnableState.Finished; + } + } + + public setCacheInfo(cache: protocol.CacheInfo): void { + this.cache = cache; + } + + public addFromKnownItems(key: string): boolean { + this.state = protocol.ContextRunnableState.InProgress; + const reference = this.runnableResultContext.createContextItemReference(key); + if (reference === undefined) { + return false; + } + this.items.set(key, reference); + return true; + } + + public addTrait(traitKind: protocol.TraitKind, name: string, value: string, key: string): void { + this.state = protocol.ContextRunnableState.InProgress; + const trait = protocol.Trait.create(traitKind, name, value); + this.items.set(key ?? crypto.randomUUID(), this.runnableResultContext.manageContextItem(trait)); + this.primaryBudget.spent(protocol.Trait.sizeInChars(trait)); + } + + public addSnippet(code: SnippetProvider, location: SnippetLocation, key: string | undefined): void; + public addSnippet(code: SnippetProvider, location: SnippetLocation, key: string | undefined, ifRoom: false): void; + public addSnippet(code: SnippetProvider, location: SnippetLocation, key: string | undefined, ifRoom: true): boolean; + public addSnippet(code: SnippetProvider, location: SnippetLocation, key: string | undefined, ifRoom: boolean): boolean; + public addSnippet(code: SnippetProvider, location: SnippetLocation, key: string | undefined, ifRoom: boolean = false): boolean { + const budget = location === SnippetLocation.Primary ? this.primaryBudget : this.secondaryBudget; + if (code.isEmpty()) { + return true; + } + const snippet = code.snippet(key); + const size = protocol.CodeSnippet.sizeInChars(snippet); + if (ifRoom && !budget.hasRoom(size)) { + this.state = protocol.ContextRunnableState.IsFull; + return false; + } + this.state = protocol.ContextRunnableState.InProgress; + budget.spent(size); + this.items.set(key ?? crypto.randomUUID(), this.runnableResultContext.manageContextItem(snippet)); + return true; + } + + public toJson(): protocol.ContextRunnableResult { + return { + kind: protocol.ContextRunnableResultKind.ComputedResult, + id: this.id, + state: this.state, + priority: this.priority, + items: Array.from(this.items.values()), + cache: this.cache, + speculativeKind: this.speculativeKind, + debugPath: this.debugPath, + }; + } +} + +class RunnableResultReference { + private readonly cached: protocol.CachedContextRunnableResult; + + constructor(cached: protocol.CachedContextRunnableResult) { + this.cached = cached; + } + + public get items(): protocol.ContextItem[] { + return this.cached.items.map(item => protocol.ContextItemReference.create(item.key)); + } + + public toJson(): protocol.ContextRunnableResultReference { + return { kind: protocol.ContextRunnableResultKind.Reference, id: this.cached.id }; + } +} + +export class ContextResult implements RunnableResultContext { + public readonly primaryBudget: CharacterBudget; + public readonly secondaryBudget: CharacterBudget; + public readonly context: RequestContext; + + private state: protocol.ContextRequestResultState = protocol.ContextRequestResultState.Created; + private path: number[] | undefined; + private timings: protocol.Timings | undefined; + private timedOut: boolean = false; + private readonly errors: protocol.ErrorData[] = []; + private readonly runnableResults: (RunnableResult | RunnableResultReference)[] = []; + private readonly contextItems: Map = new Map(); + + constructor(primaryBudget: CharacterBudget, secondaryBudget: CharacterBudget, context: RequestContext) { + this.primaryBudget = primaryBudget; + this.secondaryBudget = secondaryBudget; + this.context = context; + } + + public getSession(): ComputeContextSession { + return this.context.session; + } + + public addPath(path: number[]): void { + this.path = path; + } + + public addErrorData(error: RecoverableError): void { + this.errors.push(protocol.ErrorData.create(error.code, error.message)); + } + + public addTimings(totalTime: number, computeTime: number): void { + this.timings = protocol.Timings.create(totalTime, computeTime); + } + + public setTimedOut(timedOut: boolean): void { + this.timedOut = timedOut; + } + + public createRunnableResult(id: protocol.ContextRunnableResultId, priority: number, speculativeKind: protocol.SpeculativeKind, cache?: protocol.CacheInfo): RunnableResult { + this.state = protocol.ContextRequestResultState.InProgress; + const result = new RunnableResult(id, priority, this, this.primaryBudget, this.secondaryBudget, speculativeKind, cache); + this.runnableResults.push(result); + return result; + } + + public addRunnableResultReference(cached: protocol.CachedContextRunnableResult): void { + this.state = protocol.ContextRequestResultState.InProgress; + this.runnableResults.push(new RunnableResultReference(cached)); + } + + public createContextItemReference(key: protocol.ContextItemKey): protocol.ContextItemReference | undefined { + return this.context.createContextItemReferenceIfManaged(key) + ?? (this.contextItems.has(key) ? protocol.ContextItemReference.create(key) : undefined); + } + + public manageContextItem(item: protocol.FullContextItem): protocol.ContextItem { + if (!protocol.ContextItem.hasKey(item)) { + return item; + } + if (this.context.clientHasContextItem(item.key) || this.contextItems.has(item.key)) { + return protocol.ContextItemReference.create(item.key); + } + this.contextItems.set(item.key, item); + return protocol.ContextItemReference.create(item.key); + } + + public done(): void { + this.state = protocol.ContextRequestResultState.Finished; + } + + public toJson(): protocol.ComputeContextResponse.OK { + return { + state: this.state, + path: this.path, + timings: this.timings, + errors: this.errors, + timedOut: this.timedOut, + exhausted: this.primaryBudget.isExhausted(), + runnableResults: this.runnableResults.map(result => result.toJson()), + contextItems: Array.from(this.contextItems.values()), + }; + } +} + +export enum ComputeCost { + Low = 1, + Medium = 2, + High = 3, +} + +export namespace CacheScopes { + export function fromDeclaration(declaration: FunctionLikeDeclaration): protocol.CacheScope | undefined { + return declaration.body === undefined ? undefined : createWithinCacheScope(declaration.body, declaration.getSourceFile()); + } + + export function createWithinCacheScope(node: Node | NodeArray, sourceFile?: SourceFile): protocol.CacheScope { + return { kind: protocol.CacheScopeKind.WithinRange, range: createRange(node, sourceFile) }; + } + + export function createOutsideCacheScope(nodes: Iterable, sourceFile: SourceFile): protocol.CacheScope { + const ranges = Array.from(nodes, node => createRange(node, sourceFile)); + ranges.sort((first, second) => first.start.line - second.start.line || first.start.character - second.start.character); + return { kind: protocol.CacheScopeKind.OutsideRange, ranges }; + } + + export function createRange(node: Node | NodeArray, sourceFile?: SourceFile): protocol.Range { + let startOffset: number; + let endOffset: number; + if (Array.isArray(node)) { + startOffset = node.pos; + endOffset = node.end; + } else { + const syntaxNode = node as Node; + sourceFile ??= syntaxNode.getSourceFile(); + startOffset = syntaxNode.getStart(sourceFile); + endOffset = syntaxNode.getEnd(); + } + if (sourceFile === undefined) { + throw new Error('No source file for cache range'); + } + return { + start: sourceFile.getLineAndCharacterOfPosition(startOffset), + end: sourceFile.getLineAndCharacterOfPosition(endOffset), + }; + } +} + +export interface ContextRunnable { + readonly id: protocol.ContextRunnableResultId; + readonly priority: number; + readonly cost: ComputeCost; + initialize(result: ContextResult): void; + compute(token: CancellationTokenWithTimer): Promise; +} + +class CacheBasedContextRunnable implements ContextRunnable { + private readonly cached: protocol.CachedContextRunnableResult; + private tokenBudget: CharacterBudget | undefined; + + public readonly id: protocol.ContextRunnableResultId; + public readonly priority: number; + public readonly cost: ComputeCost; + + constructor(cached: protocol.CachedContextRunnableResult, priority: number, cost: ComputeCost) { + this.cached = cached; + this.id = cached.id; + this.priority = priority; + this.cost = cost; + } + + public initialize(result: ContextResult): void { + this.tokenBudget = result.primaryBudget; + result.addRunnableResultReference(this.cached); + } + + public async compute(): Promise { + for (const item of this.cached.items) { + this.tokenBudget?.spent(item.sizeInChars ?? 0); + } + } +} + +export type SymbolData = { symbol: NativeSymbol; name?: string }; + +enum SymbolEmitDataKind { + symbol = 'symbol', + typeAlias = 'typeAlias', +} + +type SymbolEmitData = { kind: SymbolEmitDataKind.symbol; symbol: NativeSymbol; name?: string }; +type TypeAliasEmitData = { kind: SymbolEmitDataKind.typeAlias; node: TypeAliasDeclaration }; +type EmitData = SymbolEmitData | TypeAliasEmitData; + +export abstract class AbstractContextRunnable implements ContextRunnable { + public readonly session: ComputeContextSession; + public readonly symbols: Symbols; + public readonly id: protocol.ContextRunnableResultId; + protected readonly location: SnippetLocation; + public readonly priority: number; + public readonly cost: ComputeCost; + + protected readonly project: Project; + protected readonly context: RequestContext; + private result: RunnableResult | undefined; + + constructor(session: ComputeContextSession, project: Project, context: RequestContext, id: protocol.ContextRunnableResultId, location: SnippetLocation, priority: number, cost: ComputeCost) { + this.session = session; + this.project = project; + this.context = context; + this.symbols = context.getSymbols(project); + this.id = id; + this.location = location; + this.priority = priority; + this.cost = cost; + } + + public initialize(result: ContextResult): void { + if (this.result !== undefined) { + throw new Error('Runnable already initialized'); + } + this.result = this.createRunnableResult(result); + } + + public useCachedResult(cached: protocol.CachedContextRunnableResult): boolean { + const cacheInfo = cached.cache; + if (cacheInfo?.emitMode !== protocol.EmitMode.ClientBased) { + return false; + } + if (cached.state === protocol.ContextRunnableState.Finished) { + return true; + } + if (cached.state !== protocol.ContextRunnableState.IsFull) { + return false; + } + const kind = cacheInfo.scope.kind; + return kind === protocol.CacheScopeKind.WithinRange || kind === protocol.CacheScopeKind.NeighborFiles || kind === protocol.CacheScopeKind.File; + } + + public async compute(token: CancellationTokenWithTimer): Promise { + if (this.result === undefined) { + throw new Error('Runnable not initialized'); + } + token.throwIfCancellationRequested(); + if (!this.result.isPrimaryBudgetExhausted()) { + await this.run(this.result, token); + this.result.done(); + } + } + + public abstract getActiveSourceFile(): SourceFile; + protected abstract createRunnableResult(result: ContextResult): RunnableResult; + protected abstract run(result: RunnableResult, token: CancellationTokenWithTimer): Promise; + + protected getProject(): Project { + return this.project; + } + + protected createCacheScope(node: Node | NodeArray, sourceFile?: SourceFile): protocol.CacheScope { + return CacheScopes.createWithinCacheScope(node, sourceFile); + } + + protected async handleSymbol(symbol: NativeSymbol, name?: string, ifRoom: boolean = false): Promise { + if (this.result === undefined) { + return true; + } + const emitData = await this.getEmitDataForSymbol(symbol, name); + for (const item of emitData) { + if (item.kind === SymbolEmitDataKind.typeAlias) { + if (await this.skipNode(item.node)) { + continue; + } + const key = await this.symbols.createKey(item.node); + if (key !== undefined && this.result.addFromKnownItems(key)) { + continue; + } + const builder = new CodeSnippetBuilder(this.context, this.symbols, this.getActiveSourceFile()); + await builder.addDeclaration(item.node); + if (!builder.isEmpty() && !this.result.addSnippet(builder, this.location, key, ifRoom)) { + return false; + } + } else { + if (Symbols.isTypeParameter(item.symbol) || await this.skipSymbolBasedOnDeclaration(item.symbol)) { + continue; + } + const key = await this.symbols.createKey(item.symbol); + if (key !== undefined && this.result.addFromKnownItems(key)) { + continue; + } + const builder = new CodeSnippetBuilder(this.context, this.symbols, this.getActiveSourceFile()); + await builder.addTypeSymbol(item.symbol, item.name); + if (!builder.isEmpty() && !this.result.addSnippet(builder, this.location, key, ifRoom)) { + return false; + } + } + } + return true; + } + + protected async skipNode(node: Node): Promise { + return this.skipSourceFile(node.getSourceFile()); + } + + protected async skipSourceFile(sourceFile: SourceFile): Promise { + if (this.getActiveSourceFile().path === sourceFile.path) { + return true; + } + const metadata = await this.project.program.getSourceFileMetadataByPath(sourceFile.path); + return metadata?.isDefaultLibrary === true || metadata?.isFromExternalLibrary === true; + } + + protected async skipSymbolBasedOnDeclaration(symbol: NativeSymbol): Promise { + for (const declaration of await this.symbols.getDeclarations(symbol)) { + if (await this.skipSourceFile(declaration.getSourceFile())) { + return true; + } + } + return false; + } + + protected async getSymbolsForTypeNode(node: TypeNode): Promise { + const result: SymbolData[] = []; + await this.doGetSymbolsForTypeNode(result, node); + return result; + } + + protected async getSymbolsToEmitForType(type: Type): Promise { + return (await this.symbols.getTypeSymbols(type)).map(symbol => ({ symbol, name: symbol.name })); + } + + private async doGetSymbolsForTypeNode(result: SymbolData[], node: TypeNode): Promise { + if (isTypeReferenceNode(node)) { + const symbol = await this.symbols.getLeafSymbolAtLocation(node.typeName); + if (symbol !== undefined) { + result.push({ symbol, name: node.typeName.getText() }); + } + } else if (isUnionTypeNode(node) || isIntersectionTypeNode(node)) { + for (const type of node.types) { + await this.doGetSymbolsForTypeNode(result, type); + } + } else if (isTypeLiteralNode(node)) { + const symbol = await this.symbols.getLeafSymbolAtLocation(node); + if (symbol !== undefined) { + result.push({ symbol, name: symbol.name }); + } + } + } + + private async getEmitDataForSymbol(symbol: NativeSymbol, name?: string): Promise { + const result: EmitData[] = []; + await this.doGetEmitDataForSymbol(result, new Set(), 0, symbol, name); + return result; + } + + private async doGetEmitDataForSymbol(result: EmitData[], seen: Set, level: number, initialSymbol: NativeSymbol, name?: string): Promise { + const symbol = Symbols.isAlias(initialSymbol) ? await this.symbols.getLeafSymbol(initialSymbol) : initialSymbol; + if (seen.has(symbol.id) || level > 2) { + return; + } + seen.add(symbol.id); + if (!Symbols.isTypeAlias(symbol)) { + result.push({ kind: SymbolEmitDataKind.symbol, symbol, name }); + return; + } + + const declaration = (await this.symbols.getDeclarations(symbol)).find(isTypeAliasDeclaration); + if (declaration === undefined) { + return; + } + name ??= declaration.name.getText(); + const type = declaration.type; + if (isTypeLiteralNode(type)) { + let typeSymbol = await this.symbols.getSymbolAtLocation(type); + if (typeSymbol === undefined) { + const resolvedType = await this.project.checker.getTypeFromTypeNode(type); + typeSymbol = resolvedType === undefined ? undefined : await resolvedType.getSymbol(); + } + if (typeSymbol !== undefined && !seen.has(typeSymbol.id)) { + result.push({ kind: SymbolEmitDataKind.symbol, symbol: typeSymbol, name }); + } + } else if (isTypeReferenceNode(type)) { + const typeSymbol = await this.symbols.getSymbolAtLocation(type.typeName); + if (typeSymbol !== undefined) { + await this.doGetEmitDataForSymbol(result, seen, level + 1, typeSymbol, name); + } + } else if (isUnionTypeNode(type) || isIntersectionTypeNode(type)) { + result.push({ kind: SymbolEmitDataKind.typeAlias, node: declaration }); + if (level < 2) { + for (const item of type.types) { + for (const data of await this.getSymbolsForTypeNode(item)) { + await this.doGetEmitDataForSymbol(result, seen, level + 1, data.symbol, data.name); + } + } + } + } + } +} + +export class ContextRunnableCollector { + private readonly cachedRunnableResults: Map; + + public readonly primary: ContextRunnable[] = []; + public readonly secondary: ContextRunnable[] = []; + public readonly tertiary: ContextRunnable[] = []; + + constructor(cachedRunnableResults: Map) { + this.cachedRunnableResults = cachedRunnableResults; + } + + public addPrimary(runnable: AbstractContextRunnable): void { + this.primary.push(this.useCachedRunnableIfPossible(runnable)); + } + + public addSecondary(runnable: AbstractContextRunnable): void { + this.secondary.push(this.useCachedRunnableIfPossible(runnable)); + } + + public addTertiary(runnable: AbstractContextRunnable): void { + this.tertiary.push(this.useCachedRunnableIfPossible(runnable)); + } + + public *entries(): IterableIterator { + yield* this.primary; + yield* this.secondary; + yield* this.tertiary; + } + + public getPrimaryRunnables(): ContextRunnable[] { + return this.sort(this.primary); + } + + public getSecondaryRunnables(): ContextRunnable[] { + return this.sort(this.secondary); + } + + public getTertiaryRunnables(): ContextRunnable[] { + return this.sort(this.tertiary); + } + + private sort(runnables: ContextRunnable[]): ContextRunnable[] { + return runnables.sort((first, second) => first.cost - second.cost || second.priority - first.priority); + } + + private useCachedRunnableIfPossible(runnable: AbstractContextRunnable): ContextRunnable { + const cached = this.cachedRunnableResults.get(runnable.id); + return cached !== undefined && runnable.useCachedResult(cached) + ? new CacheBasedContextRunnable(cached, runnable.priority, runnable.cost) + : runnable; + } +} + +export abstract class ContextProvider { + public isCallableProvider?: boolean; + + public abstract provide(result: ContextRunnableCollector, session: ComputeContextSession, project: Project, context: RequestContext, token: CancellationTokenWithTimer): Promise; +} + +export interface ProviderComputeContext { + isFirstCallableProvider(contextProvider: ContextProvider): boolean; +} + +export type ContextProviderFactory = (node: Node, tokenInfo: tss.TokenInfo, context: ProviderComputeContext) => ContextProvider | undefined; + +export class TokenBudgetExhaustedError extends Error { + constructor() { + super('Budget exhausted'); + } +} + +export class CharacterBudget { + private charBudget: number; + private readonly lowWaterMark: number; + private itemRejected: boolean = false; + + constructor(budget: number, lowWaterMark: number = 256) { + this.charBudget = budget; + this.lowWaterMark = lowWaterMark; + } + + public spent(chars: number): void { + this.charBudget -= chars; + } + + public hasRoom(chars: number): boolean { + const result = this.charBudget - this.lowWaterMark >= chars; + if (!result) { + this.itemRejected = true; + } + return result; + } + + public isExhausted(): boolean { + return this.charBudget <= 0; + } + + public wasItemRejected(): boolean { + return this.itemRejected; + } + + public throwIfExhausted(): void { + if (this.isExhausted()) { + throw new TokenBudgetExhaustedError(); + } + } + + public spentAndThrowIfExhausted(chars: number): void { + this.spent(chars); + this.throwIfExhausted(); + } +} + +export { Types }; diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/functionContextProvider.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/functionContextProvider.ts new file mode 100644 index 00000000000000..c866dc032c8a0b --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/functionContextProvider.ts @@ -0,0 +1,28 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { Project, Symbol as NativeSymbol } from '@typescript/native/unstable/async'; +import type { ArrowFunction, FunctionDeclaration, FunctionExpression } from '@typescript/native/unstable/ast'; +import { FunctionLikeContextProvider } from './baseContextProviders'; +import type { ComputeContextSession, ContextRunnableCollector, ProviderComputeContext, RequestContext } from './contextProvider'; +import type tss from './typescripts'; +import type { CancellationTokenWithTimer } from './typescripts'; + +export class FunctionContextProvider extends FunctionLikeContextProvider { + protected readonly functionDeclaration: FunctionDeclaration | ArrowFunction | FunctionExpression; + + constructor(functionDeclaration: FunctionDeclaration | ArrowFunction | FunctionExpression, tokenInfo: tss.TokenInfo, computeContext: ProviderComputeContext) { + super(functionDeclaration, tokenInfo, computeContext); + this.functionDeclaration = functionDeclaration; + } + + public override async provide(result: ContextRunnableCollector, session: ComputeContextSession, project: Project, context: RequestContext, token: CancellationTokenWithTimer): Promise { + await super.provide(result, session, project, context, token); + } + + protected override async getTypeExcludes(): Promise> { + return new Set(); + } +} diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/methodContextProvider.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/methodContextProvider.ts new file mode 100644 index 00000000000000..4e05d61be88f66 --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/methodContextProvider.ts @@ -0,0 +1,481 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { SignatureKind, type Project, type Symbol as NativeSymbol } from '@typescript/native/unstable/async'; +import { + escapeLeadingUnderscores, + InternalSymbolName, + ModifierFlags, + isClassDeclaration, + isConstructorDeclaration, + isExpressionWithTypeArguments, + isGetAccessorDeclaration, + isInterfaceDeclaration, + isMethodDeclaration, + isMethodSignatureDeclaration, + isPropertyDeclaration, + isPropertySignatureDeclaration, + isSetAccessorDeclaration, + isTypeReferenceNode, + type ClassDeclaration, + type ConstructorDeclaration, + type GetAccessorDeclaration, + type InterfaceDeclaration, + type MethodDeclaration, + type Node, + type SetAccessorDeclaration, + type SourceFile, + type __String, +} from '@typescript/native/unstable/ast'; +import { FunctionLikeContextProvider, FunctionLikeContextRunnable } from './baseContextProviders'; +import { CodeSnippetBuilder } from './code'; +import { + AbstractContextRunnable, + ComputeCost, + Search, + SnippetLocation, + type ComputeContextSession, + type ContextResult, + type ContextRunnableCollector, + type ProviderComputeContext, + type RequestContext, + type RunnableResult, +} from './contextProvider'; +import * as protocol from '../../common/serverProtocol'; +import { type CancellationTokenWithTimer, Symbols, type TokenInfo } from './typescripts'; + +abstract class ClassPropertyBlueprintSearch extends Search { + protected declaration: T; + + constructor(project: Project, symbols: Symbols, declaration: T) { + super(project, symbols); + this.declaration = declaration; + } + + public isSame(other: T): boolean { + return this.declaration === other || (this.declaration.getSourceFile().path === other.getSourceFile().path && this.declaration.pos === other.pos); + } + + public override async score(project: Project, context: RequestContext): Promise { + if (await project.program.getSourceFile(this.declaration.getSourceFile().fileName) === undefined) { + return 0; + } + if (context.neighborFiles.length === 0) { + return 1; + } + let result = Math.pow(10, context.neighborFiles.length.toString().length); + for (const file of context.neighborFiles) { + if (await project.program.getSourceFile(file) !== undefined) { + result++; + } + } + return result; + } + + protected async findClassWithMember(startSymbols: readonly NativeSymbol[], memberName: __String, token: CancellationTokenWithTimer): Promise { + const queue = [...startSymbols]; + const seen = new Set(queue.map(symbol => symbol.id)); + while (queue.length > 0) { + token.throwIfCancellationRequested(); + const current = queue.shift(); + if (current === undefined) { + break; + } + for (const candidate of await this.getDirectSubTypes(current, token)) { + if (seen.has(candidate.id)) { + continue; + } + seen.add(candidate.id); + queue.push(candidate); + if (!Symbols.isClass(candidate)) { + continue; + } + const member = (await candidate.getMembers()).get(memberName); + if (member === undefined) { + continue; + } + for (const declaration of await this.symbols.getDeclarations(member)) { + if (declaration.kind !== this.declaration.kind || (!isMethodDeclaration(declaration) && !isConstructorDeclaration(declaration))) { + continue; + } + const parent = declaration.parent; + if (isClassDeclaration(parent) && !this.isCurrentClass(parent)) { + return parent; + } + } + } + } + return undefined; + } + + private async getDirectSubTypes(symbol: NativeSymbol, token: CancellationTokenWithTimer): Promise { + const result: NativeSymbol[] = []; + const seen = new Set(); + for (const declaration of await this.symbols.getDeclarations(symbol)) { + const name = (isClassDeclaration(declaration) || isInterfaceDeclaration(declaration)) ? declaration.name : undefined; + if (name === undefined) { + continue; + } + for (const entry of await this.project.checker.getReferencedSymbolsForNode(name, name.getStart())) { + for (const reference of entry.references) { + token.throwIfCancellationRequested(); + const node = await reference.resolve(this.project); + const subtypeDeclaration = node === undefined ? undefined : this.getContainingHeritageDeclaration(node); + if (subtypeDeclaration === undefined) { + continue; + } + const subtype = await this.symbols.getLeafSymbolAtLocation(subtypeDeclaration.name ?? subtypeDeclaration); + if (subtype !== undefined && !seen.has(subtype.id)) { + seen.add(subtype.id); + result.push(subtype); + } + } + } + } + return result; + } + + private getContainingHeritageDeclaration(node: Node): ClassDeclaration | InterfaceDeclaration | undefined { + let current: Node | undefined = node; + let inHeritageClause = false; + while (current !== undefined) { + if (isExpressionWithTypeArguments(current)) { + inHeritageClause = true; + } + if (inHeritageClause && (isClassDeclaration(current) || isInterfaceDeclaration(current))) { + return current; + } + current = current.parent; + } + return undefined; + } + + private isCurrentClass(candidate: ClassDeclaration): boolean { + const current = this.declaration.parent; + return isClassDeclaration(current) && (candidate === current || (candidate.getSourceFile().path === current.getSourceFile().path && candidate.pos === current.pos)); + } +} + +abstract class MethodBlueprintSearch extends ClassPropertyBlueprintSearch { + constructor(project: Project, symbols: Symbols, declaration: MethodDeclaration) { + super(project, symbols, declaration); + } + + public static async create(project: Project, symbols: Symbols, declaration: MethodDeclaration): Promise | undefined> { + const classDeclaration = declaration.parent; + if (!isClassDeclaration(classDeclaration)) { + return undefined; + } + const classSymbol = await symbols.getLeafSymbolAtLocation(classDeclaration.name ?? classDeclaration); + if (!Symbols.isClass(classSymbol)) { + return undefined; + } + const direct = await symbols.getDirectSuperSymbols(classSymbol); + const isPrivate = 'modifierFlags' in declaration && typeof declaration.modifierFlags === 'number' && (declaration.modifierFlags & ModifierFlags.Private) !== 0; + if (isPrivate && direct?.extends !== undefined) { + return new PrivateMethodBlueprintSearch(project, symbols, classDeclaration, direct.extends.symbol, declaration); + } + + const memberName = escapeLeadingUnderscores(declaration.name.getText()); + for (const superClass of await symbols.getAllSuperClasses(classSymbol)) { + if ((await superClass.getMembers()).has(memberName)) { + return new FindMethodInSubclassSearch(project, symbols, classDeclaration, declaration, superClass); + } + } + const typesToCheck: NativeSymbol[] = []; + for (const superType of await symbols.getAllSuperTypes(classSymbol)) { + if ((Symbols.isInterface(superType) || Symbols.isTypeLiteral(superType)) && (await superType.getMembers()).has(memberName)) { + typesToCheck.push(superType); + } + } + return typesToCheck.length === 0 ? undefined : new FindMethodInHierarchySearch(project, symbols, classDeclaration, declaration, typesToCheck); + } +} + +abstract class FindInSiblingClassSearch extends ClassPropertyBlueprintSearch { + private readonly classDeclaration: ClassDeclaration; + protected readonly extendsSymbol: NativeSymbol; + + constructor(project: Project, symbols: Symbols, classDeclaration: ClassDeclaration, extendsSymbol: NativeSymbol, declaration: T) { + super(project, symbols, declaration); + this.classDeclaration = classDeclaration; + this.extendsSymbol = extendsSymbol; + } + + public override async run(_context: RequestContext, token: CancellationTokenWithTimer): Promise { + return this.findClassWithMember([this.extendsSymbol], this.getMemberName(), token); + } + + protected abstract getMemberName(): __String; + + protected getClassDeclaration(): ClassDeclaration { + return this.classDeclaration; + } +} + +class PrivateMethodBlueprintSearch extends FindInSiblingClassSearch { + public override with(project: Project, symbols: Symbols): PrivateMethodBlueprintSearch { + return project === this.project ? this : new PrivateMethodBlueprintSearch(project, symbols, this.getClassDeclaration(), this.extendsSymbol, this.declaration); + } + + protected override getMemberName(): __String { + return escapeLeadingUnderscores(this.declaration.name.getText()); + } +} + +class FindMethodInSubclassSearch extends MethodBlueprintSearch { + private readonly classDeclaration: ClassDeclaration; + private readonly startClass: NativeSymbol; + + constructor(project: Project, symbols: Symbols, classDeclaration: ClassDeclaration, declaration: MethodDeclaration, startClass: NativeSymbol) { + super(project, symbols, declaration); + this.classDeclaration = classDeclaration; + this.startClass = startClass; + } + + public override with(project: Project, symbols: Symbols): FindMethodInSubclassSearch { + return project === this.project ? this : new FindMethodInSubclassSearch(project, symbols, this.classDeclaration, this.declaration, this.startClass); + } + + public override async run(_context: RequestContext, token: CancellationTokenWithTimer): Promise { + return this.findClassWithMember([this.startClass], escapeLeadingUnderscores(this.declaration.name.getText()), token); + } +} + +class FindMethodInHierarchySearch extends MethodBlueprintSearch { + private readonly classDeclaration: ClassDeclaration; + private readonly typesToCheck: readonly NativeSymbol[]; + + constructor(project: Project, symbols: Symbols, classDeclaration: ClassDeclaration, declaration: MethodDeclaration, typesToCheck: readonly NativeSymbol[]) { + super(project, symbols, declaration); + this.classDeclaration = classDeclaration; + this.typesToCheck = typesToCheck; + } + + public override with(project: Project, symbols: Symbols): FindMethodInHierarchySearch { + return project === this.project ? this : new FindMethodInHierarchySearch(project, symbols, this.classDeclaration, this.declaration, this.typesToCheck); + } + + public override async run(_context: RequestContext, token: CancellationTokenWithTimer): Promise { + return this.findClassWithMember(this.typesToCheck, escapeLeadingUnderscores(this.declaration.name.getText()), token); + } +} + +abstract class SimilarPropertyRunnable extends FunctionLikeContextRunnable { + constructor(session: ComputeContextSession, project: Project, context: RequestContext, declaration: T, priority: number = protocol.Priorities.Blueprints) { + super(session, project, context, 'SimilarPropertyRunnable', declaration, priority, ComputeCost.High); + } + + protected override createRunnableResult(result: ContextResult): RunnableResult { + const scope = this.getCacheScope(); + return result.createRunnableResult(this.id, this.priority, protocol.SpeculativeKind.emit, scope === undefined ? undefined : { emitMode: protocol.EmitMode.ClientBased, scope }); + } + + protected override async run(result: RunnableResult, token: CancellationTokenWithTimer): Promise { + const search = await this.createSearch(token); + if (search === undefined) { + return; + } + const [project, candidate] = await this.session.run(search, this.context, token); + if (project === undefined || candidate === undefined) { + return; + } + const builder = new CodeSnippetBuilder(this.context, this.context.getSymbols(project), this.declaration.getSourceFile()); + await builder.addDeclaration(candidate); + result.addSnippet(builder, this.location, undefined); + } + + protected abstract createSearch(token: CancellationTokenWithTimer): Promise | undefined>; +} + +class SimilarMethodRunnable extends SimilarPropertyRunnable { + protected override async createSearch(): Promise | undefined> { + return MethodBlueprintSearch.create(this.getProject(), this.symbols, this.declaration); + } +} + +abstract class ClassPropertyContextProvider extends FunctionLikeContextProvider { + protected readonly declaration: T; + + constructor(declaration: T, tokenInfo: TokenInfo, computeContext: ProviderComputeContext) { + super(declaration, tokenInfo, computeContext); + this.declaration = declaration; + } + + protected override async getTypeExcludes(project: Project, context: RequestContext): Promise> { + const result = new Set(); + const classDeclaration = this.declaration.parent; + if (!isClassDeclaration(classDeclaration)) { + return result; + } + const symbols = context.getSymbols(project); + for (const heritageClause of classDeclaration.heritageClauses ?? []) { + for (const type of heritageClause.types) { + // const symbol = isExpressionWithTypeArguments(type) ? await symbols.getLeafSymbolAtLocation(type.expression) : await symbols.getLeafSymbolAtLocation(type.typeName); + const symbol = await symbols.getLeafSymbolAtLocation(type.expression); + if (Symbols.isClass(symbol)) { + result.add(symbol); + } + } + } + return result; + } +} + +class PropertiesTypeRunnable extends AbstractContextRunnable { + private readonly declaration: MethodDeclaration | ConstructorDeclaration | GetAccessorDeclaration | SetAccessorDeclaration; + + constructor(session: ComputeContextSession, project: Project, context: RequestContext, declaration: MethodDeclaration | ConstructorDeclaration | GetAccessorDeclaration | SetAccessorDeclaration, priority: number = protocol.Priorities.Properties) { + super(session, project, context, 'PropertiesTypeRunnable', SnippetLocation.Secondary, priority, ComputeCost.Medium); + this.declaration = declaration; + } + + public override getActiveSourceFile(): SourceFile { + return this.declaration.getSourceFile(); + } + + protected override createRunnableResult(result: ContextResult): RunnableResult { + return result.createRunnableResult(this.id, this.priority, protocol.SpeculativeKind.emit, { emitMode: protocol.EmitMode.ClientBased, scope: this.createCacheScope(this.declaration) }); + } + + protected override async run(_result: RunnableResult, token: CancellationTokenWithTimer): Promise { + const containerDeclaration = this.declaration.parent; + if (!isClassDeclaration(containerDeclaration)) { + return; + } + const containerSymbol = await this.symbols.getLeafSymbolAtLocation(containerDeclaration.name ?? containerDeclaration); + if (!Symbols.isClass(containerSymbol)) { + return; + } + for (const member of (await containerSymbol.getMembers()).values()) { + token.throwIfCancellationRequested(); + if (!await this.handleMember(member, ModifierFlags.Private | ModifierFlags.Protected)) { + return; + } + } + for (const superClass of await this.symbols.getAllSuperClasses(containerSymbol)) { + for (const member of (await superClass.getMembers()).values()) { + token.throwIfCancellationRequested(); + if (!await this.handleMember(member, ModifierFlags.Public | ModifierFlags.Protected)) { + return; + } + } + } + } + + private async handleMember(symbol: NativeSymbol, flags: ModifierFlags): Promise { + const declarations = await this.symbols.getDeclarations(symbol); + if (!declarations.some(declaration => this.hasModifierFlags(declaration, flags))) { + return true; + } + for (const [typeSymbol, name] of await this.getEmitMemberData(symbol, declarations)) { + if (typeSymbol !== undefined && !await this.handleSymbol(typeSymbol, name, true)) { + return false; + } + } + return true; + } + + private async getEmitMemberData(symbol: NativeSymbol, declarations: readonly Node[]): Promise { + const result: (readonly [NativeSymbol | undefined, string | undefined])[] = []; + const type = await this.getProject().checker.getTypeOfSymbol(symbol); + if (type === undefined) { + return result; + } + if (Symbols.isProperty(symbol)) { + for (const typeSymbol of await this.symbols.getTypeSymbols(type)) { + result.push([typeSymbol, this.getDeclaredTypeName(declarations)]); + } + } else if (Symbols.isMethod(symbol)) { + for (const signature of await this.getProject().checker.getSignaturesOfType(type, SignatureKind.Call)) { + const returnType = await this.getProject().checker.getReturnTypeOfSignature(signature); + if (returnType !== undefined) { + for (const typeSymbol of await this.symbols.getTypeSymbols(returnType)) { + result.push([typeSymbol, this.getDeclaredTypeName(declarations)]); + } + } + } + } + return result; + } + + private getDeclaredTypeName(declarations: readonly Node[]): string | undefined { + for (const declaration of declarations) { + if ((isPropertyDeclaration(declaration) || isPropertySignatureDeclaration(declaration) || isMethodDeclaration(declaration) || isMethodSignatureDeclaration(declaration) || isGetAccessorDeclaration(declaration) || isSetAccessorDeclaration(declaration)) && declaration.type !== undefined && isTypeReferenceNode(declaration.type)) { + return declaration.type.typeName.getText(); + } + } + return undefined; + } + + private hasModifierFlags(node: Node, flags: ModifierFlags): boolean { + return 'modifierFlags' in node && typeof node.modifierFlags === 'number' && (node.modifierFlags & flags) !== 0; + } +} + +export class MethodContextProvider extends ClassPropertyContextProvider { + constructor(declaration: MethodDeclaration, tokenInfo: TokenInfo, computeContext: ProviderComputeContext) { + super(declaration, tokenInfo, computeContext); + } + + public override async provide(result: ContextRunnableCollector, session: ComputeContextSession, project: Project, context: RequestContext, token: CancellationTokenWithTimer): Promise { + if (session.enableBlueprintSearch()) { + result.addPrimary(new SimilarMethodRunnable(session, project, context, this.declaration)); + } + await super.provide(result, session, project, context, token); + result.addSecondary(new PropertiesTypeRunnable(session, project, context, this.declaration)); + } +} + +export class AccessorProvider extends ClassPropertyContextProvider { + constructor(declaration: GetAccessorDeclaration | SetAccessorDeclaration, tokenInfo: TokenInfo, computeContext: ProviderComputeContext) { + super(declaration, tokenInfo, computeContext); + } + + public override async provide(result: ContextRunnableCollector, session: ComputeContextSession, project: Project, context: RequestContext, token: CancellationTokenWithTimer): Promise { + await super.provide(result, session, project, context, token); + result.addSecondary(new PropertiesTypeRunnable(session, project, context, this.declaration)); + } +} + +class ConstructorBlueprintSearch extends FindInSiblingClassSearch { + public override with(project: Project, symbols: Symbols): ConstructorBlueprintSearch { + return project === this.project ? this : new ConstructorBlueprintSearch(project, symbols, this.getClassDeclaration(), this.extendsSymbol, this.declaration); + } + + protected override getMemberName(): __String { + return InternalSymbolName.Constructor; + } +} + +class SimilarConstructorRunnable extends SimilarPropertyRunnable { + protected override async createSearch(): Promise | undefined> { + const classDeclaration = this.declaration.parent; + if (!isClassDeclaration(classDeclaration)) { + return undefined; + } + const classSymbol = await this.symbols.getLeafSymbolAtLocation(classDeclaration.name ?? classDeclaration); + if (!Symbols.isClass(classSymbol)) { + return undefined; + } + const direct = await this.symbols.getDirectSuperSymbols(classSymbol); + return direct?.extends === undefined + ? undefined + : new ConstructorBlueprintSearch(this.getProject(), this.symbols, classDeclaration, direct.extends.symbol, this.declaration); + } +} + +export class ConstructorContextProvider extends ClassPropertyContextProvider { + constructor(declaration: ConstructorDeclaration, tokenInfo: TokenInfo, computeContext: ProviderComputeContext) { + super(declaration, tokenInfo, computeContext); + } + + public override async provide(result: ContextRunnableCollector, session: ComputeContextSession, project: Project, context: RequestContext, token: CancellationTokenWithTimer): Promise { + if (session.enableBlueprintSearch()) { + result.addPrimary(new SimilarConstructorRunnable(session, project, context, this.declaration)); + } + await super.provide(result, session, project, context, token); + } +} diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/moduleContextProvider.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/moduleContextProvider.ts new file mode 100644 index 00000000000000..4534583766a5da --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/moduleContextProvider.ts @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { Project, Symbol as NativeSymbol } from '@typescript/native/unstable/async'; +import type { ModuleDeclaration } from '@typescript/native/unstable/ast'; +import { ImportsRunnable, TypeOfExpressionRunnable, TypeOfLocalsRunnable, TypesOfNeighborFilesRunnable } from './baseContextProviders'; +import { ContextProvider, type ComputeContextSession, type ContextRunnableCollector, type ProviderComputeContext, type RequestContext } from './contextProvider'; +import type tss from './typescripts'; +import type { CancellationTokenWithTimer } from './typescripts'; + +export class ModuleContextProvider extends ContextProvider { + protected readonly declaration: ModuleDeclaration; + private readonly tokenInfo: tss.TokenInfo; + private readonly computeInfo: ProviderComputeContext; + + public override readonly isCallableProvider: boolean = true; + + constructor(declaration: ModuleDeclaration, tokenInfo: tss.TokenInfo, computeInfo: ProviderComputeContext) { + super(); + this.declaration = declaration; + this.tokenInfo = tokenInfo; + this.computeInfo = computeInfo; + } + + public override async provide(result: ContextRunnableCollector, session: ComputeContextSession, project: Project, context: RequestContext, token: CancellationTokenWithTimer): Promise { + token.throwIfCancellationRequested(); + if (!this.computeInfo.isFirstCallableProvider(this)) { + return; + } + const excludes = new Set(); + result.addPrimary(new TypeOfLocalsRunnable(session, project, context, this.tokenInfo, excludes, undefined)); + const expression = TypeOfExpressionRunnable.create(session, project, context, this.tokenInfo, token); + if (expression !== undefined) { + result.addPrimary(expression); + } + result.addSecondary(new ImportsRunnable(session, project, context, this.tokenInfo, excludes)); + if (context.neighborFiles.length > 0) { + result.addTertiary(new TypesOfNeighborFilesRunnable(session, project, context, this.tokenInfo)); + } + } +} diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/nesRenameService.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/nesRenameService.ts new file mode 100644 index 00000000000000..78ef39434ecf3d --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/nesRenameService.ts @@ -0,0 +1,118 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as inspector from 'node:inspector'; + +import type { Project, Snapshot } from '@typescript/native/unstable/async'; +import type { SourceFile } from '@typescript/native/unstable/ast'; +import * as vscode from 'vscode'; +import { ILogService } from '../../../../platform/log/common/logService'; +import { DisposableStore } from '../../../../util/vs/base/common/lifecycle'; +import * as protocol from '../../common/serverProtocol'; +import { nesRename, prepareNesRename } from './api'; +import { TypeScript7Api } from './ts7Api'; +import { PrepareNesRenameResult } from './nesRenameValidator'; +import { CancellationTokenWithTimer, OperationCanceledException } from './typescripts'; + +type ProjectState = { + readonly project: Project; + readonly sourceFile: SourceFile; +}; + +export class TS7NesRenameService implements vscode.Disposable { + private readonly disposables = new DisposableStore(); + private readonly nativeApi: TypeScript7Api; + private readonly isDebugging: boolean; + + constructor(logService: ILogService) { + this.nativeApi = this.disposables.add(new TypeScript7Api(logService)); + this.isDebugging = inspector.url() !== undefined; + } + + public dispose(): void { + this.disposables.dispose(); + } + + public async isActivated(documentOrLanguageId: vscode.TextDocument | string): Promise { + const languageId = typeof documentOrLanguageId === 'string' ? documentOrLanguageId : documentOrLanguageId.languageId; + return (languageId === 'typescript' || languageId === 'typescriptreact') && await this.nativeApi.getApi() !== undefined; + } + + public async prepare(document: vscode.TextDocument, position: vscode.Position, oldName: string, newName: string, lastSymbolRename: vscode.Range | undefined, startTime: number, timeBudget: number, token: vscode.CancellationToken): Promise { + const no: protocol.PrepareNesRenameResult.No = { canRename: protocol.RenameKind.no, timedOut: false }; + const api = await this.nativeApi.getApi(); + if (api === undefined || token.isCancellationRequested) { + return no; + } + api.clearSourceFileCache(); + const snapshot = await api.updateSnapshot({ openFiles: [{ uri: document.uri.toString() }] }); + try { + const state = await this.getProjectState(snapshot, document); + if (state === undefined) { + return no; + } + const cancellationToken = new CancellationTokenWithTimer(token, startTime, timeBudget, this.isDebugging); + const result = new PrepareNesRenameResult(); + try { + const offset = state.sourceFile.getPositionOfLineAndCharacter(position.line, position.character); + await prepareNesRename(result, api, snapshot, state.project, state.sourceFile, offset, oldName, newName, toRange(lastSymbolRename), cancellationToken); + } catch (error) { + if (error instanceof OperationCanceledException) { + result.setCanRename(protocol.RenameKind.no, 'Operation canceled'); + } else { + throw error; + } + } + result.setTimedOut(cancellationToken.isTimedOut()); + return result.toJsonResponse(); + } finally { + await snapshot.dispose(); + } + } + + public async postRename(document: vscode.TextDocument, position: vscode.Position, oldName: string, newName: string, lastSymbolRename: vscode.Range | undefined, token: vscode.CancellationToken): Promise { + const api = await this.nativeApi.getApi(); + if (api === undefined || token.isCancellationRequested) { + return []; + } + api.clearSourceFileCache(); + const snapshot = await api.updateSnapshot({ openFiles: [{ uri: document.uri.toString() }] }); + try { + const state = await this.getProjectState(snapshot, document); + if (state === undefined) { + return []; + } + const cancellationToken = new CancellationTokenWithTimer(token, Date.now(), Number.MAX_VALUE, this.isDebugging); + try { + const offset = state.sourceFile.getPositionOfLineAndCharacter(position.line, position.character); + return await nesRename(api, snapshot, state.project, state.sourceFile, offset, oldName, newName, toRange(lastSymbolRename), cancellationToken); + } catch (error) { + if (error instanceof OperationCanceledException) { + return []; + } + throw error; + } + } finally { + await snapshot.dispose(); + } + } + + private async getProjectState(snapshot: Snapshot, document: vscode.TextDocument): Promise { + const identifier = { uri: document.uri.toString() }; + const project = await snapshot.getDefaultProjectForFile(identifier); + const sourceFile = await project?.program.getSourceFile(identifier); + if (project === undefined || sourceFile === undefined || sourceFile.text !== document.getText()) { + return undefined; + } + return { project, sourceFile }; + } +} + +function toRange(range: vscode.Range | undefined): protocol.Range | undefined { + return range === undefined ? undefined : { + start: { line: range.start.line, character: range.start.character }, + end: { line: range.end.line, character: range.end.character }, + }; +} diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/nesRenameValidator.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/nesRenameValidator.ts new file mode 100644 index 00000000000000..b4c93a48d1be51 --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/nesRenameValidator.ts @@ -0,0 +1,229 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { SymbolFlags, type Project, type Symbol as NativeSymbol } from '@typescript/native/unstable/async'; +import { escapeLeadingUnderscores, isBlock, isFunctionDeclaration, isMethodDeclaration, isModuleBlock, isSourceFile, type Node } from '@typescript/native/unstable/ast'; +import * as protocol from '../../common/serverProtocol'; +import { CancellationTokenWithTimer, Symbols } from './typescripts'; + +const renameSymbolFlags = SymbolFlags.Value | SymbolFlags.Type | SymbolFlags.Namespace | SymbolFlags.Alias; + +export class PrepareNesRenameResult { + private canRename: protocol.RenameKind | undefined; + private oldName: string | undefined; + private reason: string | undefined; + private timedOut: boolean = false; + private onOldState: boolean = false; + + public getCanRename(): protocol.RenameKind | undefined { + return this.canRename; + } + + public setCanRename(value: protocol.RenameKind.no, reason?: string): PrepareNesRenameResult; + public setCanRename(value: protocol.RenameKind.yes | protocol.RenameKind.maybe, oldName: string, onOldState?: boolean): PrepareNesRenameResult; + public setCanRename(value: protocol.RenameKind, valueOrReason?: string, onOldState?: boolean): PrepareNesRenameResult { + this.canRename = value; + if (value === protocol.RenameKind.no) { + this.reason = valueOrReason; + } else { + this.oldName = valueOrReason; + this.onOldState = onOldState ?? this.onOldState; + } + return this; + } + + public setOnOldState(value: boolean): PrepareNesRenameResult { + if (this.canRename === protocol.RenameKind.no) { + throw new Error('Cannot set onOldState when canRename is no'); + } + this.onOldState = value; + return this; + } + + public setTimedOut(value: boolean): PrepareNesRenameResult { + this.timedOut = value; + return this; + } + + public toJsonResponse(): protocol.PrepareNesRenameResponse.OK { + if (this.timedOut) { + return { + canRename: protocol.RenameKind.no, + reason: this.reason, + timedOut: true, + }; + } + if (this.canRename === protocol.RenameKind.yes || this.canRename === protocol.RenameKind.maybe) { + return { + canRename: this.canRename, + oldName: this.oldName!, + onOldState: this.onOldState, + }; + } + return { + canRename: protocol.RenameKind.no, + timedOut: false, + reason: this.reason, + }; + } +} + +class DeclarationChecker { + constructor( + private readonly result: PrepareNesRenameResult, + private readonly symbols: Symbols, + private readonly symbol: NativeSymbol, + ) { } + + public async checkDeclarations(): Promise { + const declarations = await this.symbols.getDeclarations(this.symbol); + if (declarations.length <= 1) { + return; + } + let withBody = 0; + const signatures = new Set(); + for (const declaration of declarations) { + if ((isMethodDeclaration(declaration) || isFunctionDeclaration(declaration)) && declaration.body !== undefined) { + withBody++; + if (withBody === 2) { + this.result.setCanRename(protocol.RenameKind.no, 'The symbol has multiple declarations with body'); + return; + } + continue; + } + const text = declaration.getText(); + if (signatures.has(text)) { + this.result.setCanRename(protocol.RenameKind.no, 'The symbol has multiple identical declarations'); + return; + } + signatures.add(text); + } + } +} + +export async function validateNesRename(result: PrepareNesRenameResult, project: Project, node: Node, oldName: string, newName: string, token: CancellationTokenWithTimer): Promise { + const symbols = new Symbols(project, token); + const symbol = await symbols.getLeafSymbolAtLocation(node); + if (symbol === undefined) { + result.setCanRename(protocol.RenameKind.no, 'No symbol found at location'); + return; + } + const parent = await symbol.getParent(); + const declarations = await symbols.getDeclarations(symbol); + for (const declaration of declarations) { + if (await symbols.isSourceFileFromLibrary(declaration.getSourceFile())) { + result.setCanRename(protocol.RenameKind.no, 'The symbol is declared in a library file'); + return; + } + } + if (declarations.length === 1 && (Symbols.isBlockScopedVariable(symbol) || Symbols.isFunctionScopedVariable(symbol))) { + const inScope = await symbols.getSymbolsInScope(declarations[0], SymbolFlags.BlockScopedVariable | SymbolFlags.FunctionScopedVariable); + for (const inScopeSymbol of inScope) { + if (inScopeSymbol.id !== symbol.id && inScopeSymbol.name === symbol.name) { + result.setCanRename(protocol.RenameKind.no, `A variable with the name '${oldName}' already exists in the same scope`); + return; + } + } + } else if (declarations.length > 1) { + if (Symbols.isFunction(symbol)) { + await new DeclarationChecker(result, symbols, symbol).checkDeclarations(); + if (result.getCanRename() === protocol.RenameKind.no) { + return; + } + } else if (!Symbols.isMethod(symbol) || parent === undefined) { + result.setCanRename(protocol.RenameKind.no, 'The symbol has multiple declarations'); + return; + } else if (Symbols.isInterface(parent) || Symbols.isTypeLiteral(parent) || Symbols.isClass(parent)) { + await new DeclarationChecker(result, symbols, symbol).checkDeclarations(); + if (result.getCanRename() === protocol.RenameKind.no) { + return; + } + } + } + + const escapedNewName = escapeLeadingUnderscores(newName); + if (parent !== undefined) { + if ((await parent.getMembers()).has(escapedNewName)) { + result.setCanRename(protocol.RenameKind.no, `A member with the name '${newName}' already exists on '${parent.name}'`); + return; + } + if ((await parent.getExports()).has(escapedNewName)) { + result.setCanRename(protocol.RenameKind.no, `An export with the name '${newName}' already exists on module '${parent.name}'`); + return; + } + if (Symbols.isClass(parent) || Symbols.isInterface(parent)) { + for (const superType of await symbols.getAllSuperTypes(parent)) { + if ((await superType.getMembers()).has(escapedNewName)) { + result.setCanRename(protocol.RenameKind.no, `A member with the name '${newName}' already exists on base type '${superType.name}'`); + return; + } + token.throwIfCancellationRequested(); + } + result.setCanRename(protocol.RenameKind.yes, oldName); + return; + } else if (Symbols.isEnum(parent)) { + result.setCanRename(protocol.RenameKind.yes, oldName); + return; + } + } + token.throwIfCancellationRequested(); + if (declarations.length === 0) { + result.setCanRename(protocol.RenameKind.no, 'The symbol has no declarations'); + return; + } + if (await hasSameSymbolOnDeclarationSide(symbols, declarations, newName)) { + result.setCanRename(protocol.RenameKind.no, `A symbol with the name '${newName}' already exists in the scope`); + } else { + result.setCanRename(protocol.RenameKind.yes, oldName); + } +} + +async function hasSameSymbolOnDeclarationSide(symbols: Symbols, declarations: readonly Node[], newName: string): Promise { + let inModule: boolean | undefined; + for (const declaration of declarations) { + const inScope = await symbols.getTypeChecker().resolveName(newName, renameSymbolFlags, declaration, false); + if (inScope !== undefined) { + inModule ??= await isInModule(symbols, declarations); + if (!inModule) { + return true; + } + const block = getParentBlock(declaration); + if (block === undefined || await isInSameBlockScopeDeclared(symbols, inScope, block)) { + return true; + } + } + } + return false; +} + +async function isInModule(symbols: Symbols, declarations: readonly Node[]): Promise { + for (const declaration of declarations) { + // if (await symbols.getTypeChecker().getSymbolOfSourceFile(declaration.getSourceFile().fileName) === undefined) { + if (await symbols.getLeafSymbolAtLocation(declaration.getSourceFile()) === undefined) { + return false; + } + } + return true; +} + +async function isInSameBlockScopeDeclared(symbols: Symbols, symbol: NativeSymbol, block: Node): Promise { + for (const declaration of await symbols.getDeclarations(symbol)) { + if (getParentBlock(declaration) === block) { + return true; + } + } + return false; +} + +function getParentBlock(node: Node): Node | undefined { + let current: Node | undefined = node; + while (current !== undefined) { + if (isBlock(current) || isModuleBlock(current) || isSourceFile(current)) { + return current; + } + current = current.parent; + } + return undefined; +} diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/nullContextProvider.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/nullContextProvider.ts new file mode 100644 index 00000000000000..661770a243aaa9 --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/nullContextProvider.ts @@ -0,0 +1,13 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { Project } from '@typescript/native/unstable/async'; +import { ContextProvider, type ComputeContextSession, type ContextRunnableCollector, type RequestContext } from './contextProvider'; +import type { CancellationTokenWithTimer } from './typescripts'; + +export class NullContextProvider extends ContextProvider { + public override async provide(_result: ContextRunnableCollector, _session: ComputeContextSession, _project: Project, _context: RequestContext, _token: CancellationTokenWithTimer): Promise { + } +} diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/sourceFileContextProvider.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/sourceFileContextProvider.ts new file mode 100644 index 00000000000000..05e297833c6674 --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/sourceFileContextProvider.ts @@ -0,0 +1,79 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { SymbolFlags, type Project, type Symbol as NativeSymbol } from '@typescript/native/unstable/async'; +import type { SourceFile } from '@typescript/native/unstable/ast'; +import { ImportsRunnable, TypeOfExpressionRunnable, TypeOfLocalsRunnable, TypesOfNeighborFilesRunnable } from './baseContextProviders'; +import { AbstractContextRunnable, ComputeCost, ContextProvider, SnippetLocation, type ComputeContextSession, type ContextResult, type ContextRunnableCollector, type ProviderComputeContext, type RequestContext, type RunnableResult } from './contextProvider'; +import * as protocol from '../../common/serverProtocol'; +import tss, { type CancellationTokenWithTimer } from './typescripts'; + +export class GlobalsRunnable extends AbstractContextRunnable { + private readonly tokenInfo: tss.TokenInfo; + + constructor(session: ComputeContextSession, project: Project, context: RequestContext, tokenInfo: tss.TokenInfo) { + super(session, project, context, 'GlobalsRunnable', SnippetLocation.Secondary, protocol.Priorities.Globals, ComputeCost.Medium); + this.tokenInfo = tokenInfo; + } + + public override getActiveSourceFile(): SourceFile { + return this.tokenInfo.token.getSourceFile(); + } + + protected override createRunnableResult(result: ContextResult): RunnableResult { + return result.createRunnableResult(this.id, this.priority, protocol.SpeculativeKind.emit, { emitMode: protocol.EmitMode.ClientBased, scope: { kind: protocol.CacheScopeKind.File } }); + } + + protected override async run(_result: RunnableResult, token: CancellationTokenWithTimer): Promise { + for (const symbol of await this.getSymbolsInScope()) { + token.throwIfCancellationRequested(); + if (!await this.handleSymbol(symbol, undefined, true)) { + break; + } + } + } + + protected async getSymbolsInScope(): Promise { + const result: NativeSymbol[] = []; + const symbols = await this.symbols.getSymbolsInScope(this.getActiveSourceFile(), SymbolFlags.Function | SymbolFlags.Class | SymbolFlags.Interface | SymbolFlags.TypeAlias | SymbolFlags.ValueModule); + for (const symbol of symbols) { + if (await this.skipSymbolBasedOnDeclaration(symbol)) { + continue; + } + result.push(await this.symbols.getLeafSymbol(symbol)); + } + return result; + } +} + +export class SourceFileContextProvider extends ContextProvider { + private readonly tokenInfo: tss.TokenInfo; + private readonly computeInfo: ProviderComputeContext; + + public override readonly isCallableProvider: boolean = true; + + constructor(tokenInfo: tss.TokenInfo, computeInfo: ProviderComputeContext) { + super(); + this.tokenInfo = tokenInfo; + this.computeInfo = computeInfo; + } + + public override async provide(result: ContextRunnableCollector, session: ComputeContextSession, project: Project, context: RequestContext, token: CancellationTokenWithTimer): Promise { + token.throwIfCancellationRequested(); + result.addSecondary(new GlobalsRunnable(session, project, context, this.tokenInfo)); + if (!this.computeInfo.isFirstCallableProvider(this)) { + return; + } + result.addPrimary(new TypeOfLocalsRunnable(session, project, context, this.tokenInfo, new Set(), undefined)); + const expression = TypeOfExpressionRunnable.create(session, project, context, this.tokenInfo, token); + if (expression !== undefined) { + result.addPrimary(expression); + } + result.addSecondary(new ImportsRunnable(session, project, context, this.tokenInfo, new Set())); + if (context.neighborFiles.length > 0) { + result.addTertiary(new TypesOfNeighborFilesRunnable(session, project, context, this.tokenInfo)); + } + } +} diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/test/nesRename.spec.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/test/nesRename.spec.ts new file mode 100644 index 00000000000000..c35bf75b91aead --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/test/nesRename.spec.ts @@ -0,0 +1,160 @@ +/*--------------------------------------------------------------------------------------------- + * 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 'node:assert'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { API, type Project, type Snapshot } from '@typescript/native/unstable/async'; +import type { SourceFile } from '@typescript/native/unstable/ast'; +import type * as vscode from 'vscode'; +import { afterAll, beforeAll, suite, test } from 'vitest'; +import { z } from 'zod'; +import * as protocol from '../../../common/serverProtocol'; +import { nesRename, prepareNesRename } from '../api'; +import { PrepareNesRenameResult } from '../nesRenameValidator'; +import { CancellationTokenWithTimer } from '../typescripts'; + +const fixtures = path.join(__dirname, '../../../serverPlugin/fixtures/nes'); +const cancellationToken: vscode.CancellationToken = { + isCancellationRequested: false, + onCancellationRequested: () => ({ dispose() { } }), +}; + +const TestAnnotationSchema = z.object({ + title: z.string(), + oldName: z.string(), + newName: z.string(), + expected: z.string(), + delta: z.number().optional(), +}); + +suite.skip('TypeScript 7 NES rename engine', () => { + let api: API; + + beforeAll(() => { + api = new API({ cwd: process.cwd() }); + }); + + afterAll(async () => { + await api.close(); + }); + + test('matches prepare rename fixture expectations', async () => { + const state = await openProject(api, 'p1'); + try { + const actual: { title: string; expected: protocol.RenameKind; result: protocol.PrepareNesRenameResult }[] = []; + const expression = /\/\/\/\/\s(\{.*\})/g; + let match: RegExpExecArray | null; + while ((match = expression.exec(state.sourceFile.text)) !== null) { + const parsed = TestAnnotationSchema.safeParse(JSON.parse(match[1])); + if (!parsed.success) { + continue; + } + const annotationPosition = state.sourceFile.getLineAndCharacterOfPosition(match.index); + const position = state.sourceFile.getPositionOfLineAndCharacter(annotationPosition.line + 1, annotationPosition.character + (parsed.data.delta ?? 0)); + const result = new PrepareNesRenameResult(); + await prepareNesRename(result, api, state.snapshot, state.project, state.sourceFile, position, parsed.data.oldName, parsed.data.newName, undefined, createToken()); + actual.push({ title: parsed.data.title, expected: protocol.RenameKind.fromString(parsed.data.expected), result: result.toJsonResponse() }); + } + assert.deepStrictEqual(actual.filter(item => item.result.canRename !== item.expected), []); + } finally { + await state.snapshot.dispose(); + } + }); + + test('prepares and computes edits on the old state', async () => { + const state = await openProject(api, 'p2'); + try { + const declarationStart = state.sourceFile.text.indexOf('bar2', state.sourceFile.text.indexOf('const bar2')); + const declarationEnd = declarationStart + 'bar2'.length; + const firstReference = state.sourceFile.text.indexOf('bar);'); + const secondReference = state.sourceFile.text.indexOf('bar);', firstReference + 1); + const lastSymbolRename: protocol.Range = { + start: toPosition(state.sourceFile, declarationStart), + end: toPosition(state.sourceFile, declarationEnd), + }; + const result = new PrepareNesRenameResult(); + await prepareNesRename(result, api, state.snapshot, state.project, state.sourceFile, firstReference, 'bar', 'bar2', lastSymbolRename, createToken()); + const groups = await nesRename(api, state.snapshot, state.project, state.sourceFile, firstReference, 'bar', 'bar2', lastSymbolRename, createToken()); + + assert.deepStrictEqual({ prepare: result.toJsonResponse(), groups }, { + prepare: { canRename: protocol.RenameKind.yes, oldName: 'bar', onOldState: true }, + groups: [{ + file: state.sourceFile.fileName, + changes: [firstReference, secondReference].map(start => ({ + range: { + start: toPosition(state.sourceFile, start), + end: toPosition(state.sourceFile, start + 'bar'.length), + }, + })), + }], + }); + } finally { + await state.snapshot.dispose(); + } + }); + + test('rejects renames of default library symbols', async () => { + // const state = await openProject(api, 'p2'); + // try { + // const oldName = 'log'; + // const newName = 'collect'; + // const firstReference = state.sourceFile.text.indexOf(`.${oldName}`) + 1; + // const secondReference = state.sourceFile.text.indexOf(`.${oldName}`, firstReference + oldName.length) + 1; + // const delta = newName.length - oldName.length; + // const lastSymbolRename: protocol.Range = { + // start: toPosition(state.sourceFile, firstReference), + // end: toPosition(state.sourceFile, firstReference + newName.length), + // }; + // const updatedText = state.sourceFile.text.substring(0, firstReference) + newName + state.sourceFile.text.substring(firstReference + oldName.length); + // const result = new PrepareNesRenameResult(); + // await prepareNesRename(result, api, state.snapshot, state.project, state.sourceFile, secondReference, oldName, newName, undefined, createToken()); + + // let groups: protocol.RenameGroup[] = []; + // await api.runWithTemporaryFileUpdate(state.snapshot, state.sourceFile.fileName, updatedText, async updatedSnapshot => { + // const updatedProject = updatedSnapshot.getProject(state.project.configFileName) ?? await updatedSnapshot.getDefaultProjectForFile(state.sourceFile.fileName); + // const updatedSourceFile = await updatedProject?.program.getSourceFile(state.sourceFile.fileName); + // assert.ok(updatedProject !== undefined && updatedSourceFile !== undefined); + // groups = await nesRename(api, updatedSnapshot, updatedProject, updatedSourceFile, secondReference + delta, oldName, newName, lastSymbolRename, createToken()); + // }); + + // assert.deepStrictEqual({ prepare: result.toJsonResponse(), groups }, { + // prepare: { canRename: protocol.RenameKind.no, timedOut: false, reason: 'The symbol is declared in a library file' }, + // groups: [], + // }); + // } finally { + // await state.snapshot.dispose(); + // } + }); +}); + +type ProjectState = { + readonly snapshot: Snapshot; + readonly project: Project; + readonly sourceFile: SourceFile; +}; + +async function openProject(api: API, projectName: string): Promise { + const projectDirectory = path.join(fixtures, projectName); + const configFile = path.join(projectDirectory, 'tsconfig.json'); + const fileName = path.join(projectDirectory, 'source/test.ts'); + assert.ok(fs.existsSync(fileName)); + const snapshot = await api.updateSnapshot({ openProjects: [configFile] }); + const project = snapshot.getProject(configFile) ?? await snapshot.getDefaultProjectForFile(fileName); + assert.ok(project !== undefined, `No project for ${fileName}`); + const sourceFile = await project.program.getSourceFile(fileName); + assert.ok(sourceFile !== undefined, `No source file for ${fileName}`); + return { snapshot, project, sourceFile }; +} + +function createToken(): CancellationTokenWithTimer { + return new CancellationTokenWithTimer(cancellationToken, Date.now(), 30_000); +} + +function toPosition(sourceFile: SourceFile, position: number): protocol.Position { + const result = sourceFile.getLineAndCharacterOfPosition(position); + return { line: result.line, character: result.character }; +} diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/test/simple.spec.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/test/simple.spec.ts new file mode 100644 index 00000000000000..3baac1a5009c46 --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/test/simple.spec.ts @@ -0,0 +1,166 @@ +/*--------------------------------------------------------------------------------------------- + * 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 'node:assert'; +import path from 'node:path'; + +import { API } from '@typescript/native/unstable/async'; +import { version } from '@typescript/native'; +import type * as vscode from 'vscode'; +import { afterAll, beforeAll, suite, test } from 'vitest'; +import * as protocol from '../../../common/serverProtocol'; +import { computeContext } from '../api'; +import { CharacterBudget, ComputeContextSession, ContextResult, RequestContext } from '../contextProvider'; +import { CancellationTokenWithTimer } from '../typescripts'; + +const fixtures = path.join(__dirname, '../../../serverPlugin/fixtures/context'); +const cancellationToken: vscode.CancellationToken = { + isCancellationRequested: false, + onCancellationRequested: () => ({ dispose() { } }), +}; + +suite('TypeScript 7 context engine', () => { + let api: API; + + beforeAll(() => { + api = new API({ cwd: process.cwd() }); + }); + + afterAll(async () => { + await api.close(); + }); + + test('computes compiler option traits', async () => { + const items = await compute('p1', 'source/f1.ts', 0, 0); + const traits = items.filter(item => item.kind === protocol.ContextKind.Trait).map(item => [item.name, item.value]); + assert.deepStrictEqual(traits, [ + ['The TypeScript version used in this project is ', version], + ['The TypeScript module system used in this project is ', 'Node16'], + ['The TypeScript module resolution strategy used in this project is ', 'Node16'], + ['The target version of JavaScript for this project is ', 'ES2022'], + ['Library files that should be included in TypeScript compilation are ', 'lib.es2022.d.ts,lib.dom.d.ts'], + ]); + }); + + test.skip('computes imported and local types', async () => { + const imported = await compute('p12', 'source/f2.ts', 3, 0); + const local = await compute('p12', 'source/f3.ts', 4, 0, 'TypeOfLocalsRunnable'); + const expected = normalize('declare class Person { constructor(age: number = 10); public getAlter(): number; }'); + assert.deepStrictEqual({ + imported: snippets(imported).includes(expected), + local: snippets(local).includes(expected), + }, { imported: true, local: true }); + }); + + test('computes function signature types', async () => { + const items = await compute('p7', 'source/f2.ts', 6, 0); + const values = snippets(items); + assert.deepStrictEqual([ + 'declare class Foo { public foo(): void; }', + 'interface Bar { bar(): void; }', + 'enum Enum { a = 1, b = 2 }', + 'const enum CEnum { a = 1, b = 2 }', + 'type Baz = { baz(): void; bazz: () => number; }', + ].map(value => values.includes(normalize(value))), [true, true, true, true, true]); + }); + + test('computes inherited and property types', async () => { + const inherited = await compute('p2', 'source/f2.ts', 5, 0); + const properties = await compute('p13', 'source/f2.ts', 15, 0); + assert.deepStrictEqual({ + inherited: snippets(inherited).includes(normalize('declare class B { /** * The distance between two points. */ protected distance: number; /** * The length of the line. */ protected _length: number; /** * Returns the occurrence of \'foo\'. * * @returns the occurrence of \'foo\'. */ public foo(): number; }')), + age: snippets(properties).includes(normalize('type Age = { value: number; }')), + street: snippets(properties).includes(normalize('declare class Street { constructor(name: string); public getName(); }')), + }, { inherited: true, age: true, street: true }); + }); + + test('computes expression types', async () => { + const calculator = await compute('p14', 'source/f3.ts', 4, 22); + const result = await compute('p14', 'source/f4.ts', 4, 25); + assert.deepStrictEqual({ + calculator: snippets(calculator).includes(normalize('declare class Calculator { constructor(initial: number = 0); public add(x: number): Calculator; public getResult(): Result; }')), + result: snippets(result).includes(normalize('interface Result { value: number; message: string; }')), + }, { calculator: true, result: true }); + }); + + test('computes class, method, and constructor blueprints', async () => { + const classItems = snippets(await compute('p1', 'source/f3.ts', 3, 0)); + const methodItems = snippets(await compute('p5', 'source/f3.ts', 4, 0)); + const constructorItems = snippets(await compute('p8', 'source/f3.ts', 5, 0)); + assert.deepStrictEqual({ + class: classItems.includes(normalize('export class X implements Name, NameLength { name() { return \'x\'; } length() { return \'x\'.length; } }')), + method: methodItems.includes(normalize('/** * Javadoc */ export class Bar extends Foo { private name(): string { return \'Bar\'; } }')), + constructor: constructorItems.includes(normalize('/** * Javadoc */ export class Bar extends Foo { private name: string; constructor() { super(); this.name = \'Bar\'; } }')), + }, { class: true, method: true, constructor: true }); + }); + + async function compute(projectName: string, relativeFile: string, line: number, character: number, runnableId?: protocol.ContextRunnableResultId): Promise { + const projectDirectory = path.join(fixtures, projectName); + const configFile = path.join(projectDirectory, 'tsconfig.json'); + const fileName = path.join(projectDirectory, relativeFile); + const snapshot = await api.updateSnapshot({ openProjects: [configFile] }); + try { + const project = snapshot.getProject(configFile) ?? await snapshot.getDefaultProjectForFile(fileName); + assert.ok(project !== undefined, `No project for ${fileName}`); + const sourceFile = await project.program.getSourceFile(fileName); + assert.ok(sourceFile !== undefined, `No source file for ${fileName}`); + const startTime = Date.now(); + const token = new CancellationTokenWithTimer(cancellationToken, startTime, 30_000); + const session = new TestComputeContextSession(project, token); + const context = new RequestContext(session, [], new Map(), true); + const result = new ContextResult(new CharacterBudget(7 * 1024 * 4), new CharacterBudget(8 * 1024 * 4), context); + const position = sourceFile.getPositionOfLineAndCharacter(line, character); + await computeContext(result, session, project, sourceFile, position, token); + return resolveItems(result.toJson(), runnableId); + } finally { + await snapshot.dispose(); + } + } +}); + +class TestComputeContextSession extends ComputeContextSession { + public override enableBlueprintSearch(): boolean { + return true; + } +} + +function resolveItems(response: protocol.ComputeContextResponse.OK, runnableId?: protocol.ContextRunnableResultId): protocol.FullContextItem[] { + const itemMap = new Map(); + for (const item of response.contextItems ?? []) { + if (item.kind !== protocol.ContextKind.Reference && protocol.ContextItem.hasKey(item)) { + itemMap.set(item.key, item); + } + } + const result: protocol.FullContextItem[] = []; + const seen = new Set(); + for (const runnable of response.runnableResults ?? []) { + if (runnable.kind !== protocol.ContextRunnableResultKind.ComputedResult || (runnableId !== undefined && runnable.id !== runnableId)) { + continue; + } + for (const item of runnable.items) { + if (item.kind === protocol.ContextKind.Reference) { + if (seen.has(item.key)) { + continue; + } + const referenced = itemMap.get(item.key); + if (referenced !== undefined) { + seen.add(item.key); + result.push(referenced); + } + } else { + result.push(item); + } + } + } + return result; +} + +function snippets(items: readonly protocol.FullContextItem[]): string[] { + return items.filter(item => item.kind === protocol.ContextKind.Snippet).map(item => normalize(item.value)); +} + +function normalize(value: string): string { + return value.trim().replace(/\r?\n/g, ' ').replace(/\t+/g, ' ').replace(/\s+/g, ' '); +} diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/ts7Api.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/ts7Api.ts new file mode 100644 index 00000000000000..6e609c6d3bcc71 --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/ts7Api.ts @@ -0,0 +1,164 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { API } from '@typescript/native/unstable/async'; +import * as vscode from 'vscode'; +import { ILogService } from '../../../../platform/log/common/logService'; +import { DisposableStore } from '../../../../util/vs/base/common/lifecycle'; +import { TypeScript } from '../tsService'; + +interface TypeScript7ExtensionApi { + onLanguageServerInitialized: vscode.Event; + initializeAPIConnection(pipePath?: string): Promise; +} + +export class TypeScript7Api implements vscode.Disposable { + private static connection: TypeScript7Connection | undefined; + private static refCount: number = 0; + + private readonly connection: TypeScript7Connection; + private disposed: boolean = false; + + public readonly onDidReconnect: vscode.Event; + + constructor(logService: ILogService) { + TypeScript7Api.connection ??= new TypeScript7Connection(logService); + TypeScript7Api.refCount++; + this.connection = TypeScript7Api.connection; + this.onDidReconnect = this.connection.onDidReconnect; + } + + public getApi(): Promise | undefined> { + return this.connection.getApi(); + } + + public dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + if (--TypeScript7Api.refCount === 0) { + TypeScript7Api.connection = undefined; + this.connection.dispose(); + } + } +} + +/** + * The actual connection to the TypeScript 7 language server. Shared by all {@link TypeScript7Api} handles + * so that we only ever open one pipe to the server. + */ +class TypeScript7Connection implements vscode.Disposable { + + private static readonly maxConnectAttempts: number = 3; + + private readonly disposables = new DisposableStore(); + private readonly onDidReconnectEmitter = this.disposables.add(new vscode.EventEmitter()); + + public readonly onDidReconnect = this.onDidReconnectEmitter.event; + + private api: API | undefined; + private apiPromise: Promise | undefined> | undefined; + private extensionApi: TypeScript7ExtensionApi | undefined; + private generation: number = 0; + private disposed: boolean = false; + + constructor(private readonly logService: ILogService) { } + + public getApi(): Promise | undefined> { + if (this.api !== undefined) { + return Promise.resolve(this.api); + } + if (this.disposed) { + return Promise.resolve(undefined); + } + if (this.apiPromise === undefined) { + const promise = this.createApi(); + this.apiPromise = promise; + void promise.then(() => { + if (this.apiPromise === promise) { + this.apiPromise = undefined; + } + }); + } + return this.apiPromise; + } + + public dispose(): void { + this.disposed = true; + this.resetApi(); + this.disposables.dispose(); + } + + private async createApi(): Promise | undefined> { + try { + const extensionApi = await this.getExtensionApi(); + if (extensionApi === undefined) { + return undefined; + } + for (let attempt = 0; attempt < TypeScript7Connection.maxConnectAttempts; attempt++) { + const generation = this.generation; + const pipe = await extensionApi.initializeAPIConnection(); + const api = await API.fromLSPConnection({ pipe }); + if (this.disposed) { + this.close(api); + return undefined; + } + if (this.generation === generation) { + this.api = api; + return api; + } + // The language server (re)initialized while we were connecting, so this pipe is already stale. + this.close(api); + } + return undefined; + } catch (error) { + this.logService.error(error, 'Error connecting to the TypeScript 7 API'); + return undefined; + } + } + + private async getExtensionApi(): Promise { + if (this.extensionApi !== undefined) { + return this.extensionApi; + } + const extension = TypeScript.getVersion7Extension(); + if (extension === undefined) { + return undefined; + } + const extensionApi = await extension.activate(); + if (this.disposed) { + return undefined; + } + if (this.extensionApi === undefined) { + this.extensionApi = extensionApi; + this.disposables.add(extensionApi.onLanguageServerInitialized(() => this.reconnect())); + } + return this.extensionApi; + } + + private reconnect(): void { + // The initial initialization arrives while we are still connecting. Bump the generation so that + // connect picks up the new pipe, but only tell consumers when an established connection went away. + const hadApi = this.api !== undefined; + this.resetApi(); + if (hadApi) { + this.onDidReconnectEmitter.fire(); + } + } + + private resetApi(): void { + this.generation++; + const api = this.api; + this.api = undefined; + if (api !== undefined) { + this.close(api); + } + } + + private close(api: API): void { + api.close().catch(error => this.logService.error(error, 'Error closing stale TypeScript 7 API connection')); + } +} diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/tsContextService.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/tsContextService.ts new file mode 100644 index 00000000000000..724d5524dbcf7a --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/tsContextService.ts @@ -0,0 +1,436 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import * as inspector from 'inspector'; + +import type { API } from '@typescript/native/unstable/async'; + +import { IConfigurationService } from '../../../../platform/configuration/common/configurationService'; +import { type ContextItem, type RequestContext, KnownSources } from '../../../../platform/languageServer/common/languageContextService'; +import { ILogService } from '../../../../platform/log/common/logService'; +import { IExperimentationService } from '../../../../platform/telemetry/common/nullExperimentationService'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry'; +import * as protocol from '../../common/serverProtocol'; +import { ContextItemResultBuilder, ResolvedRunnableResult } from '../types'; +import { AbstractTSLanguageContextService, currentTokenBudget } from '../tsContextService'; +import { computeContext as computeServerContext } from './api'; +import { CharacterBudget, ComputeContextSession, ContextResult, RequestContext as ServerRequestContext, TokenBudgetExhaustedError } from './contextProvider'; +import { CancellationTokenWithTimer, OperationCanceledException } from './typescripts'; +import { TypeScript7Api } from './ts7Api'; + +class PendingRequestInfo { + public readonly document: string; + public readonly version: number; + public readonly position: vscode.Position; + public readonly context: RequestContext; + + constructor(document: vscode.TextDocument, position: vscode.Position, context: RequestContext) { + this.document = document.uri.toString(); + this.version = document.version; + this.position = position; + this.context = context; + } +} + +type ComputeContextResult = + | { readonly kind: 'ok'; readonly body: protocol.ComputeContextResponse.OK } + | { readonly kind: 'cancelled' } + | { readonly kind: 'unavailable' } + | { readonly kind: 'failed'; readonly error: protocol.CustomResponse.Failed }; + +namespace ComputeContextResult { + export const cancelled: ComputeContextResult = { kind: 'cancelled' }; + export const unavailable: ComputeContextResult = { kind: 'unavailable' }; + + export function ok(body: protocol.ComputeContextResponse.OK): ComputeContextResult { + return { kind: 'ok', body }; + } + + export function toErrorData(error: unknown): protocol.CustomResponse.Failed { + return error instanceof Error + ? { error: protocol.ErrorCode.exception, message: error.message, stack: error.stack } + : { error: protocol.ErrorCode.exception, message: 'Unknown error' }; + } + + export function failed(error: unknown): ComputeContextResult { + return { kind: 'failed', error: toErrorData(error) }; + } +} + +class InflightRequestInfo { + public readonly document: string; + public readonly position: vscode.Position; + public readonly requestId: string; + public readonly source: KnownSources | string; + public readonly serverPromise: Promise; + + private readonly tokenSource: vscode.CancellationTokenSource; + + constructor(document: vscode.TextDocument, position: vscode.Position, context: RequestContext, tokenSource: vscode.CancellationTokenSource, serverPromise: Promise) { + this.document = document.uri.toString(); + this.position = position; + this.requestId = context.requestId; + this.source = context.source ?? KnownSources.unknown; + this.tokenSource = tokenSource; + this.serverPromise = serverPromise; + } + + public matches(document: vscode.TextDocument, position: vscode.Position): boolean { + return this.document === document.uri.toString() && this.position.isEqual(position); + } + + public matchesDocument(document: vscode.TextDocument): boolean { + return this.document === document.uri.toString(); + } + + public cancel(): void { + this.tokenSource.cancel(); + } +} + +class OnTimeoutData { + private readonly document: string; + private readonly version: number; + private readonly position: vscode.Position; + + public readonly runnableResults: ResolvedRunnableResult[] = []; + public resultBuilder: ContextItemResultBuilder | undefined; + + constructor(document: vscode.TextDocument, position: vscode.Position) { + this.document = document.uri.toString(); + this.version = document.version; + this.position = position; + } + + public addRunnableResults(results: readonly ResolvedRunnableResult[]): void { + this.runnableResults.push(...results); + } + + public matches(document: vscode.TextDocument, position: vscode.Position): boolean { + return this.document === document.uri.toString() && this.version === document.version && this.position.isEqual(position); + } +} + +export class TS7LanguageContextService extends AbstractTSLanguageContextService { + private static readonly defaultCachePopulationRaceTimeout: number = 20; + + private readonly nativeApi: TypeScript7Api; + private readonly isDebugging: boolean; + private pendingRequest: PendingRequestInfo | undefined; + private inflightCachePopulationRequest: InflightRequestInfo | undefined; + private onTimeoutData: OnTimeoutData | undefined; + + constructor( + telemetryService: ITelemetryService, + configurationService: IConfigurationService, + experimentationService: IExperimentationService, + logService: ILogService + ) { + super(telemetryService, logService, configurationService, experimentationService); + this.isDebugging = inspector?.url() !== undefined; + this.nativeApi = this.disposables.add(new TypeScript7Api(logService)); + this.disposables.add(this.nativeApi.onDidReconnect(() => this.reconnect())); + } + + public override dispose(): void { + this.inflightCachePopulationRequest?.cancel(); + this.inflightCachePopulationRequest = undefined; + super.dispose(); + } + + async isActivated(documentOrLanguageId: vscode.TextDocument | string): Promise { + const languageId = typeof documentOrLanguageId === 'string' ? documentOrLanguageId : documentOrLanguageId.languageId; + if (languageId !== 'typescript' && languageId !== 'typescriptreact') { + return false; + } + return await this.getApi() !== undefined; + } + + async populateCache(document: vscode.TextDocument, position: vscode.Position, context: RequestContext): Promise { + if (document.languageId !== 'typescript' && document.languageId !== 'typescriptreact') { + return; + } + if (this.inflightCachePopulationRequest !== undefined) { + if (!this.inflightCachePopulationRequest.matches(document, position)) { + this.pendingRequest = new PendingRequestInfo(document, position, context); + } + return; + } + const startTime = Date.now(); + const contextRequestState = this.runnableResultManager.getContextRequestState(document, position); + if (contextRequestState !== undefined && contextRequestState.server.length === 0) { + return; + } + const neighborFiles = this.neighborFileModel.getNeighborFiles(document); + const timeBudget = this.cachePopulationTimeout; + try { + const isDebugging = this.isDebugging; + const forDebugging: ContextItem[] | undefined = isDebugging ? [] : undefined; + const tokenSource = new vscode.CancellationTokenSource(); + const token = tokenSource.token; + const documentVersion = document.version; + const cacheState = this.runnableResultManager.getCacheState(); + let result: ComputeContextResult; + const promise = this.computeContext(document, position, context, startTime, timeBudget, neighborFiles, contextRequestState?.server, token); + const inflightRequest = new InflightRequestInfo(document, position, context, tokenSource, promise); + this.inflightCachePopulationRequest = inflightRequest; + try { + result = await promise; + } finally { + if (this.inflightCachePopulationRequest === inflightRequest) { + this.inflightCachePopulationRequest = undefined; + } + tokenSource.dispose(); + } + if (result.kind === 'unavailable') { + return; + } + const timeTaken = Date.now() - startTime; + if (result.kind === 'cancelled') { + this.telemetrySender.sendRequestCancelledTelemetry(context, timeTaken); + } else if (result.kind === 'failed') { + this.telemetrySender.sendRequestFailureTelemetry(context, result.error); + this.logService.error(`Error computing TypeScript 7 context for document: ${document.uri.toString()} at position: ${position.line + 1}:${position.character + 1}`, result.error.stack ?? result.error.message); + } else { + const body = result.body; + const contextItemResult = new ContextItemResultBuilder(timeTaken); + const { resolved, cached, referenced, serverComputed } = this.runnableResultManager.update(document, documentVersion, position, context, body, contextRequestState); + contextItemResult.cachedItems += cached; + contextItemResult.referencedItems += referenced; + contextItemResult.serverComputed = serverComputed; + for (const runnableResult of resolved) { + for (const converted of contextItemResult.update(runnableResult)) { + forDebugging?.push(converted.item); + } + } + contextItemResult.updateResponse(body, token); + this.telemetrySender.sendRequestTelemetry(document, position, context, contextItemResult, timeTaken, { before: cacheState, after: this.runnableResultManager.getCacheState() }, undefined); + // eslint-disable-next-line local/code-no-unused-expressions + isDebugging && forDebugging?.length; + this._onCachePopulated.fire({ document, position, source: context.source, items: resolved, summary: contextItemResult }); + } + } catch (error) { + this.telemetrySender.sendRequestFailureTelemetry(context, ComputeContextResult.toErrorData(error)); + this.logService.error(error, `Error populating cache for document: ${document.uri.toString()} at position: ${position.line + 1}:${position.character + 1}`); + } finally { + this.runPendingRequest(); + } + } + + private async computeContext(document: vscode.TextDocument, position: vscode.Position, context: RequestContext, startTime: number, timeBudget: number, neighborFiles: readonly string[], clientSideRunnableResults: readonly protocol.CachedContextRunnableResult[] | undefined, token: vscode.CancellationToken): Promise { + try { + const api = await this.getApi(); + if (api === undefined) { + return ComputeContextResult.unavailable; + } + // Workaround for https://github.com/microsoft/typescript-go/issues/4916 + api.clearSourceFileCache(); + const snapshot = await api.updateSnapshot({ openFiles: [ { uri: document.uri.toString() } ] }); + try { + if (token.isCancellationRequested) { + return ComputeContextResult.cancelled; + } + const project = await snapshot.getDefaultProjectForFile({ uri: document.uri.toString() }); + if (project === undefined) { + return ComputeContextResult.cancelled; + } + const sourceFile = await project.program.getSourceFile({ uri: document.uri.toString() }); + if (sourceFile === undefined || sourceFile.text !== document.getText()) { + return ComputeContextResult.cancelled; + } + const cancellationToken = new CancellationTokenWithTimer(token, startTime, timeBudget, this.isDebugging); + const session = new ComputeContextSession(project, cancellationToken); + const cachedResults = clientSideRunnableResults ?? []; + const requestContext = new ServerRequestContext(session, neighborFiles, new Map(cachedResults.map(result => [result.id, result])), this.includeDocumentation); + const result = new ContextResult( + new CharacterBudget((context.tokenBudget ?? 7 * 1024) * 4), + new CharacterBudget(currentTokenBudget * 4), + requestContext, + ); + const computeStart = Date.now(); + try { + const offset = sourceFile.getPositionOfLineAndCharacter(position.line, position.character); + await computeServerContext(result, session, project, sourceFile, offset, cancellationToken); + } catch (error) { + if (error instanceof OperationCanceledException) { + if (token.isCancellationRequested) { + throw error; + } + } else if (!(error instanceof TokenBudgetExhaustedError)) { + throw error; + } + } + const endTime = Date.now(); + result.addTimings(endTime - startTime, endTime - computeStart); + result.setTimedOut(cancellationToken.isTimedOut()); + return ComputeContextResult.ok(result.toJson()); + } finally { + await snapshot.dispose(); + } + } catch (error) { + // Never reject: the same promise is raced by `getContext`. + return error instanceof OperationCanceledException ? ComputeContextResult.cancelled : ComputeContextResult.failed(error); + } + } + + private runPendingRequest(): void { + if (this.pendingRequest === undefined) { + return; + } + const pendingRequest = this.pendingRequest; + this.pendingRequest = undefined; + const document = vscode.window.activeTextEditor?.document; + if (document !== undefined && document.uri.toString() === pendingRequest.document && document.version === pendingRequest.version && document.validatePosition(pendingRequest.position).isEqual(pendingRequest.position)) { + this.populateCache(document, pendingRequest.position, pendingRequest.context).catch(() => { /* handled in populateCache */ }); + } + } + + public async *getContext(document: vscode.TextDocument, position: vscode.Position, context: RequestContext, token: vscode.CancellationToken): AsyncIterable { + this.onTimeoutData = undefined; + if (document.languageId !== 'typescript' && document.languageId !== 'typescriptreact') { + return; + } + + const startTime = Date.now(); + let cacheRequest = 'none'; + const cachePopulationRequestInflight = this.inflightCachePopulationRequest !== undefined && this.inflightCachePopulationRequest.matchesDocument(document); + if (cachePopulationRequestInflight) { + this.onTimeoutData = new OnTimeoutData(document, position); + } + if (token.isCancellationRequested) { + this.telemetrySender.sendRequestCancelledTelemetry(context, Date.now() - startTime); + return; + } + + const isDebugging = this.isDebugging; + const forDebugging: ContextItem[] | undefined = isDebugging ? [] : undefined; + const contextItemResult = new ContextItemResultBuilder(Date.now() - startTime); + if (this.onTimeoutData !== undefined) { + this.onTimeoutData.resultBuilder = contextItemResult; + } + const characterBudget = this.getCharacterBudget(context, document); + const itemsToYield: ContextItem[] = []; + const { mandatory, optional, onTimeout } = this.getRunnables(document, position, cachePopulationRequestInflight); + this.onTimeoutData?.addRunnableResults(onTimeout); + + outer: for (const runnableResult of mandatory) { + for (const { item, size } of contextItemResult.update(runnableResult, true)) { + forDebugging?.push(item); + characterBudget.spend(size); + if (characterBudget.isExhausted()) { + break outer; + } + itemsToYield.push(item); + } + } + if (!characterBudget.isOptionalExhausted()) { + outer: for (const runnableResult of optional) { + for (const { item, size } of contextItemResult.update(runnableResult, true)) { + forDebugging?.push(item); + characterBudget.spend(size); + if (characterBudget.isOptionalExhausted()) { + break outer; + } + itemsToYield.push(item); + } + } + } + + if (!token.isCancellationRequested) { + for (const item of itemsToYield) { + if (token.isCancellationRequested) { + this.onTimeoutData = undefined; + return; + } + yield item; + } + + const inflightRequest = this.inflightCachePopulationRequest; + if (inflightRequest !== undefined && inflightRequest.matchesDocument(document)) { + cacheRequest = 'inflight'; + const timeout = Math.max(0, Math.min(context.timeBudget ?? TS7LanguageContextService.defaultCachePopulationRaceTimeout, TS7LanguageContextService.defaultCachePopulationRaceTimeout)); + const response = await Promise.race([ + inflightRequest.serverPromise, + new Promise<'timedOut'>(resolve => setTimeout(() => resolve('timedOut'), timeout)), + ]); + if (response !== 'timedOut') { + if (this.onTimeoutData !== undefined) { + this.onTimeoutData = undefined; + for (const runnableResult of this.runnableResultManager.getCachedRunnableResults(document, position, protocol.EmitMode.ClientBasedOnTimeout)) { + for (const { item } of contextItemResult.update(runnableResult)) { + forDebugging?.push(item); + yield item; + } + } + cacheRequest = 'awaited'; + } + } + } + } else { + this.onTimeoutData = undefined; + } + + if (context.proposedEdits !== undefined) { + this.telemetrySender.sendSpeculativeRequestTelemetry(context, this.runnableResultManager.getRequestId() ?? 'unknown', contextItemResult.stats.yielded); + } else { + const cacheState = this.runnableResultManager.getCacheState(); + contextItemResult.path = this.runnableResultManager.getNodePath(); + contextItemResult.cancelled = token.isCancellationRequested; + contextItemResult.serverTime = 0; + contextItemResult.contextComputeTime = 0; + contextItemResult.fromCache = true; + this.telemetrySender.sendRequestTelemetry(document, position, context, contextItemResult, Date.now() - startTime, { before: cacheState, after: cacheState }, cacheRequest); + // eslint-disable-next-line local/code-no-unused-expressions + isDebugging && forDebugging?.length; + this._onContextComputed.fire({ document, position, source: context.source, items: itemsToYield, summary: contextItemResult }); + } + } + + private getRunnables(document: vscode.TextDocument, position: vscode.Position, cachePopulationInflight: boolean): { mandatory: readonly ResolvedRunnableResult[]; optional: readonly ResolvedRunnableResult[]; onTimeout: readonly ResolvedRunnableResult[] } { + const mandatory: ResolvedRunnableResult[] = []; + const optional: ResolvedRunnableResult[] = []; + const onTimeout: ResolvedRunnableResult[] = []; + for (const runnable of this.runnableResultManager.getCachedRunnableResults(document, position)) { + if (cachePopulationInflight && runnable.cache?.emitMode === protocol.EmitMode.ClientBasedOnTimeout) { + onTimeout.push(runnable); + } else if (runnable.priority === protocol.Priorities.Expression || runnable.priority === protocol.Priorities.Locals || runnable.priority === protocol.Priorities.Inherited || runnable.priority === protocol.Priorities.Traits) { + mandatory.push(runnable); + } else { + optional.push(runnable); + } + } + return { mandatory, optional, onTimeout }; + } + + public getContextOnTimeout(document: vscode.TextDocument, position: vscode.Position, context: RequestContext): readonly ContextItem[] | undefined { + try { + if (this.onTimeoutData === undefined || !this.onTimeoutData.matches(document, position) || this.onTimeoutData.resultBuilder === undefined) { + return []; + } + const result: ContextItem[] = []; + for (const runnableResult of this.onTimeoutData.runnableResults) { + for (const { item } of this.onTimeoutData.resultBuilder.update(runnableResult, true)) { + result.push(item); + } + } + return result; + } finally { + this.onTimeoutData = undefined; + } + } + + private async getApi(): Promise | undefined> { + return this.nativeApi.getApi(); + } + + private reconnect(): void { + this.inflightCachePopulationRequest?.cancel(); + this.inflightCachePopulationRequest = undefined; + this.pendingRequest = undefined; + this.onTimeoutData = undefined; + this.runnableResultManager.clear(); + } +} diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/types.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/types.ts new file mode 100644 index 00000000000000..6a89d9e55df58c --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/types.ts @@ -0,0 +1,68 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { Project, Symbol as NativeSymbol } from '@typescript/native/unstable/async'; +import type { Node, SourceFile } from '@typescript/native/unstable/ast'; +import type * as protocol from '../../common/serverProtocol'; +import type { Symbols } from './typescripts'; + +export interface SnippetProvider { + isEmpty(): boolean; + snippet(key: string | undefined): protocol.CodeSnippet; +} + +export type CodeCacheItem = { + value: string[]; + uri: string; + additionalUris?: Set; +}; + +export interface EmitterContext { + getCachedCode(key: string): CodeCacheItem | undefined; + cacheCode(key: string, code: CodeCacheItem): void; +} + +export abstract class ProgramContext { + protected async getSymbolInfo(symbol: NativeSymbol): Promise<{ skip: true } | { skip: false; primary: SourceFile; declarations: readonly Node[] }> { + const declarations = await this.getSymbols().getDeclarations(symbol); + if (declarations.length === 0) { + return { skip: true }; + } + let primary: SourceFile | undefined; + for (const declaration of declarations) { + const sourceFile = declaration.getSourceFile(); + primary ??= sourceFile; + if (await this.skipDeclaration(declaration, sourceFile)) { + return { skip: true }; + } + } + return primary === undefined ? { skip: true } : { skip: false, primary, declarations }; + } + + protected async skipDeclaration(_declaration: Node, sourceFile: SourceFile): Promise { + const metadata = await this.getProject().program.getSourceFileMetadataByPath(sourceFile.path); + return metadata?.isDefaultLibrary === true || metadata?.isFromExternalLibrary === true; + } + + protected abstract getProject(): Project; + protected abstract getSymbols(): Symbols; +} + +export class RecoverableError extends Error { + public static readonly SourceFileNotFound: number = 1; + public static readonly NodeNotFound: number = 2; + public static readonly NodeKindMismatch: number = 3; + public static readonly SymbolNotFound: number = 4; + public static readonly NoDeclaration: number = 5; + public static readonly NoProgram: number = 6; + public static readonly NoSourceFile: number = 7; + + public readonly code: number; + + constructor(message: string, code: number) { + super(message); + this.code = code; + } +} diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/typescripts.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/typescripts.ts new file mode 100644 index 00000000000000..b5b2908c5ebbee --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/typescripts.ts @@ -0,0 +1,488 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createHash } from 'node:crypto'; + +import { Symbol as NativeSymbol, SymbolFlags, type NodeHandle, type Program, type Project, type Type, type DocumentPosition } from '@typescript/native/unstable/async'; +import { + findPrecedingToken, + getTokenAtPosition, + isBlock, + isClassDeclaration, + isInterfaceDeclaration, + isModuleBlock, + isSourceFile, + isTypeAliasDeclaration, + isTypeReferenceNode, + SyntaxKind, + type Node, + type SourceFile, + type TypeNode, + type DeclarationBase +} from '@typescript/native/unstable/ast'; +import type * as vscode from 'vscode'; + +export class OperationCanceledException extends Error { + constructor() { + super('TypeScript 7 context request cancelled'); + } +} + +export class CancellationTokenWithTimer { + private readonly cancellationToken: vscode.CancellationToken; + private readonly end: number; + + constructor(cancellationToken: vscode.CancellationToken, startTime: number, budget: number, isDebugging: boolean = false) { + this.cancellationToken = cancellationToken; + this.end = isDebugging ? Number.MAX_VALUE : startTime + budget; + } + + public isCancellationRequested(): boolean { + return this.cancellationToken.isCancellationRequested || this.isTimedOut(); + } + + public isTimedOut(): boolean { + return Date.now() > this.end; + } + + public throwIfCancellationRequested(): void { + if (this.isCancellationRequested()) { + throw new OperationCanceledException(); + } + } +} + +namespace tss { + export type TokenInfo = { + token: Node; + touching?: Node; + previous?: Node; + }; + + export function getRelevantTokens(sourceFile: SourceFile, position: number): TokenInfo { + const token = getTokenAtPosition(sourceFile, position); + const result: TokenInfo = { token }; + if (token.kind === SyntaxKind.EndOfFile) { + result.previous = findPrecedingToken(sourceFile, position); + return result; + } + + const start = token.getStart(sourceFile); + if (position > start) { + result.touching = token; + } else if (position < start) { + let candidate: Node | undefined = token.parent; + while (candidate !== undefined) { + if (position >= candidate.getStart(sourceFile)) { + result.touching = candidate; + break; + } + candidate = candidate.parent; + } + } + result.previous = findPrecedingToken(sourceFile, position); + return result; + } + + export namespace Nodes { + export function getChildren(node: Node): readonly Node[] { + if (isSourceFile(node)) { + return node.statements; + } + const result: Node[] = []; + node.forEachChild(child => { + result.push(child); + return undefined; + }); + return result; + } + + export function getTypeName(node: TypeNode): string | undefined { + return isTypeReferenceNode(node) ? node.typeName.getText() : undefined; + } + + export function getParentOfKind(node: Node, kind: SyntaxKind): Node | undefined { + let current: Node | undefined = node; + while (current !== undefined) { + if (current.kind === kind) { + return current; + } + current = current.parent; + } + return undefined; + } + + export function getParentBlock(node: Node): Node | undefined { + let current: Node | undefined = node; + while (current !== undefined) { + if (isBlock(current) || isModuleBlock(current) || isSourceFile(current)) { + return current; + } + current = current.parent; + } + return undefined; + } + } + + export namespace StableSyntaxKinds { + export function getPath(node: Node): number[] { + const result: number[] = []; + let current: Node | undefined = node; + while (current !== undefined) { + result.push(current.kind); + if (isSourceFile(current)) { + break; + } + current = current.parent; + } + return result; + } + } +} + +export type TokenInfo = tss.TokenInfo; + +export type DirectSuperSymbolInfo = { + extends?: { symbol: NativeSymbol; name: string }; + implements?: { symbol: NativeSymbol; name: string }[]; +}; + +export type SymbolInfo = { + symbol: NativeSymbol; + primary: SourceFile; + declarations: readonly Node[]; +}; + +export class Symbols { + private readonly project: Project; + private readonly token: CancellationTokenWithTimer; + private readonly declarationCache: Map> = new Map(); + + constructor(project: Project, token: CancellationTokenWithTimer) { + this.project = project; + this.token = token; + } + + public getProject(): Project { + return this.project; + } + + public getProgram(): Program { + return this.project.program; + } + + public getTypeChecker(): Project['checker'] { + return this.project.checker; + } + + public async isSourceFileFromLibrary(sourceFile: SourceFile): Promise { + this.token.throwIfCancellationRequested(); + const isDefaultLibrary = await this.project.program.isSourceFileDefaultLibrary(sourceFile); + this.token.throwIfCancellationRequested(); + if (isDefaultLibrary) { + return true; + } + const isExternalLibrary = await this.project.program.isSourceFileFromExternalLibrary(sourceFile); + this.token.throwIfCancellationRequested(); + return isExternalLibrary; + } + + public async getSymbolAtLocation(node: Node): Promise { + this.token.throwIfCancellationRequested(); + const result = await this.project.checker.getSymbolAtLocation(node); + this.token.throwIfCancellationRequested(); + return result; + } + + public async getSymbolsInScope(location: Node | DocumentPosition, meaning: SymbolFlags): Promise { + interface CheckerWithSymbolsInScope { + getSymbolsInScope(location: Node | DocumentPosition, meaning: SymbolFlags): readonly NativeSymbol[]; + } + const checker = this.project.checker; + if (typeof (checker as unknown as CheckerWithSymbolsInScope).getSymbolsInScope === 'function') { + return (checker as unknown as CheckerWithSymbolsInScope).getSymbolsInScope(location, meaning); + } + return []; + } + + public async getAliasedSymbol(symbol: NativeSymbol): Promise { + return Symbols.isAlias(symbol) ? this.getLeafSymbol(symbol) : symbol; + } + + public async getAliasedSymbolAtLocation(node: Node): Promise { + const symbol = await this.getSymbolAtLocation(node); + return symbol === undefined ? undefined : this.getAliasedSymbol(symbol); + } + + public async getLeafSymbolAtLocation(node: Node): Promise { + const symbol = await this.getSymbolAtLocation(node); + return symbol === undefined ? undefined : this.getLeafSymbol(symbol); + } + + public async getLeafSymbol(initialSymbol: NativeSymbol): Promise { + let symbol = initialSymbol; + let count = 0; + while (Symbols.isAlias(symbol) && count++ < 10) { + this.token.throwIfCancellationRequested(); + const candidate = await this.project.checker.getAliasedSymbol(symbol); + this.token.throwIfCancellationRequested(); + if (candidate.id === symbol.id || await this.project.checker.isUnknownSymbol(candidate)) { + break; + } + symbol = candidate; + } + while (Symbols.isTypeAlias(symbol) && count++ < 10) { + const declarations = await this.getDeclarations(symbol); + if (declarations.length !== 1 || !isTypeAliasDeclaration(declarations[0])) { + break; + } + const candidate = await this.getSymbolAtLocation(declarations[0].type); + if (candidate === undefined || candidate.id === symbol.id) { + break; + } + symbol = candidate; + } + return symbol; + } + + public getDeclarations(symbol: NativeSymbol): Promise { + let result = this.declarationCache.get(symbol.id); + if (result === undefined) { + result = this.resolveDeclarations(symbol.declarations); + this.declarationCache.set(symbol.id, result); + } + return result; + } + + public async getSymbolInfo(symbol: NativeSymbol, activeSourceFile?: SourceFile): Promise { + const declarations = await this.getDeclarations(symbol); + if (declarations.length === 0) { + return undefined; + } + let primary: SourceFile | undefined; + for (const declaration of declarations) { + const sourceFile = declaration.getSourceFile(); + primary ??= sourceFile; + if (activeSourceFile !== undefined && sourceFile.path === activeSourceFile.path) { + return undefined; + } + this.token.throwIfCancellationRequested(); + const metadata = await this.project.program.getSourceFileMetadataByPath(sourceFile.path); + this.token.throwIfCancellationRequested(); + if (metadata?.isDefaultLibrary || metadata?.isFromExternalLibrary) { + return undefined; + } + } + return primary === undefined ? undefined : { symbol, primary, declarations }; + } + + public async getDirectSuperSymbols(symbol: NativeSymbol): Promise { + const result: DirectSuperSymbolInfo = {}; + for (const declaration of await this.getDeclarations(symbol)) { + if (!isClassDeclaration(declaration) && !isInterfaceDeclaration(declaration)) { + continue; + } + for (const heritageClause of declaration.heritageClauses ?? []) { + for (const type of heritageClause.types) { + // const candidate = await (isExpressionWithTypeArguments(type) ? this.getLeafSymbolAtLocation(type.expression) : this.getLeafSymbolAtLocation(type.typeName)); + const candidate = await this.getLeafSymbolAtLocation(type.expression); + if (candidate === undefined) { + continue; + } + // const name = isExpressionWithTypeArguments(type) ? type.expression.getText() : type.typeName.getText(); + const name = type.expression.getText(); + if (heritageClause.token === SyntaxKind.ExtendsKeyword && result.extends === undefined) { + result.extends = { symbol: candidate, name }; + } else if (heritageClause.token === SyntaxKind.ImplementsKeyword) { + (result.implements ??= []).push({ symbol: candidate, name }); + } + } + } + } + return result.extends === undefined && result.implements === undefined ? undefined : result; + } + + public async getAllSuperTypes(symbol: NativeSymbol): Promise { + return this.getAllSuperSymbols(symbol); + } + + public async getAllSuperClasses(symbol: NativeSymbol): Promise { + return (await this.getAllSuperSymbols(symbol)).filter(candidate => Symbols.isClass(candidate)); + } + + public async getAllSuperSymbols(symbol: NativeSymbol): Promise { + const result: NativeSymbol[] = []; + const seen = new Set([symbol.id]); + const queue: NativeSymbol[] = [symbol]; + while (queue.length > 0) { + this.token.throwIfCancellationRequested(); + const current = queue.shift(); + if (current === undefined) { + break; + } + const direct = await this.getDirectSuperSymbols(current); + const candidates = direct === undefined ? [] : [direct.extends?.symbol, ...(direct.implements?.map(item => item.symbol) ?? [])]; + for (const candidate of candidates) { + if (candidate === undefined || seen.has(candidate.id)) { + continue; + } + seen.add(candidate.id); + result.push(candidate); + queue.push(candidate); + } + } + return result; + } + + public async getTypeSymbols(type: Type): Promise { + const result: NativeSymbol[] = []; + await this.collectTypeSymbols(result, new Set(), type); + return result; + } + + public async createKey(symbol: NativeSymbol): Promise; + public async createKey(declaration: DeclarationBase): Promise; + public async createKey(arg: NativeSymbol | DeclarationBase): Promise + { + if (arg instanceof NativeSymbol) { + const symbol = arg; + const declarations = await this.getDeclarations(symbol); + if (declarations.length === 0) { + return undefined; + } + const fragments = declarations.map(declaration => ({ + f: declaration.getSourceFile().path, + s: declaration.getStart(), + e: declaration.getEnd(), + k: declaration.kind, + })).sort((first, second) => first.f.localeCompare(second.f) || first.s - second.s || first.e - second.e || first.k - second.k); + const hash = createHash('md5'); // CodeQL [SM04514] Used only as a compact cache key, not for security. + if ((symbol.flags & SymbolFlags.Transient) !== 0) { + hash.update(JSON.stringify({ trans: true })); + } + hash.update(JSON.stringify(fragments)); + return hash.digest('base64'); + } else { + const declaration = arg; + const fragment = { + f: declaration.getSourceFile().path, + s: declaration.getStart(), + e: declaration.getEnd(), + k: declaration.kind, + }; + const hash = createHash('md5'); // CodeQL [SM04514] Used only as a compact cache key, not for security. + hash.update(JSON.stringify(fragment)); + return hash.digest('base64'); + } + } + + public async getDeclaration(symbol: NativeSymbol, kind: SyntaxKind): Promise { + return (await this.getDeclarations(symbol)).find(declaration => declaration.kind === kind) as T | undefined; + } + + public static isFunctionScopedVariable(symbol: NativeSymbol | undefined): symbol is NativeSymbol { + return symbol !== undefined && (symbol.flags & SymbolFlags.FunctionScopedVariable) !== 0; + } + + public static isBlockScopedVariable(symbol: NativeSymbol | undefined): symbol is NativeSymbol { + return symbol !== undefined && (symbol.flags & SymbolFlags.BlockScopedVariable) !== 0; + } + + public static isConstructor(symbol: NativeSymbol | undefined): symbol is NativeSymbol { + return symbol !== undefined && (symbol.flags & SymbolFlags.Constructor) !== 0; + } + + public static isMethod(symbol: NativeSymbol | undefined): symbol is NativeSymbol { + return symbol !== undefined && (symbol.flags & SymbolFlags.Method) !== 0; + } + + public static isProperty(symbol: NativeSymbol | undefined): symbol is NativeSymbol { + return symbol !== undefined && (symbol.flags & SymbolFlags.Property) !== 0; + } + + public static isClass(symbol: NativeSymbol | undefined): symbol is NativeSymbol { + return symbol !== undefined && (symbol.flags & SymbolFlags.Class) !== 0; + } + + public static isInterface(symbol: NativeSymbol | undefined): symbol is NativeSymbol { + return symbol !== undefined && (symbol.flags & SymbolFlags.Interface) !== 0; + } + + public static isTypeAlias(symbol: NativeSymbol | undefined): symbol is NativeSymbol { + return symbol !== undefined && (symbol.flags & SymbolFlags.TypeAlias) !== 0; + } + + public static isTypeParameter(symbol: NativeSymbol | undefined): symbol is NativeSymbol { + return symbol !== undefined && (symbol.flags & SymbolFlags.TypeParameter) !== 0; + } + + public static isTypeLiteral(symbol: NativeSymbol | undefined): symbol is NativeSymbol { + return symbol !== undefined && (symbol.flags & SymbolFlags.TypeLiteral) !== 0; + } + + public static isEnum(symbol: NativeSymbol | undefined): symbol is NativeSymbol { + return symbol !== undefined && (symbol.flags & (SymbolFlags.RegularEnum | SymbolFlags.ConstEnum)) !== 0; + } + + public static isFunction(symbol: NativeSymbol | undefined): symbol is NativeSymbol { + return symbol !== undefined && (symbol.flags & SymbolFlags.Function) !== 0; + } + + public static isValueModule(symbol: NativeSymbol | undefined): symbol is NativeSymbol { + return symbol !== undefined && (symbol.flags & SymbolFlags.ValueModule) !== 0; + } + + public static isAlias(symbol: NativeSymbol | undefined): symbol is NativeSymbol { + return symbol !== undefined && (symbol.flags & SymbolFlags.Alias) !== 0; + } + + public static isInternal(symbol: NativeSymbol): boolean { + return symbol.name === '__type' || symbol.name === '__class' || symbol.name === '__object'; + } + + private async collectTypeSymbols(result: NativeSymbol[], seen: Set, type: Type): Promise { + this.token.throwIfCancellationRequested(); + const alias = await type.getAliasSymbol(); + const symbol = alias ?? await type.getSymbol(); + if (symbol !== undefined) { + const leaf = await this.getLeafSymbol(symbol); + if (!seen.has(leaf.id)) { + seen.add(leaf.id); + result.push(leaf); + } + return; + } + if (type.isUnionType() || type.isIntersectionType()) { + for (const item of await type.getTypes()) { + await this.collectTypeSymbols(result, seen, item); + } + } + } + + private async resolveDeclarations(handles: readonly NodeHandle[]): Promise { + const result: Node[] = []; + for (const handle of handles) { + this.token.throwIfCancellationRequested(); + const declaration = await handle.resolve(this.project); + this.token.throwIfCancellationRequested(); + if (declaration !== undefined) { + result.push(declaration); + } + } + return result; + } +} + +export namespace Types { + export function isIntersection(type: Type): boolean { + return type.isIntersectionType(); + } + + export function isUnion(type: Type): boolean { + return type.isUnionType(); + } +} + +export default tss; diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/tsContextService.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/tsContextService.ts new file mode 100644 index 00000000000000..ecc53b2a05ae8b --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/tsContextService.ts @@ -0,0 +1,795 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; + +import { LRUCache } from 'lru-cache'; +import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; +import { ILanguageContextService, type ContextItem, type RequestContext } from '../../../platform/languageServer/common/languageContextService'; +import { ILogService } from '../../../platform/log/common/logService'; +import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService'; +import { ITelemetryService } from '../../../platform/telemetry/common/telemetry'; +import { DisposableStore } from '../../../util/vs/base/common/lifecycle'; +import * as protocol from '../common/serverProtocol'; +import { CacheState, ContextItemUsageMode, ResolvedRunnableResult, type CacheInfo, type OnCachePopulatedEvent, type OnContextComputedEvent, type OnContextComputedOnTimeoutEvent } from './types'; +import { TelemetrySender } from './telemetrySender'; + +export const currentTokenBudget: number = 8 * 1024; + +type RequestInfo = { + readonly document: string; + readonly version: number; + readonly languageId: string; + readonly position: vscode.Position; + readonly requestId: string; + readonly path: number[]; +}; + +type ContextRequestState = { + client: readonly ResolvedRunnableResult[]; + clientOnTimeout: readonly ResolvedRunnableResult[]; + server: readonly protocol.CachedContextRunnableResult[]; + resultMap: Map; + itemMap: Map; +}; + +type ManagerUpdateResult = { + resolved: ResolvedRunnableResult[]; + serverComputed: Set; + cached: number; + referenced: number; +}; + +class RunnableResultManager implements vscode.Disposable { + + private readonly disposables = new DisposableStore(); + private requestInfo: RequestInfo | undefined; + + private cacheInfo: CacheInfo; + private results: Map; + private readonly withInRangeRunnableResults: { resultId: protocol.ContextRunnableResultId; range: vscode.Range }[]; + private readonly outsideRangeRunnableResults: { resultId: protocol.ContextRunnableResultId; ranges: vscode.Range[] }[] = []; + private readonly neighborFileRunnableResults: { resultId: protocol.ContextRunnableResultId }[]; + + constructor() { + this.requestInfo = undefined; + this.results = new Map(); + + this.cacheInfo = { + version: 0, + state: CacheState.NotPopulated + }; + this.withInRangeRunnableResults = []; + this.outsideRangeRunnableResults = []; + this.neighborFileRunnableResults = []; + + this.disposables.add(vscode.workspace.onDidChangeTextDocument((event: vscode.TextDocumentChangeEvent) => { + if (this.requestInfo === undefined || event.contentChanges.length === 0) { + return; + } + if (event.document.uri.toString() !== this.requestInfo.document) { + if (this.affectsTypeScript(event)) { + this.clear(); + } + } else { + for (const change of event.contentChanges) { + const changeRange = change.range; + for (let i = 0; i < this.withInRangeRunnableResults.length;) { + const entry = this.withInRangeRunnableResults[i]; + if (entry.range.contains(changeRange)) { + entry.range = this.applyTextContentChangeEventToWithinRange(change, entry.range); + i++; + } else { + const id = entry.resultId; + this.results.delete(id); + this.withInRangeRunnableResults.splice(i, 1); + } + } + for (let i = 0; i < this.outsideRangeRunnableResults.length;) { + const entry = this.outsideRangeRunnableResults[i]; + const ranges = this.applyTextContentChangeEventToOutsideRanges(change, entry.ranges); + if (ranges === undefined) { + const id = entry.resultId; + this.results.delete(id); + this.outsideRangeRunnableResults.splice(i, 1); + } else { + entry.ranges = ranges; + i++; + } + } + this.cacheInfo.version = event.document.version; + } + } + })); + this.disposables.add(vscode.workspace.onDidCloseTextDocument((document: vscode.TextDocument) => { + if (this.requestInfo?.document === document.uri.toString()) { + this.clear(); + } + })); + this.disposables.add(vscode.window.onDidChangeActiveTextEditor(() => { + this.clear(); + })); + this.disposables.add(vscode.window.tabGroups.onDidChangeTabs((event: vscode.TabChangeEvent) => { + if (event.closed.length === 0 && event.opened.length === 0) { + return; + } + for (const item of this.neighborFileRunnableResults) { + this.results.delete(item.resultId); + } + this.neighborFileRunnableResults.length = 0; + })); + } + + public clear(): void { + this.requestInfo = undefined; + this.results.clear(); + + this.cacheInfo = { + version: 0, + state: CacheState.NotPopulated + }; + this.withInRangeRunnableResults.length = 0; + this.outsideRangeRunnableResults.length = 0; + this.neighborFileRunnableResults.length = 0; + } + + public getCacheState(): CacheState { + return this.cacheInfo.state; + } + + public update(document: vscode.TextDocument, version: number, position: vscode.Position, context: RequestContext, body: protocol.ComputeContextResponse.OK, requestState: ContextRequestState | undefined): ManagerUpdateResult { + const itemMap = requestState?.itemMap ?? new Map(); + const usedResults = requestState?.resultMap ?? new Map(); + + this.withInRangeRunnableResults.length = 0; + this.outsideRangeRunnableResults.length = 0; + this.neighborFileRunnableResults.length = 0; + this.results.clear(); + this.cacheInfo = { + version: version, + state: CacheState.NotPopulated + }; + + let cachedItems = 0; + let referencedItems = 0; + const serverComputed: Set = new Set(); + this.requestInfo = { + document: document.uri.toString(), + version: version, + languageId: document.languageId, + position: position, + requestId: context.requestId, + path: body.path ?? [0] + }; + + if (body.runnableResults === undefined || body.runnableResults.length === 0 || body.path === undefined || body.path.length === 0 || body.path[0] === 0) { + return { resolved: [], cached: cachedItems, referenced: referencedItems, serverComputed: serverComputed }; + } + + const serverItems: Set = new Set(); + // Add new client side context items to the item map. + if (body.contextItems !== undefined && body.contextItems.length > 0) { + for (const item of body.contextItems) { + if (protocol.ContextItem.hasKey(item)) { + itemMap.set(item.key, item); + serverItems.add(item.key); + } + } + } + const updateRunnableResult = (resultItem: protocol.ContextRunnableResultTypes): ResolvedRunnableResult | undefined => { + let result: ResolvedRunnableResult | undefined; + if (resultItem.kind === protocol.ContextRunnableResultKind.ComputedResult) { + serverComputed.add(resultItem.id); + const items: protocol.FullContextItem[] = []; + for (const contextItem of resultItem.items) { + if (contextItem.kind === protocol.ContextKind.Reference) { + const referenced: protocol.FullContextItem | undefined = itemMap.get(contextItem.key); + if (referenced !== undefined) { + referencedItems++; + items.push(referenced); + if (!serverItems.has(contextItem.key)) { + cachedItems++; + } + } + } else { + items.push(contextItem); + } + } + result = ResolvedRunnableResult.from(resultItem, items); + } else if (resultItem.kind === protocol.ContextRunnableResultKind.Reference) { + result = usedResults.get(resultItem.id); + if (result !== undefined) { + cachedItems += result.items.length; + } + } + if (result === undefined) { + return; + } + this.results.set(result.id, result); + if (result.cache !== undefined) { + if (result.cache.scope.kind === protocol.CacheScopeKind.WithinRange) { + const scopeRange = result.cache.scope.range; + const range = new vscode.Range(scopeRange.start.line, scopeRange.start.character, scopeRange.end.line, scopeRange.end.character); + this.withInRangeRunnableResults.push({ range, resultId: result.id }); + } else if (result.cache.scope.kind === protocol.CacheScopeKind.NeighborFiles) { + this.neighborFileRunnableResults.push({ resultId: result.id }); + } else if (result.cache.scope.kind === protocol.CacheScopeKind.OutsideRange) { + const ranges: vscode.Range[] = []; + for (const scopeRange of result.cache.scope.ranges) { + ranges.push(new vscode.Range(scopeRange.start.line, scopeRange.start.character, scopeRange.end.line, scopeRange.end.character)); + } + this.outsideRangeRunnableResults.push({ resultId: result.id, ranges }); + } + } + this.updateCacheState(result.state); + return result; + }; + + const results: ResolvedRunnableResult[] = []; + for (const runnableResult of body.runnableResults) { + const result = updateRunnableResult(runnableResult); + if (result !== undefined) { + results.push(result); + } + } + return { resolved: results, cached: cachedItems, referenced: referencedItems, serverComputed: serverComputed }; + } + + private updateCacheState(state: protocol.ContextRunnableState): void { + switch (this.cacheInfo.state) { + case CacheState.NotPopulated: + switch (state) { + case protocol.ContextRunnableState.Finished: + this.cacheInfo.state = CacheState.FullyPopulated; + break; + case protocol.ContextRunnableState.IsFull: + case protocol.ContextRunnableState.InProgress: + this.cacheInfo.state = CacheState.PartiallyPopulated; + break; + default: + this.cacheInfo.state = CacheState.NotPopulated; + } + break; + case CacheState.PartiallyPopulated: + // If the cache is partially populated we can only stay in that state. + break; + case CacheState.FullyPopulated: + switch (state) { + case protocol.ContextRunnableState.Finished: + // If the cache is fully populated we can only stay in that state. + break; + case protocol.ContextRunnableState.IsFull: + case protocol.ContextRunnableState.InProgress: + this.cacheInfo.state = CacheState.PartiallyPopulated; + break; + default: + this.cacheInfo.state = CacheState.NotPopulated; + } + break; + } + } + + public getRequestId(): string | undefined { + return this.requestInfo?.requestId; + } + + public getNodePath(): number[] { + return this.requestInfo?.path ?? [0]; + } + + public getRunnableResult(id: protocol.ContextRunnableResultId): ResolvedRunnableResult | undefined { + return this.results.get(id); + } + + public getCachedRunnableResults(document: vscode.TextDocument, position: vscode.Position, emitMode?: protocol.EmitMode): ResolvedRunnableResult[] { + const results: ResolvedRunnableResult[] = []; + if (this.requestInfo?.document !== document.uri.toString()) { + return results; + } + if (this.cacheInfo.version !== document.version || this.cacheInfo.state === CacheState.NotPopulated || this.requestInfo.path.length === 0 || this.requestInfo.path[0] === 0) { + return results; + } + for (const item of this.results.values()) { + if (emitMode !== undefined && item.cache?.emitMode === emitMode) { + continue; + } + const scope = item.cache?.scope; + if (scope === undefined || scope.kind !== protocol.CacheScopeKind.WithinRange) { + results.push(item); + } else { + const r = scope.range; + const range = new vscode.Range(r.start.line, r.start.character, r.end.line, r.end.character); + if (range.contains(position)) { + results.push(item); + } + } + } + // Sort them by priority so that the most important items are emitted first if they + // are contained in more than one runnable result. + return results.sort((a, b) => { + return a.priority < b.priority ? 1 : a.priority > b.priority ? -1 : 0; + }); + } + + public getContextRequestState(document: vscode.TextDocument, position: vscode.Position): ContextRequestState | undefined { + if (this.requestInfo?.document !== document.uri.toString()) { + return undefined; + } + if (this.cacheInfo.version !== document.version || this.cacheInfo.state === CacheState.NotPopulated || this.requestInfo.path.length === 0 || this.requestInfo.path[0] === 0) { + return undefined; + } + const items: Map = new Map(); + const client: ResolvedRunnableResult[] = []; + const clientOnTimeout: ResolvedRunnableResult[] = []; + const server: protocol.CachedContextRunnableResult[] = []; + if (this.isCacheFullyUpToDate(document, position)) { + for (const item of this.results.values()) { + client.push(item); + } + } else { + const canSkipItems = (rr: ResolvedRunnableResult, cache: protocol.CacheInfo): boolean => { + if (rr.state === protocol.ContextRunnableState.Finished) { + return true; + } + if (rr.state === protocol.ContextRunnableState.IsFull) { + const kind = cache.scope.kind; + return kind === protocol.CacheScopeKind.WithinRange || kind === protocol.CacheScopeKind.NeighborFiles || kind === protocol.CacheScopeKind.File; + } + return false; + }; + const handleRunnableResult = (id: string, rr: ResolvedRunnableResult) => { + const cache = rr.cache; + const cachedResult: protocol.CachedContextRunnableResult = { + id: id, + kind: protocol.ContextRunnableResultKind.CacheEntry, + state: rr.state, + items: [] + }; + let skipItems = false; + if (cache !== undefined) { + cachedResult.cache = cache; + const emitMode = cache.emitMode; + if (emitMode === protocol.EmitMode.ClientBased) { + client.push(rr); + skipItems = canSkipItems(rr, cache); + } else if (emitMode === protocol.EmitMode.ClientBasedOnTimeout) { + clientOnTimeout.push(rr); + } + } + server.push(cachedResult); + + if (skipItems) { + return; + } + + // Add cached context items to the result; + for (const item of rr.items) { + if (!protocol.ContextItem.hasKey(item)) { + continue; + } + const key = item.key; + let size: number | undefined = undefined; + switch (item.kind) { + case protocol.ContextKind.Snippet: + size = protocol.CodeSnippet.sizeInChars(item); + break; + case protocol.ContextKind.Trait: + size = protocol.Trait.sizeInChars(item); + break; + default: + } + cachedResult.items.push(protocol.CachedContextItem.create(key, size)); + items.set(key, item); + } + }; + // We don't need to sort by priority here since the data is used for the next cache request. + for (const [id, item] of this.results.entries()) { + const scope = item.cache?.scope; + if (scope === undefined || scope.kind !== protocol.CacheScopeKind.WithinRange) { + handleRunnableResult(id, item); + } else { + const r = scope.range; + const range = new vscode.Range(r.start.line, r.start.character, r.end.line, r.end.character); + if (range.contains(position)) { + handleRunnableResult(id, item); + } + } + } + } + return { client, clientOnTimeout, server, itemMap: items, resultMap: new Map(this.results) }; + } + + private isCacheFullyUpToDate(document: vscode.TextDocument, position: vscode.Position): boolean { + if (this.requestInfo === undefined) { + return false; + } + if (this.requestInfo.document !== document.uri.toString()) { + return false; + } + + // Same document, version and position. Cache can be full used. + if (this.requestInfo.version === document.version && this.requestInfo.position.isEqual(position)) { + return true; + } + + // Document is older than cached request. Not up to date. + if (this.requestInfo.version > document.version) { + return false; + } + + // if the position is not contained in all ranges return false. + for (const runnable of this.withInRangeRunnableResults) { + if (!runnable.range.contains(position)) { + return false; + } + } + + const range = position.isBefore(this.requestInfo.position) ? new vscode.Range(position, this.requestInfo.position) : new vscode.Range(this.requestInfo.position, position); + const text = document.getText(range); + return text.trim().length === 0; + } + + public dispose(): void { + this.clear(); + this.disposables.dispose(); + } + + private affectsTypeScript(event: vscode.TextDocumentChangeEvent): boolean { + const languageId = event.document.languageId; + return languageId === 'typescript' || languageId === 'typescriptreact' || languageId === 'javascript' || languageId === 'javascriptreact' || languageId === 'json'; + } + + private applyTextContentChangeEventToWithinRange(event: vscode.TextDocumentContentChangeEvent, range: vscode.Range): vscode.Range { + // The start stays untouched since the change range is contained in the range. + const eventRange = event.range; + const eventText = event.text; + + // Calculate how many lines the new text adds or removes + const linesDelta = (eventText.match(/\n/g) || []).length - (eventRange.end.line - eventRange.start.line); + + // Calculate the new end position + const endLine = range.end.line + linesDelta; + + let endCharacter = range.end.character; + if (eventRange.end.line === range.end.line) { + // Calculate the character delta for the last line of the change + const lastNewLineIndex = eventText.lastIndexOf('\n'); + const newTextLength = lastNewLineIndex !== -1 ? eventText.length - lastNewLineIndex - 1 : eventText.length; + const oldTextLength = eventRange.end.character - (eventRange.end.line > eventRange.start.line ? 0 : eventRange.start.character); + const charDelta = newTextLength - oldTextLength; + endCharacter += charDelta; + } + return new vscode.Range(range.start, new vscode.Position(endLine, endCharacter)); + } + + private applyTextContentChangeEventToOutsideRanges(event: vscode.TextDocumentContentChangeEvent, ranges: vscode.Range[]): vscode.Range[] | undefined { + if (ranges.length === 0) { + return ranges; + } + const changeRange = event.range; + const eventText = event.text; + + // Quick optimization: if change is completely after last range, no ranges need adjustment + const lastRange = ranges[ranges.length - 1]; + if (changeRange.start.isAfter(lastRange.end)) { + return ranges; + } + // Calculate how many lines the new text adds or removes + const linesDelta = (eventText.match(/\n/g) || []).length - (changeRange.end.line - changeRange.start.line); + const adjustedRanges: vscode.Range[] = []; + + for (const range of ranges) { + if (range.end.isBefore(changeRange.start)) { + // Range is completely before change, no adjustment needed + adjustedRanges.push(range); + } else if (range.start.isAfter(changeRange.end)) { + // Range is completely after change, adjust by lines delta + if (linesDelta === 0) { + adjustedRanges.push(range); + } else { + adjustedRanges.push(new vscode.Range( + new vscode.Position(range.start.line + linesDelta, range.start.character), + new vscode.Position(range.end.line + linesDelta, range.end.character) + )); + } + } else { + + // The range intersects with the range with will invalidate the cache entry. + return undefined; + } + } + + return adjustedRanges; + } +} + +namespace TextDocuments { + export function consider(document: vscode.TextDocument): boolean { + return document.uri.scheme === 'file' && (document.languageId === 'typescript' || document.languageId === 'typescriptreact'); + } +} + +class NeighborFileModel implements vscode.Disposable { + + private static readonly MAX_ITEMS = 12; + + private readonly disposables; + private readonly visible: LRUCache; + private readonly notVisible: LRUCache; + + constructor() { + this.disposables = new DisposableStore(); + this.visible = new LRUCache({ max: NeighborFileModel.MAX_ITEMS }); + this.notVisible = new LRUCache({ max: NeighborFileModel.MAX_ITEMS }); + this.disposables.add(vscode.window.onDidChangeActiveTextEditor((editor: vscode.TextEditor | undefined) => { + if (editor === undefined) { + return; + } + const document = editor.document; + if (TextDocuments.consider(document)) { + const uri = document.uri.toString(); + this.visible.set(uri, document.uri.fsPath); + this.notVisible.delete(uri); + } + })); + this.disposables.add(vscode.workspace.onDidCloseTextDocument((document: vscode.TextDocument) => { + const uri = document.uri.toString(); + if (TextDocuments.consider(document)) { + this.visible.delete(uri); + this.notVisible.delete(uri); + } + })); + this.disposables.add(vscode.window.tabGroups.onDidChangeTabs((e: vscode.TabChangeEvent) => { + // We don't track open tabs here to ensure we only track documents that are + // actually focused. Otherwise opening multiple tabs at once would cause too much churn. + for (const tab of e.closed) { + if (tab.input instanceof vscode.TabInputText) { + const uri = tab.input.uri.toString(); + const isVisible = this.visible.has(uri); + if (isVisible) { + this.visible.delete(uri); + this.notVisible.set(uri, tab.input.uri.fsPath); + } + } + } + })); + const textDocumentsToConsider: Map = new Map(); + for (const document of vscode.workspace.textDocuments) { + if (TextDocuments.consider(document)) { + textDocumentsToConsider.set(document.uri.toString(), document.uri); + } + } + for (const group of vscode.window.tabGroups.all) { + for (const tab of group.tabs) { + const uri = tab.input instanceof vscode.TabInputText ? tab.input.uri : undefined; + if (uri !== undefined && textDocumentsToConsider.has(uri.toString())) { + this.visible.set(uri.toString(), uri.fsPath); + textDocumentsToConsider.delete(uri.toString()); + } + } + } + for (const [key, uri] of textDocumentsToConsider.entries()) { + this.notVisible.set(key, uri.fsPath); + } + if (vscode.window.activeTextEditor !== undefined) { + const document = vscode.window.activeTextEditor.document; + if (TextDocuments.consider(document)) { + const uri = document.uri.toString(); + this.visible.set(uri, document.uri.fsPath); + this.notVisible.delete(uri); + } + } + } + + public getNeighborFiles(currentDocument: vscode.TextDocument): string[] { + const result: string[] = []; + const currentUri = currentDocument.uri.toString(); + for (const [key, value] of this.visible.entries()) { + if (key === currentUri) { + continue; + } + result.push(value); + } + if (result.length < NeighborFileModel.MAX_ITEMS) { + for (const [key, value] of this.notVisible.entries()) { + if (key === currentUri) { + continue; + } + result.push(value); + if (result.length >= NeighborFileModel.MAX_ITEMS) { + break; + } + } + } + return result; + } + + public dispose(): void { + this.disposables.dispose(); + } +} + +class CharacterBudget { + + public readonly overall: number; + private mandatory: number; + private optional: number; + private start: { mandatory: number; optional: number }; + + constructor(mandatory: number, optional: number) { + this.overall = mandatory; + this.mandatory = mandatory; + this.optional = optional; + this.start = { mandatory, optional }; + } + + spend(chars: number): void { + this.mandatory -= chars; + this.optional -= chars; + } + + isExhausted(): boolean { + return this.mandatory <= 0; + } + + isOptionalExhausted(): boolean { + return this.optional <= 0; + } + + public fresh(): CharacterBudget { + return new CharacterBudget(this.start.mandatory, this.start.optional); + } +} + +export interface TSLanguageContextService extends Omit, vscode.Disposable { + readonly onCachePopulated: vscode.Event; + readonly onContextComputed: vscode.Event; + readonly onContextComputedOnTimeout: vscode.Event; +} + +export abstract class AbstractTSLanguageContextService implements TSLanguageContextService { + + private static readonly defaultCachePopulationBudget: number = 500; + + protected readonly disposables: DisposableStore; + protected readonly telemetrySender: TelemetrySender; + protected readonly neighborFileModel: NeighborFileModel; + protected readonly runnableResultManager: RunnableResultManager; + protected readonly logService: ILogService; + protected readonly configurationService: IConfigurationService; + protected readonly experimentationService: IExperimentationService; + + protected usageMode: ContextItemUsageMode; + protected cachePopulationTimeout: number; + protected includeDocumentation: boolean; + + + protected _onCachePopulated: vscode.EventEmitter; + public readonly onCachePopulated: vscode.Event; + + protected _onContextComputed: vscode.EventEmitter; + public readonly onContextComputed: vscode.Event; + + protected _onContextComputedOnTimeout: vscode.EventEmitter; + public readonly onContextComputedOnTimeout: vscode.Event; + + constructor( + telemetryService: ITelemetryService, + logService: ILogService, + configurationService: IConfigurationService, + experimentationService: IExperimentationService + ) { + this.disposables = new DisposableStore(); + + this.configurationService = configurationService; + this.experimentationService = experimentationService; + this.logService = logService; + this.telemetrySender = new TelemetrySender(telemetryService, logService); + this.neighborFileModel = this.disposables.add(new NeighborFileModel()); + this.runnableResultManager = this.disposables.add(new RunnableResultManager()); + + this.usageMode = this.getUsageMode(); + this.cachePopulationTimeout = this.getCachePopulationBudget(); + this.includeDocumentation = this.getIncludeDocumentation(); + + this.disposables.add(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(ConfigKey.TypeScriptLanguageContextMode.fullyQualifiedId)) { + this.usageMode = this.getUsageMode(); + } else if (e.affectsConfiguration(ConfigKey.TypeScriptLanguageContextCacheTimeout.fullyQualifiedId)) { + this.cachePopulationTimeout = this.getCachePopulationBudget(); + } else if (e.affectsConfiguration(ConfigKey.TypeScriptLanguageContextIncludeDocumentation.fullyQualifiedId)) { + this.includeDocumentation = this.getIncludeDocumentation(); + } + })); + + + this._onCachePopulated = this.disposables.add(new vscode.EventEmitter()); + this.onCachePopulated = this._onCachePopulated.event; + + this._onContextComputed = this.disposables.add(new vscode.EventEmitter()); + this.onContextComputed = this._onContextComputed.event; + + this._onContextComputedOnTimeout = this.disposables.add(new vscode.EventEmitter()); + this.onContextComputedOnTimeout = this._onContextComputedOnTimeout.event; + } + + public dispose(): void { + this.disposables.dispose(); + } + + public abstract isActivated(documentOrLanguageId: vscode.TextDocument | string): Promise; + + public abstract populateCache(document: vscode.TextDocument, position: vscode.Position, context: RequestContext): Promise; + + public abstract getContext(document: vscode.TextDocument, position: vscode.Position, context: RequestContext, token: vscode.CancellationToken): AsyncIterable; + + public abstract getContextOnTimeout(document: vscode.TextDocument, position: vscode.Position, context: RequestContext): readonly ContextItem[] | undefined; + + private getCachePopulationBudget(): number { + const result = this.configurationService.getExperimentBasedConfig(ConfigKey.TypeScriptLanguageContextCacheTimeout, this.experimentationService); + return result ?? AbstractTSLanguageContextService.defaultCachePopulationBudget; + } + + private getUsageMode(): ContextItemUsageMode { + const value = this.configurationService.getExperimentBasedConfig(ConfigKey.TypeScriptLanguageContextMode, this.experimentationService); + return ContextItemUsageMode.fromString(value); + } + + private getIncludeDocumentation(): boolean { + return this.configurationService.getExperimentBasedConfig(ConfigKey.TypeScriptLanguageContextIncludeDocumentation, this.experimentationService); + } + + protected getCharacterBudget(context: RequestContext, document: vscode.TextDocument): CharacterBudget { + const chars = (context.tokenBudget ?? currentTokenBudget) * 4; + switch (this.usageMode) { + case ContextItemUsageMode.minimal: + return new CharacterBudget(chars, 0); + case ContextItemUsageMode.double: + return new CharacterBudget(chars, Math.min(chars, document.getText().length)); + case ContextItemUsageMode.fillHalf: + return new CharacterBudget(chars, Math.floor(chars / 2)); + case ContextItemUsageMode.fill: + return new CharacterBudget(chars, chars); + default: + return new CharacterBudget(chars, chars); + } + } +} + +export class NullTSLanguageContextService implements TSLanguageContextService { + + private readonly disposables: DisposableStore; + + public readonly onCachePopulated: vscode.Event; + public readonly onContextComputed: vscode.Event; + public readonly onContextComputedOnTimeout: vscode.Event; + + constructor() { + this.disposables = new DisposableStore(); + this.onCachePopulated = this.disposables.add(new vscode.EventEmitter()).event; + this.onContextComputed = this.disposables.add(new vscode.EventEmitter()).event; + this.onContextComputedOnTimeout = this.disposables.add(new vscode.EventEmitter()).event; + } + + public dispose(): void { + this.disposables.dispose(); + } + + public async isActivated(): Promise { + return false; + } + + public async populateCache(): Promise { + // No cache to populate + } + + public getContext(): AsyncIterable { + return (async function* () { })(); + } + + public getContextOnTimeout(): readonly ContextItem[] | undefined { + return undefined; + } +} diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/tsService.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/tsService.ts new file mode 100644 index 00000000000000..00d2d23cef0717 --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/tsService.ts @@ -0,0 +1,57 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; + +export namespace TypeScript { + + const unifiedSection = 'js/ts'; + const legacySection = 'typescript'; + const useTsgoKey = 'experimental.useTsgo'; + const version7ExtensionIds = ['typescriptteam.vscode-typescript', 'typescriptteam.native-preview'] as const; + + export const versionKey = `${unifiedSection}.${useTsgoKey}`; + export const legacyVersionKey = `${legacySection}.${useTsgoKey}`; + + export function runsVersion7(): boolean { + // Mirrors `readUnifiedConfig` in the TypeScript extension: the unified setting wins whenever the user set it, + // otherwise the deprecated `typescript.experimental.useTsgo` still applies. + const unified = vscode.workspace.getConfiguration(unifiedSection); + if (hasUserValue(unified.inspect(useTsgoKey))) { + return unified.get(useTsgoKey, false) === true; + } + return vscode.workspace.getConfiguration(legacySection).get(useTsgoKey, false) === true; + } + + export function affectsVersion(e: vscode.ConfigurationChangeEvent): boolean { + return e.affectsConfiguration(versionKey) || e.affectsConfiguration(legacyVersionKey); + } + + export function isVersion7SupportEnabled(configurationService: IConfigurationService): boolean { + return configurationService.getConfig(ConfigKey.TypeScript7LanguageContext) ?? false; + } + + export function getVersion7Extension(getExtension: (extensionId: string) => vscode.Extension | undefined = extensionId => vscode.extensions.getExtension(extensionId)): vscode.Extension | undefined { + for (const extensionId of version7ExtensionIds) { + const extension = getExtension(extensionId); + if (extension !== undefined) { + return extension; + } + } + return undefined; + } + + function hasUserValue(inspect: ReturnType): boolean { + return inspect !== undefined && ( + inspect.globalValue !== undefined || + inspect.workspaceValue !== undefined || + inspect.workspaceFolderValue !== undefined || + inspect.globalLanguageValue !== undefined || + inspect.workspaceLanguageValue !== undefined || + inspect.workspaceFolderLanguageValue !== undefined + ); + } +} diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/tsc6/nesRenameService.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/tsc6/nesRenameService.ts new file mode 100644 index 00000000000000..be4d7b32022324 --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/tsc6/nesRenameService.ts @@ -0,0 +1,125 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { ILogService } from '../../../../platform/log/common/logService'; +import { CancellationToken } from '../../../../util/vs/base/common/cancellation'; +import * as protocol from '../../common/serverProtocol'; + +enum ExecutionTarget { + Semantic, + Syntax +} + +type ExecConfig = { + readonly lowPriority?: boolean; + readonly nonRecoverable?: boolean; + readonly cancelOnResourceChange?: vscode.Uri; + readonly executionTarget?: ExecutionTarget; +}; + +type PrepareNesRenameRequestArgs = Omit & { + file: vscode.Uri; + line: number; + offset: number; +}; + +namespace PrepareNesRenameRequestArgs { + export function create(document: vscode.TextDocument, position: vscode.Position, oldName: string, newName: string, lastSymbolRename: vscode.Range | undefined, startTime: number, timeBudget: number): PrepareNesRenameRequestArgs { + return { + file: vscode.Uri.file(document.fileName), + line: position.line + 1, + offset: position.character + 1, + oldName, + newName, + lastSymbolRename: lastSymbolRename ? { + start: { line: lastSymbolRename.start.line + 1, character: lastSymbolRename.start.character + 1 }, + end: { line: lastSymbolRename.end.line + 1, character: lastSymbolRename.end.character + 1 }, + } : undefined, + startTime, + timeBudget, + }; + } +} + +type NesRenameRequestArgs = Omit & { + file: vscode.Uri; + line: number; + offset: number; +}; + +namespace NesRenameRequestArgs { + export function create(document: vscode.TextDocument, position: vscode.Position, oldName: string, newName: string, lastSymbolRename: vscode.Range | undefined): NesRenameRequestArgs { + return { + file: vscode.Uri.file(document.fileName), + line: position.line + 1, + offset: position.character + 1, + oldName, + newName, + lastSymbolRename: lastSymbolRename ? { + start: { line: lastSymbolRename.start.line + 1, character: lastSymbolRename.start.character + 1 }, + end: { line: lastSymbolRename.end.line + 1, character: lastSymbolRename.end.character + 1 }, + } : undefined, + }; + } +} + +export class TS6NesRenameService implements vscode.Disposable { + private static readonly ExecConfig: ExecConfig = { executionTarget: ExecutionTarget.Semantic }; + + private isActivatedPromise: Promise | undefined; + + constructor(private readonly logService: ILogService) { } + + public dispose(): void { } + + public async isActivated(documentOrLanguageId: vscode.TextDocument | string): Promise { + const languageId = typeof documentOrLanguageId === 'string' ? documentOrLanguageId : documentOrLanguageId.languageId; + if (languageId !== 'typescript' && languageId !== 'typescriptreact') { + return false; + } + this.isActivatedPromise ??= this.doIsTypeScriptActivated(); + return this.isActivatedPromise; + } + + public async prepare(document: vscode.TextDocument, position: vscode.Position, oldName: string, newName: string, lastSymbolRename: vscode.Range | undefined, startTime: number, timeBudget: number, token: vscode.CancellationToken): Promise { + const args = PrepareNesRenameRequestArgs.create(document, position, oldName, newName, lastSymbolRename, startTime, timeBudget); + const response = await vscode.commands.executeCommand('typescript.tsserverRequest', '_.copilot.prepareNesRename', args, TS6NesRenameService.ExecConfig, token); + if (protocol.PrepareNesRenameResponse.isError(response)) { + return response.body; + } + if (protocol.PrepareNesRenameResponse.isOk(response)) { + return response.body; + } + return { canRename: protocol.RenameKind.no, timedOut: false }; + } + + public async postRename(document: vscode.TextDocument, position: vscode.Position, oldName: string, newName: string, lastSymbolRename: vscode.Range | undefined, token: vscode.CancellationToken): Promise { + const args = NesRenameRequestArgs.create(document, position, oldName, newName, lastSymbolRename); + const response = await vscode.commands.executeCommand('typescript.tsserverRequest', '_.copilot.postNesRename', args, TS6NesRenameService.ExecConfig, token); + return protocol.NesRenameResponse.isOk(response) ? response.body.groups : []; + } + + private async doIsTypeScriptActivated(): Promise { + try { + const typeScriptExtension = vscode.extensions.getExtension('vscode.typescript-language-features'); + if (typeScriptExtension === undefined) { + return false; + } + await typeScriptExtension.activate(); + + const response: protocol.PingResponse | undefined = await vscode.commands.executeCommand('typescript.tsserverRequest', '_.copilot.ping', TS6NesRenameService.ExecConfig, CancellationToken.None); + if (response?.body?.kind === 'ok') { + this.logService.info('TypeScript server plugin activated.'); + return true; + } + const message = response === undefined ? 'No ping response received.' : response.body?.message ?? 'Message not provided.'; + this.logService.error('TypeScript server plugin not activated:', message); + } catch (error) { + this.logService.error('Error pinging TypeScript server plugin:', error); + } + return false; + } +} diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/tsc6/tsContextService.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/tsc6/tsContextService.ts new file mode 100644 index 00000000000000..0ecd763121e5da --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/tsc6/tsContextService.ts @@ -0,0 +1,449 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; + +import { IConfigurationService } from '../../../../platform/configuration/common/configurationService'; +import { type ContextItem, type RequestContext, KnownSources } from '../../../../platform/languageServer/common/languageContextService'; +import { ILogService } from '../../../../platform/log/common/logService'; +import { IExperimentationService } from '../../../../platform/telemetry/common/nullExperimentationService'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry'; +import { CancellationToken } from '../../../../util/vs/base/common/cancellation'; +import * as protocol from '../../common/serverProtocol'; +import { ContextItemResultBuilder, ResolvedRunnableResult } from '../types'; +import { currentTokenBudget, AbstractTSLanguageContextService } from '../tsContextService'; + +enum ExecutionTarget { + Semantic, + Syntax +} + +type ExecConfig = { + readonly lowPriority?: boolean; + readonly nonRecoverable?: boolean; + readonly cancelOnResourceChange?: vscode.Uri; + readonly executionTarget?: ExecutionTarget; +}; + +type ComputeContextRequestArgs = Omit & { + file: vscode.Uri; + line: number; + offset: number; + $traceId?: string; +}; + +namespace ComputeContextRequestArgs { + export function create(document: vscode.TextDocument, position: vscode.Position, context: RequestContext, startTime: number, timeBudget: number, willLogRequestTelemetry: boolean, neighborFiles: readonly string[] | undefined, clientSideRunnableResults: readonly protocol.CachedContextRunnableResult[] | undefined, includeDocumentation: boolean): ComputeContextRequestArgs { + return { + file: vscode.Uri.file(document.fileName), + line: position.line + 1, + offset: position.character + 1, + startTime: startTime, + timeBudget: timeBudget, + primaryCharacterBudget: (context.tokenBudget ?? 7 * 1024) * 4, + secondaryCharacterBudget: (currentTokenBudget * 4), + includeDocumentation: includeDocumentation, + neighborFiles: neighborFiles !== undefined && neighborFiles.length > 0 ? neighborFiles : undefined, + clientSideRunnableResults: clientSideRunnableResults, + $traceId: willLogRequestTelemetry ? context.requestId : undefined + }; + } +} + +class PendingRequestInfo { + + public readonly document: string; + public readonly version: number; + public readonly position: vscode.Position; + public readonly context: RequestContext; + + constructor(document: vscode.TextDocument, position: vscode.Position, context: RequestContext) { + this.document = document.uri.toString(); + this.version = document.version; + this.position = position; + this.context = context; + } +} + +class InflightRequestInfo { + + public readonly document: string; + public readonly position: vscode.Position; + public readonly requestId: string; + public readonly source: KnownSources | string; + public readonly serverPromise: Thenable; + + private readonly tokenSource: vscode.CancellationTokenSource; + + constructor(document: vscode.TextDocument, position: vscode.Position, context: RequestContext, tokenSource: vscode.CancellationTokenSource, serverPromise: Thenable) { + this.document = document.uri.toString(); + this.position = position; + this.requestId = context.requestId; + this.source = context.source ?? KnownSources.unknown; + this.tokenSource = tokenSource; + this.serverPromise = serverPromise; + } + + public matches(document: vscode.TextDocument, position: vscode.Position): boolean { + return this.document === document.uri.toString() && this.position.isEqual(position); + } + + public matchesDocument(document: vscode.TextDocument): boolean { + return this.document === document.uri.toString(); + } + + public cancel(): void { + this.tokenSource.cancel(); + } +} + +class OnTimeoutData { + + private readonly document: string; + private readonly version: number; + private readonly position: vscode.Position; + + public readonly runnableResults: ResolvedRunnableResult[] = []; + public resultBuilder: ContextItemResultBuilder | undefined; + + constructor(document: vscode.TextDocument, position: vscode.Position) { + this.document = document.uri.toString(); + this.version = document.version; + this.position = position; + } + + addRunnableResult(result: ResolvedRunnableResult): void { + this.runnableResults.push(result); + } + + addRunnableResults(results: readonly ResolvedRunnableResult[]): void { + this.runnableResults.push(...results); + } + + matches(document: vscode.TextDocument, position: vscode.Position): boolean { + return this.document === document.uri.toString() && this.version === document.version && this.position.isEqual(position); + } +} + +export class TS6LanguageContextService extends AbstractTSLanguageContextService { + + private static readonly defaultCachePopulationRaceTimeout: number = 20; + private static readonly ExecConfig: ExecConfig = { executionTarget: ExecutionTarget.Semantic }; + + readonly _serviceBrand: undefined; + + private readonly isDebugging: boolean; + private _isActivated: Promise | undefined; + + private pendingRequest: PendingRequestInfo | undefined; + private inflightCachePopulationRequest: InflightRequestInfo | undefined; + private onTimeoutData: OnTimeoutData | undefined; + + constructor( + telemetryService: ITelemetryService, + configurationService: IConfigurationService, + experimentationService: IExperimentationService, + logService: ILogService + ) { + super(telemetryService, logService, configurationService, experimentationService); + this.isDebugging = process.execArgv.some((arg) => /^--(?:inspect|debug)(?:-brk)?(?:=\d+)?$/i.test(arg)); + this.pendingRequest = undefined; + this.inflightCachePopulationRequest = undefined; + this.onTimeoutData = undefined; + } + + public override dispose(): void { + this.inflightCachePopulationRequest?.cancel(); + this.inflightCachePopulationRequest = undefined; + super.dispose(); + } + + async isActivated(documentOrLanguageId: vscode.TextDocument | string): Promise { + const languageId = typeof documentOrLanguageId === 'string' ? documentOrLanguageId : documentOrLanguageId.languageId; + if (languageId !== 'typescript' && languageId !== 'typescriptreact') { + return false; + } + if (this._isActivated === undefined) { + this._isActivated = this.doIsTypeScriptActivated(languageId); + } + return this._isActivated; + } + + private async doIsTypeScriptActivated(languageId: string): Promise { + + let activated = false; + + try { + // Check that the TypeScript extension is installed and runs in the same extension host. + const typeScriptExtension = vscode.extensions.getExtension('vscode.typescript-language-features'); + if (typeScriptExtension === undefined) { + return false; + } + + // Make sure the TypeScript extension is activated. + await typeScriptExtension.activate(); + + // Send a ping request to see if the TS server plugin got installed correctly. + const response: protocol.PingResponse | undefined = await vscode.commands.executeCommand('typescript.tsserverRequest', '_.copilot.ping', TS6LanguageContextService.ExecConfig, CancellationToken.None); + this.telemetrySender.sendActivationTelemetry(response, undefined); + if (response !== undefined) { + if (response.body?.kind === 'ok') { + this.logService.info('TypeScript server plugin activated.'); + activated = true; + } else { + this.logService.error('TypeScript server plugin not activated:', response.body?.message ?? 'Message not provided.'); + } + } else { + this.logService.error('TypeScript server plugin not activated:', 'No ping response received.'); + } + } catch (error) { + this.telemetrySender.sendActivationTelemetry(undefined, error); + this.logService.error('Error pinging TypeScript server plugin:', error); + } + + return activated; + } + + async populateCache(document: vscode.TextDocument, position: vscode.Position, context: RequestContext): Promise { + if (document.languageId !== 'typescript' && document.languageId !== 'typescriptreact') { + return; + } + if (this.inflightCachePopulationRequest !== undefined) { + if (!this.inflightCachePopulationRequest.matches(document, position)) { + // We have a request running. Do not issue another cache request but remember the pending request. + this.pendingRequest = new PendingRequestInfo(document, position, context); + } + return; + } + const startTime = Date.now(); + const contextRequestState = this.runnableResultManager.getContextRequestState(document, position); + if (contextRequestState !== undefined && contextRequestState.server.length === 0) { + // There is nothing to do on the server. Cache is up to date. + return; + } + const neighborFiles: string[] = this.neighborFileModel.getNeighborFiles(document); + const timeBudget = this.cachePopulationTimeout; + const willLogRequestTelemetry = this.telemetrySender.willLogRequestTelemetry(context); + const args: ComputeContextRequestArgs = ComputeContextRequestArgs.create( + document, position, context, startTime, timeBudget, willLogRequestTelemetry, + neighborFiles, contextRequestState?.server, this.includeDocumentation + ); + try { + const isDebugging = this.isDebugging; + const forDebugging: ContextItem[] | undefined = isDebugging ? [] : undefined; + const tokenSource = new vscode.CancellationTokenSource(); + const token = tokenSource.token; + const documentVersion = document.version; + const cacheState = this.runnableResultManager.getCacheState(); + let response: protocol.ComputeContextResponse; + let inflightRequest: InflightRequestInfo | undefined = undefined; + try { + const promise: Thenable = vscode.commands.executeCommand('typescript.tsserverRequest', '_.copilot.context', args, TS6LanguageContextService.ExecConfig, token); + inflightRequest = new InflightRequestInfo(document, position, context, tokenSource, promise); + this.inflightCachePopulationRequest = inflightRequest; + response = await promise; + } finally { + if (this.inflightCachePopulationRequest === inflightRequest) { + this.inflightCachePopulationRequest = undefined; + } + tokenSource.dispose(); + } + const timeTaken = Date.now() - startTime; + if (protocol.ComputeContextResponse.isCancelled(response)) { + this.telemetrySender.sendRequestCancelledTelemetry(context, timeTaken); + } else if (protocol.ComputeContextResponse.isOk(response)) { + const body: protocol.ComputeContextResponse.OK = response.body; + const contextItemResult = new ContextItemResultBuilder(timeTaken); + const { resolved, cached, referenced, serverComputed } = this.runnableResultManager.update(document, documentVersion, position, context, body, contextRequestState); + contextItemResult.cachedItems += cached; + contextItemResult.referencedItems += referenced; + contextItemResult.serverComputed = serverComputed; + if (resolved.length > 0) { + // Update the stats for telemetry. + for (const runnableResult of resolved) { + for (const converted of contextItemResult.update(runnableResult)) { + forDebugging?.push(converted.item); + } + } + } + contextItemResult.updateResponse(body, token); + this.telemetrySender.sendRequestTelemetry(document, position, context, contextItemResult, timeTaken, { before: cacheState, after: this.runnableResultManager.getCacheState() }, undefined); + // eslint-disable-next-line local/code-no-unused-expressions + isDebugging && forDebugging?.length; + this._onCachePopulated.fire({ document, position, source: context.source, items: resolved, summary: contextItemResult }); + } else if (protocol.ComputeContextResponse.isError(response)) { + this.telemetrySender.sendRequestFailureTelemetry(context, response.body); + this.logService.error('Error populating cache:', response.body.message); + } + } catch (error) { + this.logService.error(error, `Error populating cache for document: ${document.uri.toString()} at position: ${position.line + 1}:${position.character + 1}`); + } + if (this.pendingRequest !== undefined) { + // We had a pending request. Clear it and try to populate the cache again. + const pendingRequest = this.pendingRequest; + this.pendingRequest = undefined; + const textEditor = vscode.window.activeTextEditor; + if (textEditor !== undefined) { + const document = textEditor.document; + if (document.uri.toString() === pendingRequest.document && document.version === pendingRequest.version && document.validatePosition(pendingRequest.position).isEqual(pendingRequest.position)) { + this.populateCache(document, pendingRequest.position, pendingRequest.context).catch(() => { /* handled in populateCache */ }); + } + } + } + } + + public async *getContext(document: vscode.TextDocument, position: vscode.Position, context: RequestContext, token: vscode.CancellationToken): AsyncIterable { + this.onTimeoutData = undefined; + if (document.languageId !== 'typescript' && document.languageId !== 'typescriptreact') { + return; + } + + const startTime = Date.now(); + let cacheRequest = 'none'; + const cachePopulationRequestInflight = this.inflightCachePopulationRequest !== undefined && this.inflightCachePopulationRequest.matchesDocument(document); + if (cachePopulationRequestInflight) { + this.onTimeoutData = new OnTimeoutData(document, position); + cacheRequest = 'inflight'; + } + if (token.isCancellationRequested) { + this.telemetrySender.sendRequestCancelledTelemetry(context, Date.now() - startTime); + return; + } + const isDebugging = this.isDebugging; + const forDebugging: ContextItem[] | undefined = isDebugging ? [] : undefined; + const contextItemResult = new ContextItemResultBuilder(Date.now() - startTime); + if (this.onTimeoutData !== undefined) { + this.onTimeoutData.resultBuilder = contextItemResult; + } + const characterBudget = this.getCharacterBudget(context, document); + // We first collect all items to yield so that the state of the cache doesn't change underneath us. + // This could otherwise happen if the cache population request finishes while we are yielding items. + const itemsToYield: ContextItem[] = []; + const { mandatory, optional, onTimeout } = this.getRunnables(document, position, cachePopulationRequestInflight); + if (this.onTimeoutData !== undefined) { + this.onTimeoutData.addRunnableResults(onTimeout); + } + outer: for (const runnableResult of mandatory) { + for (const { item, size } of contextItemResult.update(runnableResult, true)) { + forDebugging?.push(item); + characterBudget.spend(size); + if (characterBudget.isExhausted()) { + break outer; + } + itemsToYield.push(item); + } + } + if (!characterBudget.isOptionalExhausted()) { + outer: for (const runnableResult of optional) { + for (const { item, size } of contextItemResult.update(runnableResult, true)) { + forDebugging?.push(item); + characterBudget.spend(size); + if (characterBudget.isOptionalExhausted()) { + break outer; + } + itemsToYield.push(item); + } + } + } + if (!token.isCancellationRequested) { + for (const item of itemsToYield) { + if (token.isCancellationRequested) { + this.onTimeoutData = undefined; + break; + } + yield item; + } + + // Recheck for an inflight request and join it if it is for the same document and position. + if (this.inflightCachePopulationRequest !== undefined && this.inflightCachePopulationRequest.matchesDocument(document)) { + cacheRequest = 'inflight'; + // We have an inflight request for the same document and position. + // We wait for the server promise to resolve and then see if we can yield items from the + // inflight request. + const timeOut = Math.max(0, Math.min(context.timeBudget ?? TS6LanguageContextService.defaultCachePopulationRaceTimeout, TS6LanguageContextService.defaultCachePopulationRaceTimeout)); + const result = await Promise.race([this.inflightCachePopulationRequest.serverPromise, new Promise((resolve) => setTimeout(resolve, timeOut)).then(() => 'timedOut')]); + // The server promised resolved first. So the inflight request is done. + if (result !== 'timedOut') { + this.inflightCachePopulationRequest = undefined; + if (this.onTimeoutData !== undefined) { + this.onTimeoutData = undefined; + const runnableResults = this.runnableResultManager.getCachedRunnableResults(document, position, protocol.EmitMode.ClientBasedOnTimeout); + for (const runnableResult of runnableResults) { + for (const { item } of contextItemResult.update(runnableResult)) { + forDebugging?.push(item); + yield item; + } + } + cacheRequest = 'awaited'; + } + } + } + } else { + this.onTimeoutData = undefined; + } + + const isSpeculativeRequest = context.proposedEdits !== undefined; + if (isSpeculativeRequest) { + this.telemetrySender.sendSpeculativeRequestTelemetry(context, this.runnableResultManager.getRequestId() ?? 'unknown', contextItemResult.stats.yielded); + } else { + const cacheState = this.runnableResultManager.getCacheState(); + contextItemResult.path = this.runnableResultManager.getNodePath(); + contextItemResult.cancelled = token.isCancellationRequested; + contextItemResult.serverTime = 0; + contextItemResult.contextComputeTime = 0; + contextItemResult.fromCache = true; + this.telemetrySender.sendRequestTelemetry( + document, position, context, contextItemResult, Date.now() - startTime, + { before: cacheState, after: cacheState }, cacheRequest + ); + // eslint-disable-next-line local/code-no-unused-expressions + isDebugging && forDebugging?.length; + this._onContextComputed.fire({ + document, position, source: context.source, items: itemsToYield, summary: contextItemResult + }); + } + return; + } + + private getRunnables(document: vscode.TextDocument, position: vscode.Position, cachePopulationInflight: boolean): { mandatory: readonly ResolvedRunnableResult[]; optional: readonly ResolvedRunnableResult[]; onTimeout: readonly ResolvedRunnableResult[] } { + const mandatory: ResolvedRunnableResult[] = []; + const optional: ResolvedRunnableResult[] = []; + const onTimeout: ResolvedRunnableResult[] = []; + for (const runnable of this.runnableResultManager.getCachedRunnableResults(document, position)) { + if (cachePopulationInflight && runnable.cache?.emitMode === protocol.EmitMode.ClientBasedOnTimeout) { + onTimeout.push(runnable); + } else { + const priority = runnable.priority; + if (priority === protocol.Priorities.Expression || priority === protocol.Priorities.Locals || priority === protocol.Priorities.Inherited || priority === protocol.Priorities.Traits) { + mandatory.push(runnable); + } else { + optional.push(runnable); + } + } + } + return { mandatory, optional, onTimeout }; + } + + public getContextOnTimeout(document: vscode.TextDocument, position: vscode.Position, context: RequestContext): readonly ContextItem[] | undefined { + try { + if (this.onTimeoutData === undefined) { + return []; + } + if (!this.onTimeoutData.matches(document, position) || this.onTimeoutData.resultBuilder === undefined) { + return []; + } + const result: ContextItem[] = []; + const contextItemResult = this.onTimeoutData.resultBuilder; + for (const runnableResult of this.onTimeoutData.runnableResults) { + for (const { item } of contextItemResult.update(runnableResult, true)) { + result.push(item); + } + } + return result; + } finally { + this.onTimeoutData = undefined; + } + } +} diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/types.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/types.ts index 0f4bcdc3904eb9..97cbb6f396e548 100644 --- a/extensions/copilot/src/extension/typescriptContext/vscode-node/types.ts +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/types.ts @@ -8,6 +8,28 @@ import * as vscode from 'vscode'; import { ContextKind, type ContextItem, type ILanguageContextService } from '../../../platform/languageServer/common/languageContextService'; import * as protocol from '../common/serverProtocol'; +export enum ErrorLocation { + Client = 'client', + Server = 'server' +} + +export enum ErrorPart { + ServerPlugin = 'server-plugin', + TypescriptPlugin = 'typescript-plugin', + CopilotExtension = 'copilot-extension' +} + +export type CacheInfo = { + version: number; + state: CacheState; +}; + +export enum CacheState { + NotPopulated = 'NotPopulated', + PartiallyPopulated = 'PartiallyPopulated', + FullyPopulated = 'FullyPopulated' +} + export type ResolvedRunnableResult = { id: protocol.ContextRunnableResultId; state: protocol.ContextRunnableState; @@ -16,6 +38,7 @@ export type ResolvedRunnableResult = { cache?: protocol.CacheInfo; debugPath?: protocol.ContextRunnableResultId | undefined; }; + export namespace ResolvedRunnableResult { export function from(result: protocol.ContextRunnableResult, items: protocol.FullContextItem[]): ResolvedRunnableResult { return { @@ -29,6 +52,25 @@ export namespace ResolvedRunnableResult { } } +export enum ContextItemUsageMode { + minimal = 'minimal', + double = 'double', + fillHalf = 'fillHalf', + fill = 'fill' +} + +export namespace ContextItemUsageMode { + export function fromString(value: string): ContextItemUsageMode { + switch (value) { + case 'minimal': return ContextItemUsageMode.minimal; + case 'double': return ContextItemUsageMode.double; + case 'fillHalf': return ContextItemUsageMode.fillHalf; + case 'fill': return ContextItemUsageMode.fill; + default: return ContextItemUsageMode.minimal; + } + } +} + export type ContextComputedEvent = { document: vscode.TextDocument; position: vscode.Position; @@ -105,6 +147,7 @@ export interface ContextItemSummary { contextComputeTime: number; totalTime: number; } + export namespace ContextItemSummary { export const DefaultExhausted: ContextItemSummary = Object.freeze({ path: [0], @@ -230,4 +273,4 @@ export class ContextItemResultBuilder implements ContextItemSummary { } return undefined; } -} \ No newline at end of file +} diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index 80a2c7825ffa37..e1e51026645eda 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -1039,6 +1039,7 @@ export namespace ConfigKey { export const TypeScriptLanguageContextCacheTimeout = defineSetting('chat.languageContext.typescript.cacheTimeout', ConfigType.ExperimentBased, 500); export const TypeScriptLanguageContextFix = defineSetting('chat.languageContext.fix.typescript.enabled', ConfigType.ExperimentBased, false); export const TypeScriptLanguageContextInline = defineSetting('chat.languageContext.inline.typescript.enabled', ConfigType.ExperimentBased, false); + export const TypeScript7LanguageContext = defineSetting('chat.languageContext.typescript7.enabled', ConfigType.Simple, false); export const UseInstructionFiles = defineSetting('chat.codeGeneration.useInstructionFiles', ConfigType.Simple, true); export const ReviewAgent = defineSetting('chat.reviewAgent.enabled', ConfigType.Simple, true);