diff --git a/src/TODO.todo b/src/TODO.todo new file mode 100644 index 00000000..e625aa45 --- /dev/null +++ b/src/TODO.todo @@ -0,0 +1,25 @@ +TODO + ☐ NEW!!!! Test how long it takes to provide completions before and after splitting into two server. Test in lsp-server.ts and compare with VSCode + ☐ this.documents should live inside tspClient? + ☐ create TypeScriptServiceConfiguration that reads settings from `initializationSettings.tsServer`. Replace `TspClientOptions` passed to `TspClient`. + ✔ look into what Tracer does and whether it's worth implementing @done (22-09-12 23:48) + ☐ Events: + ☐ ConfigFileDiag + ☐ ProjectLanguageServiceState + ☐ ProjectsUpdatedInBackground + ☐ BeginInstallTypes / endInstallTypes + ☐ TypesInstallerInitializationFailed + ☐ ServerState + ☐ Request config.cancelOnResourceChange + +VSCode implementation +- new ElectronServiceProcessFactory() +- lazy_new TypeScriptServiceClientHost() +- receives ElectronServiceProcessFactory +- creates new TypeScriptServiceClient() +- creates new TypeScriptVersionManager() +- starts the server: ensureServiceStarted() -> startService() -> typescriptServerSpawner.spawn() + +- ProcessBasedTsServer <=> TspClient +- ProcessBasedTsServer implements ITypeScriptServer +- ElectronServiceProcessFactory implements TsServerProcessFactory diff --git a/src/commands/commandManager.ts b/src/commands/commandManager.ts new file mode 100644 index 00000000..dab7643b --- /dev/null +++ b/src/commands/commandManager.ts @@ -0,0 +1,39 @@ +// sync: file[extensions/typescript-language-features/src/commands/commandManager.ts] sha[f76ac124233270762d11ec3afaaaafcba53b3bbf] +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +/* + * Copyright (C) 2024 TypeFox and others. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + */ + +export interface Command { + readonly id: string; + + execute(...args: any[]): void | any; +} + +export class CommandManager { + private readonly commands = new Map(); + + public dispose(): void { + this.commands.clear(); + } + + public register(command: T): void { + const entry = this.commands.get(command.id); + if (!entry) { + this.commands.set(command.id, command); + } + } + + public handle(command: Command, ...args: any[]): void { + const entry = this.commands.get(command.id); + if (entry) { + entry.execute(...args); + } + } +} diff --git a/src/diagnostic-queue.ts b/src/diagnostic-queue.ts index c29d9722..1d0bfc66 100644 --- a/src/diagnostic-queue.ts +++ b/src/diagnostic-queue.ts @@ -72,7 +72,7 @@ class FileDiagnostics { } } -export class DiagnosticEventQueue { +export class DiagnosticsManager { protected readonly diagnostics = new Map(); private ignoredDiagnosticCodes: Set = new Set(); diff --git a/src/features/codeActions/codeActionManager.ts b/src/features/codeActions/codeActionManager.ts new file mode 100644 index 00000000..adbfaa6b --- /dev/null +++ b/src/features/codeActions/codeActionManager.ts @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2024 TypeFox and others. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + */ + +import * as lsp from 'vscode-languageserver'; +import { type ITypeScriptServiceClient } from '../../typescriptService.js'; +import type FileConfigurationManager from '../fileConfigurationManager.js'; +import { CommandManager } from '../../commands/commandManager.js'; +import { LspDocument } from '../../document.js'; +import { type CodeActionProvider, type TsCodeAction } from './codeActionProvider.js'; +import { TypeScriptAutoFixProvider } from './fixAll.js'; +import { TypeScriptQuickFixProvider } from './quickFix.js'; +import { nulToken } from '../../utils/cancellation.js'; +import { type SupportedFeatures } from '../../ts-protocol.js'; +import { DiagnosticsManager } from '../../diagnostic-queue.js'; + +interface ResolveData { + globalId?: number; + providerId?: number; +} + +/** + * Requests code actions from registered providers and ensures that returned code actions have global IDs assigned + * and are cached for the purpose of codeAction/resolve request. + */ +export class CodeActionManager { + private providerMap = new Map; + private nextProviderId = 1; + // global id => TsCodeAction map need for the resolve request + // TODO: make it bound + private tsCodeActionsMap = new Map; + private nextGlobalCodeActionId = 1; + + constructor( + client: ITypeScriptServiceClient, + fileConfigurationManager: FileConfigurationManager, + commandManager: CommandManager, + diagnosticsManager: DiagnosticsManager, + private readonly features: SupportedFeatures, + ) { + this.addProvider(new TypeScriptAutoFixProvider(client, fileConfigurationManager, diagnosticsManager)); + this.addProvider(new TypeScriptQuickFixProvider(client, fileConfigurationManager, commandManager, diagnosticsManager, features)); + } + + public get kinds(): lsp.CodeActionKind[] { + const allKinds: lsp.CodeActionKind[] = []; + + for (const [_, provider] of this.providerMap) { + allKinds.push(...provider.getMetadata().providedCodeActionKinds || []); + } + + return allKinds; + } + + public async provideCodeActions(document: LspDocument, range: lsp.Range, context: lsp.CodeActionContext, token?: lsp.CancellationToken): Promise<(lsp.Command | lsp.CodeAction)[]> { + const allCodeActions: (lsp.Command | lsp.CodeAction)[] = []; + + for (const [providerId, provider] of this.providerMap.entries()) { + const codeActions = await provider.provideCodeActions(document, range, context, token || nulToken); + if (!codeActions) { + continue; + } + + for (const action of codeActions) { + if (lsp.Command.is(action)) { + allCodeActions.push(action); + continue; + } + + const lspCodeAction = action.toLspCodeAction(); + + if (provider.isCodeActionResolvable(action)) { + const globalId = this.nextGlobalCodeActionId++; + this.tsCodeActionsMap.set(globalId, action); + lspCodeAction.data = { + globalId, + providerId, + } satisfies ResolveData; + } + + allCodeActions.push(lspCodeAction); + } + } + + return allCodeActions; + } + + public async resolveCodeAction(codeAction: lsp.CodeAction, token?: lsp.CancellationToken): Promise { + if (!this.features.codeActionResolveSupport) { + return codeAction; + } + + const { globalId, providerId } = codeAction.data as ResolveData || {}; + if (globalId === undefined || providerId === undefined) { + return codeAction; + } + + const provider = this.providerMap.get(providerId); + if (!provider || !provider.resolveCodeAction) { + return codeAction; + } + + const tsCodeAction = this.tsCodeActionsMap.get(globalId); + if (!tsCodeAction || !providerId) { + return codeAction; + } + + const resolvedTsCodeAction = await provider.resolveCodeAction(tsCodeAction, token || nulToken); + if (!resolvedTsCodeAction) { + return codeAction; + } + + const lspCodeAction = resolvedTsCodeAction.toLspCodeAction(); + for (const property of this.features.codeActionResolveSupport.properties as Array) { + if (property in lspCodeAction) { + codeAction[property] = lspCodeAction[property]; + } + } + + return codeAction; + } + + private addProvider(provider: CodeActionProvider): void { + this.providerMap.set(this.nextProviderId++, provider); + } +} diff --git a/src/features/codeActions/codeActionProvider.ts b/src/features/codeActions/codeActionProvider.ts new file mode 100644 index 00000000..5bfc46f3 --- /dev/null +++ b/src/features/codeActions/codeActionProvider.ts @@ -0,0 +1,99 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +/* + * Copyright (C) 2022 TypeFox and others. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + */ + +import * as lsp from 'vscode-languageserver'; +import type { LspDocument } from '../../document.js'; + +export class TsCodeAction implements lsp.CodeAction { + public command: lsp.CodeAction['command']; + public diagnostics: lsp.CodeAction['diagnostics']; + public disabled: lsp.CodeAction['disabled']; + public edit: lsp.CodeAction['edit']; + public isPreferred: lsp.CodeAction['isPreferred']; + + constructor( + public readonly title: string, + public readonly kind: lsp.CodeActionKind, + ) { + } + + toLspCodeAction(): lsp.CodeAction { + const codeAction = lsp.CodeAction.create(this.title, this.kind); + + if (this.command !== undefined) { + codeAction.command = this.command; + } + if (this.diagnostics !== undefined) { + codeAction.diagnostics = this.diagnostics; + } + if (this.disabled !== undefined) { + codeAction.disabled = this.disabled; + } + if (this.edit !== undefined) { + codeAction.edit = this.edit; + } + if (this.isPreferred !== undefined) { + codeAction.isPreferred = this.isPreferred; + } + + return codeAction; + } +} + +type ProviderResult = T | undefined | null | Thenable; + +/** + * Provides contextual actions for code. Code actions typically either fix problems or beautify/refactor code. + */ +export interface CodeActionProvider { + getMetadata(): CodeActionProviderMetadata; + + /** + * Get code actions for a given range in a document. + */ + provideCodeActions(document: LspDocument, range: lsp.Range, context: lsp.CodeActionContext, token: lsp.CancellationToken): ProviderResult<(lsp.Command | T)[]>; + + /** + * Whether given code action can be resolved with `resolveCodeAction`. + */ + isCodeActionResolvable(codeAction: T): boolean; + + /** + * Given a code action fill in its {@linkcode CodeAction.edit edit}-property. Changes to + * all other properties, like title, are ignored. A code action that has an edit + * will not be resolved. + * + * *Note* that a code action provider that returns commands, not code actions, cannot successfully + * implement this function. Returning commands is deprecated and instead code actions should be + * returned. + * + * @param codeAction A code action. + * @param token A cancellation token. + * @returns The resolved code action or a thenable that resolves to such. It is OK to return the given + * `item`. When no result is returned, the given `item` will be used. + */ + resolveCodeAction?(codeAction: T, token: lsp.CancellationToken): ProviderResult; +} + +/** + * Metadata about the type of code actions that a {@link CodeActionProvider} provides. + */ +export interface CodeActionProviderMetadata { + /** + * List of {@link CodeActionKind CodeActionKinds} that a {@link CodeActionProvider} may return. + * + * This list is used to determine if a given `CodeActionProvider` should be invoked or not. + * To avoid unnecessary computation, every `CodeActionProvider` should list use `providedCodeActionKinds`. The + * list of kinds may either be generic, such as `[CodeActionKind.Refactor]`, or list out every kind provided, + * such as `[CodeActionKind.Refactor.Extract.append('function'), CodeActionKind.Refactor.Extract.append('constant'), ...]`. + */ + readonly providedCodeActionKinds?: readonly lsp.CodeActionKind[]; +} diff --git a/src/features/codeActions/fixAll.ts b/src/features/codeActions/fixAll.ts new file mode 100644 index 00000000..396784a6 --- /dev/null +++ b/src/features/codeActions/fixAll.ts @@ -0,0 +1,266 @@ +// sync: file[extensions/typescript-language-features/src/languageFeatures/fixAll.ts] sha[f76ac124233270762d11ec3afaaaafcba53b3bbf] +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +/* + * Copyright (C) 2024 TypeFox and others. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + */ + +import * as lsp from 'vscode-languageserver'; +import { type ts, CommandTypes } from '../../ts-protocol.js'; +import * as errorCodes from '../../utils/errorCodes.js'; +import * as fixNames from '../../utils/fixNames.js'; +import * as typeConverters from '../../utils/typeConverters.js'; +import { type ITypeScriptServiceClient } from '../../typescriptService.js'; +// import { DiagnosticsManager } from './diagnostics.js'; +import FileConfigurationManager from '../fileConfigurationManager.js'; +import { CodeActionProvider, CodeActionProviderMetadata, TsCodeAction } from './codeActionProvider.js'; +import { CodeActionKind } from '../../utils/types.js'; +import type { LspDocument } from '../../document.js'; +import { toTextDocumentEdit } from '../../protocol-translation.js'; +import { type DiagnosticsManager } from '../../diagnostic-queue.js'; + +interface AutoFix { + readonly codes: Set; + readonly fixName: string; +} + +async function buildIndividualFixes( + fixes: readonly AutoFix[], + client: ITypeScriptServiceClient, + file: string, + diagnostics: readonly lsp.Diagnostic[], + token: lsp.CancellationToken, +): Promise { + const documentChanges: lsp.TextDocumentEdit[] = []; + + for (const diagnostic of diagnostics) { + for (const { codes, fixName } of fixes) { + if (token.isCancellationRequested) { + return; + } + + if (!codes.has(diagnostic.code as number)) { + continue; + } + + const args: ts.server.protocol.CodeFixRequestArgs = { + ...typeConverters.Range.toFileRangeRequestArgs(file, diagnostic.range), + errorCodes: [+(diagnostic.code!)], + }; + + const response = await client.execute(CommandTypes.GetCodeFixes, args, token); + if (response.type !== 'response') { + continue; + } + + const fix = response.body?.find(fix => fix.fixName === fixName); + if (fix) { + documentChanges.push(...fix.changes.map(change => toTextDocumentEdit(change, client))); + break; + } + } + } + + return { + documentChanges, + }; +} + +async function buildCombinedFix( + fixes: readonly AutoFix[], + client: ITypeScriptServiceClient, + file: string, + diagnostics: readonly lsp.Diagnostic[], + token: lsp.CancellationToken, +): Promise { + for (const diagnostic of diagnostics) { + for (const { codes, fixName } of fixes) { + if (token.isCancellationRequested) { + return; + } + + if (!codes.has(diagnostic.code as number)) { + continue; + } + + const args: ts.server.protocol.CodeFixRequestArgs = { + ...typeConverters.Range.toFileRangeRequestArgs(file, diagnostic.range), + errorCodes: [+(diagnostic.code!)], + }; + + const response = await client.execute(CommandTypes.GetCodeFixes, args, token); + if (response.type !== 'response' || !response.body?.length) { + continue; + } + + const fix = response.body?.find(fix => fix.fixName === fixName); + if (!fix) { + continue; + } + + if (!fix.fixId) { + return { + documentChanges: fix.changes.map(change => toTextDocumentEdit(change, client)), + } satisfies lsp.WorkspaceEdit; + } + + const combinedArgs: ts.server.protocol.GetCombinedCodeFixRequestArgs = { + scope: { + type: 'file', + args: { file }, + }, + fixId: fix.fixId, + }; + + const combinedResponse = await client.execute(CommandTypes.GetCombinedCodeFix, combinedArgs, token); + if (combinedResponse.type !== 'response' || !combinedResponse.body) { + return; + } + + return { + documentChanges: combinedResponse.body.changes.map(change => toTextDocumentEdit(change, client)), + } satisfies lsp.WorkspaceEdit; + } + } +} + +// #region Source Actions + +abstract class SourceAction extends TsCodeAction { + abstract build( + client: ITypeScriptServiceClient, + file: string, + diagnostics: readonly lsp.Diagnostic[], + token: lsp.CancellationToken, + ): Promise; +} + +class SourceFixAll extends SourceAction { + static readonly kind = CodeActionKind.SourceFixAllTs; + + constructor() { + super('Fix all fixable JS/TS issues', SourceFixAll.kind.value); + } + + async build(client: ITypeScriptServiceClient, file: string, diagnostics: readonly lsp.Diagnostic[], token: lsp.CancellationToken): Promise { + this.edit = await buildIndividualFixes([ + { codes: errorCodes.incorrectlyImplementsInterface, fixName: fixNames.classIncorrectlyImplementsInterface }, + { codes: errorCodes.asyncOnlyAllowedInAsyncFunctions, fixName: fixNames.awaitInSyncFunction }, + ], client, file, diagnostics, token); + + const edits = await buildCombinedFix([ + { codes: errorCodes.unreachableCode, fixName: fixNames.unreachableCode }, + ], client, file, diagnostics, token); + if (edits?.documentChanges) { + this.edit?.documentChanges?.push(...edits.documentChanges); + } + } +} + +class SourceRemoveUnused extends SourceAction { + static readonly kind = CodeActionKind.SourceRemoveUnusedTs; + + constructor() { + super('Remove all unused code', SourceRemoveUnused.kind.value); + } + + async build(client: ITypeScriptServiceClient, file: string, diagnostics: readonly lsp.Diagnostic[], token: lsp.CancellationToken): Promise { + this.edit = await buildCombinedFix([ + { codes: errorCodes.variableDeclaredButNeverUsed, fixName: fixNames.unusedIdentifier }, + ], client, file, diagnostics, token); + } +} + +class SourceAddMissingImports extends SourceAction { + static readonly kind = CodeActionKind.SourceAddMissingImportsTs; + + constructor() { + super('Add all missing imports', SourceAddMissingImports.kind.value); + } + + async build(client: ITypeScriptServiceClient, file: string, diagnostics: readonly lsp.Diagnostic[], token: lsp.CancellationToken): Promise { + this.edit = await buildCombinedFix([ + { codes: errorCodes.cannotFindName, fixName: fixNames.fixImport }, + ], client, file, diagnostics, token); + } +} + +//#endregion + +export class TypeScriptAutoFixProvider implements CodeActionProvider { + private static readonly kindProviders = [ + SourceFixAll, + SourceRemoveUnused, + SourceAddMissingImports, + ]; + + constructor( + private readonly client: ITypeScriptServiceClient, + private readonly fileConfigurationManager: FileConfigurationManager, + private readonly diagnosticsManager: DiagnosticsManager, + ) { } + + public getMetadata(): CodeActionProviderMetadata { + return { + providedCodeActionKinds: TypeScriptAutoFixProvider.kindProviders.map(x => x.kind.value), + }; + } + + public async provideCodeActions( + document: LspDocument, + _range: lsp.Range, + context: lsp.CodeActionContext, + token: lsp.CancellationToken, + ): Promise { + if (!context.only?.length) { + return undefined; + } + + const sourceKinds = context.only + .map(kind => new CodeActionKind(kind)) + .filter(codeActionKind => CodeActionKind.Source.intersects(codeActionKind)); + if (!sourceKinds.length) { + return undefined; + } + + // TODO: Since we rely on diagnostics pointing at errors in the correct places, we can't proceed if we are not + // sure that diagnostics are up-to-date. Thus we check if there are pending diagnostic requests for the file. + // In general would be better to replace the whole diagnostics handling logic with the one from + // bufferSyncSupport.ts in VSCode's typescript language features. + if (this.client.hasPendingDiagnostics(document.uri)) { + return undefined; + } + + const actions = this.getFixAllActions(sourceKinds); + const diagnostics = this.diagnosticsManager.getDiagnosticsForFile(document.filepath); + if (!diagnostics.length) { + // Actions are a no-op in this case but we still want to return them + return actions; + } + + await this.fileConfigurationManager.ensureConfigurationForDocument(document, token); + + if (token.isCancellationRequested) { + return undefined; + } + + await Promise.all(actions.map(action => action.build(this.client, document.filepath, diagnostics, token))); + + return actions; + } + + public isCodeActionResolvable(_codeAction: TsCodeAction): boolean { + return false; + } + + private getFixAllActions(kinds: CodeActionKind[]): SourceAction[] { + return TypeScriptAutoFixProvider.kindProviders + .filter(provider => kinds.some(only => only.intersects(provider.kind))) + .map(provider => new provider()); + } +} diff --git a/src/features/codeActions/quickFix.ts b/src/features/codeActions/quickFix.ts new file mode 100644 index 00000000..5e1715e9 --- /dev/null +++ b/src/features/codeActions/quickFix.ts @@ -0,0 +1,440 @@ +// sync: file[extensions/typescript-language-features/src/languageFeatures/quickFix.ts] sha[f76ac124233270762d11ec3afaaaafcba53b3bbf] +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +/* + * Copyright (C) 2024 TypeFox and others. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + */ + +/* eslint-disable @typescript-eslint/ban-types */ + +import * as lsp from 'vscode-languageserver'; +import { URI } from 'vscode-uri'; +import { type ts, CommandTypes, type SupportedFeatures } from '../../ts-protocol.js'; +import { type Command, CommandManager } from '../../commands/commandManager.js'; +import * as fixNames from '../../utils/fixNames.js'; +import * as typeConverters from '../../utils/typeConverters.js'; +import { type ITypeScriptServiceClient } from '../../typescriptService.js'; +import { memoize } from '../../utils/memoize.js'; +import { equals } from '../../utils/objects.js'; +// import { DiagnosticsManager } from './diagnostics.js'; +import FileConfigurationManager from '../fileConfigurationManager.js'; +import { applyCodeActionCommands, getEditForCodeAction } from '../util/codeAction.js'; +import { CodeActionProvider, CodeActionProviderMetadata, TsCodeAction } from './codeActionProvider.js'; +import { LspDocument } from '../../document.js'; +import { toTextDocumentEdit } from '../../protocol-translation.js'; +import { CodeActionKind } from '../../utils/types.js'; +import { type DiagnosticsManager } from '../../diagnostic-queue.js'; + +type ApplyCodeActionCommand_args = { + readonly documentUri: string; + readonly diagnostic: lsp.Diagnostic; + readonly action: ts.server.protocol.CodeFixAction; +}; + +class ApplyCodeActionCommand implements Command { + public static readonly ID = '_typescript.applyCodeActionCommand'; + public readonly id = ApplyCodeActionCommand.ID; + + constructor( + private readonly client: ITypeScriptServiceClient, + // private readonly diagnosticManager: DiagnosticsManager, + ) { } + + // public async execute({ documentUri, action, diagnostic }: ApplyCodeActionCommand_args): Promise { + public async execute({ action }: ApplyCodeActionCommand_args): Promise { + // this.diagnosticManager.deleteDiagnostic(documentUri, diagnostic); + const codeActionResult = await applyCodeActionCommands(this.client, action.commands); + return codeActionResult; + } +} + +type ApplyFixAllCodeAction_args = { + readonly action: TsQuickFixAllCodeAction; +}; + +class ApplyFixAllCodeAction implements Command { + public static readonly ID = '_typescript.applyFixAllCodeAction'; + public readonly id = ApplyFixAllCodeAction.ID; + + constructor( + private readonly client: ITypeScriptServiceClient, + ) { } + + public async execute(args: ApplyFixAllCodeAction_args): Promise { + if (args.action.combinedResponse) { + await applyCodeActionCommands(this.client, args.action.combinedResponse.body.commands); + } + } +} + +/** + * Unique set of diagnostics keyed on diagnostic range and error code. + */ +class DiagnosticsSet { + public static from(diagnostics: lsp.Diagnostic[]) { + const values = new Map(); + for (const diagnostic of diagnostics) { + values.set(DiagnosticsSet.key(diagnostic), diagnostic); + } + return new DiagnosticsSet(values); + } + + private static key(diagnostic: lsp.Diagnostic) { + const { start, end } = diagnostic.range; + return `${diagnostic.code}-${start.line},${start.character}-${end.line},${end.character}`; + } + + private constructor( + private readonly _values: Map, + ) { } + + public get values(): Iterable { + return this._values.values(); + } + + public get size() { + return this._values.size; + } +} + +class TsQuickFixCodeAction extends TsCodeAction { + constructor( + public readonly tsAction: ts.server.protocol.CodeFixAction, + title: string, + kind: lsp.CodeActionKind, + ) { + super(title, kind); + } +} + +class TsQuickFixAllCodeAction extends TsQuickFixCodeAction { + constructor( + tsAction: ts.server.protocol.CodeFixAction, + public readonly file: string, + title: string, + kind: lsp.CodeActionKind, + ) { + super(tsAction, title, kind); + } + + public combinedResponse?: ts.server.protocol.GetCombinedCodeFixResponse; +} + +class CodeActionSet { + private readonly _actions = new Set(); + private readonly _fixAllActions = new Map<{}, TsQuickFixCodeAction>(); + private readonly _aiActions = new Set(); + + public *values(): Iterable { + yield* this._actions; + yield* this._aiActions; + } + + public addAction(action: TsQuickFixCodeAction) { + for (const existing of this._actions) { + if (action.tsAction.fixName === existing.tsAction.fixName && equals(action.edit, existing.edit)) { + this._actions.delete(existing); + } + } + + this._actions.add(action); + + if (action.tsAction.fixId) { + // If we have an existing fix all action, then make sure it follows this action + const existingFixAll = this._fixAllActions.get(action.tsAction.fixId); + if (existingFixAll) { + this._actions.delete(existingFixAll); + this._actions.add(existingFixAll); + } + } + } + + public addFixAllAction(fixId: {}, action: TsQuickFixCodeAction) { + const existing = this._fixAllActions.get(fixId); + if (existing) { + // reinsert action at back of actions list + this._actions.delete(existing); + } + this.addAction(action); + this._fixAllActions.set(fixId, action); + } + + public hasFixAllAction(fixId: {}) { + return this._fixAllActions.has(fixId); + } +} + +class SupportedCodeActionProvider { + public constructor( + private readonly client: ITypeScriptServiceClient, + ) { } + + public async getFixableDiagnosticsForContext(diagnostics: readonly lsp.Diagnostic[]): Promise { + const fixableCodes = await this.fixableDiagnosticCodes; + return DiagnosticsSet.from( + diagnostics.filter(diagnostic => typeof diagnostic.code !== 'undefined' && fixableCodes.has(diagnostic.code + ''))); + } + + @memoize + private get fixableDiagnosticCodes(): Thenable> { + return this.client.execute(CommandTypes.GetSupportedCodeFixes, null) + .then(response => response.type === 'response' ? response.body || [] : []) + .then(codes => new Set(codes)); + } +} + +export class TypeScriptQuickFixProvider implements CodeActionProvider { + private static readonly _maxCodeActionsPerFile: number = 1000; + private readonly supportedCodeActionProvider: SupportedCodeActionProvider; + + constructor( + private readonly client: ITypeScriptServiceClient, + private readonly fileConfigurationManager: FileConfigurationManager, + commandManager: CommandManager, + private readonly diagnosticsManager: DiagnosticsManager, + private features: SupportedFeatures, + ) { + commandManager.register(new ApplyCodeActionCommand(client/*, diagnosticsManager*/)); + commandManager.register(new ApplyFixAllCodeAction(client)); + + this.supportedCodeActionProvider = new SupportedCodeActionProvider(client); + } + + public getMetadata(): CodeActionProviderMetadata { + return { + providedCodeActionKinds: [CodeActionKind.QuickFix.value], + }; + } + + public async provideCodeActions( + document: LspDocument, + range: lsp.Range, + context: lsp.CodeActionContext, + token: lsp.CancellationToken, + ): Promise { + let diagnostics = context.diagnostics; + if (this.client.hasPendingDiagnostics(document.uri)) { + // Delay for 500ms when there are pending diagnostics before recomputing up-to-date diagnostics. + await new Promise((resolve) => { + setTimeout(resolve, 500); + }); + + if (token.isCancellationRequested) { + return; + } + const allDiagnostics: lsp.Diagnostic[] = []; + + // // Match ranges again after getting new diagnostics + for (const diagnostic of this.diagnosticsManager.getDiagnosticsForFile(document.filepath)) { + if (typeConverters.Range.intersection(range, diagnostic.range)) { + const newLen = allDiagnostics.push(diagnostic); + if (newLen > TypeScriptQuickFixProvider._maxCodeActionsPerFile) { + break; + } + } + } + diagnostics = allDiagnostics; + } + + const fixableDiagnostics = await this.supportedCodeActionProvider.getFixableDiagnosticsForContext(diagnostics); + if (!fixableDiagnostics.size || token.isCancellationRequested) { + return; + } + + await this.fileConfigurationManager.ensureConfigurationForDocument(document, token); + if (token.isCancellationRequested) { + return; + } + + const results = new CodeActionSet(); + for (const diagnostic of fixableDiagnostics.values) { + await this.getFixesForDiagnostic(document, diagnostic, results, token); + if (token.isCancellationRequested) { + return; + } + } + + const allActions = Array.from(results.values()); + for (const action of allActions) { + action.isPreferred = isPreferredFix(action, allActions); + } + return allActions; + } + + public isCodeActionResolvable(codeAction: TsQuickFixCodeAction): codeAction is TsQuickFixAllCodeAction { + return codeAction instanceof TsQuickFixAllCodeAction; + } + + public async resolveCodeAction(codeAction: TsQuickFixCodeAction, token: lsp.CancellationToken): Promise { + if (!this.isCodeActionResolvable(codeAction) || !codeAction.tsAction.fixId) { + return codeAction; + } + + const arg: ts.server.protocol.GetCombinedCodeFixRequestArgs = { + scope: { + type: 'file', + args: { file: codeAction.file }, + }, + fixId: codeAction.tsAction.fixId, + }; + + const response = await this.client.execute(CommandTypes.GetCombinedCodeFix, arg, token); + if (response.type === 'response') { + codeAction.combinedResponse = response; + codeAction.edit = { documentChanges: response.body.changes.map(change => toTextDocumentEdit(change, this.client)) }; + } + + return codeAction; + } + + private async getFixesForDiagnostic( + document: LspDocument, + diagnostic: lsp.Diagnostic, + results: CodeActionSet, + token: lsp.CancellationToken, + ): Promise { + const args: ts.server.protocol.CodeFixRequestArgs = { + ...typeConverters.Range.toFileRangeRequestArgs(document.filepath, diagnostic.range), + errorCodes: [+(diagnostic.code!)], + }; + const response = await this.client.execute(CommandTypes.GetCodeFixes, args, token); + if (response.type !== 'response' || !response.body) { + return results; + } + + for (const tsCodeFix of response.body) { + for (const action of this.getFixesForTsCodeAction(document, diagnostic, tsCodeFix)) { + results.addAction(action); + } + if (this.features.codeActionResolveSupport) { + this.addFixAllForTsCodeAction(results, document.uri, document.filepath, diagnostic, tsCodeFix); + } + } + return results; + } + + private getFixesForTsCodeAction( + document: LspDocument, + diagnostic: lsp.Diagnostic, + action: ts.server.protocol.CodeFixAction, + ): TsQuickFixCodeAction[] { + const actions: TsQuickFixCodeAction[] = []; + const codeAction = new TsQuickFixCodeAction(action, action.description, lsp.CodeActionKind.QuickFix); + codeAction.edit = getEditForCodeAction(this.client, action); + codeAction.diagnostics = [diagnostic]; + codeAction.command = { + command: ApplyCodeActionCommand.ID, + arguments: [{ action, diagnostic, documentUri: document.uri.toString() } satisfies ApplyCodeActionCommand_args], + title: '', + }; + actions.push(codeAction); + return actions; + } + + private addFixAllForTsCodeAction( + results: CodeActionSet, + _resource: URI, + file: string, + diagnostic: lsp.Diagnostic, + tsAction: ts.server.protocol.CodeFixAction, + ): CodeActionSet { + if (!tsAction.fixId || results.hasFixAllAction(tsAction.fixId)) { + return results; + } + + // Make sure there are multiple diagnostics of the same type in the file + if (!this.diagnosticsManager.getDiagnosticsForFile(file).some(x => { + if (x === diagnostic) { + return false; + } + return x.code === diagnostic.code + || fixAllErrorCodes.has(x.code as number) && fixAllErrorCodes.get(x.code as number) === fixAllErrorCodes.get(diagnostic.code as number); + })) { + return results; + } + + const action = new TsQuickFixAllCodeAction( + tsAction, + file, + tsAction.fixAllDescription || `${tsAction.description} (Fix all in file)`, + lsp.CodeActionKind.QuickFix); + + action.diagnostics = [diagnostic]; + // TODO: Recursive property causes error on stringifying. + // action.command = { + // command: ApplyFixAllCodeAction.ID, + // arguments: [{ action } satisfies ApplyFixAllCodeAction_args], + // title: '', + // }; + results.addFixAllAction(tsAction.fixId, action); + return results; + } +} + +// Some fix all actions can actually fix multiple differnt diagnostics. Make sure we still show the fix all action +// in such cases +const fixAllErrorCodes = new Map([ + // Missing async + [2339, 2339], + [2345, 2339], +]); + +const preferredFixes = new Map([ + [fixNames.annotateWithTypeFromJSDoc, { priority: 2 }], + [fixNames.constructorForDerivedNeedSuperCall, { priority: 2 }], + [fixNames.extendsInterfaceBecomesImplements, { priority: 2 }], + [fixNames.awaitInSyncFunction, { priority: 2 }], + [fixNames.removeUnnecessaryAwait, { priority: 2 }], + [fixNames.classIncorrectlyImplementsInterface, { priority: 3 }], + [fixNames.classDoesntImplementInheritedAbstractMember, { priority: 3 }], + [fixNames.unreachableCode, { priority: 2 }], + [fixNames.unusedIdentifier, { priority: 2 }], + [fixNames.forgottenThisPropertyAccess, { priority: 2 }], + [fixNames.spelling, { priority: 0 }], + [fixNames.addMissingAwait, { priority: 2 }], + [fixNames.addMissingOverride, { priority: 2 }], + [fixNames.addMissingNewOperator, { priority: 2 }], + [fixNames.fixImport, { priority: 1, thereCanOnlyBeOne: true }], +]); + +function isPreferredFix( + action: TsQuickFixCodeAction, + allActions: readonly TsQuickFixCodeAction[], +): boolean { + if (action instanceof TsQuickFixAllCodeAction) { + return false; + } + + const fixPriority = preferredFixes.get(action.tsAction.fixName); + if (!fixPriority) { + return false; + } + + return allActions.every(otherAction => { + if (otherAction === action) { + return true; + } + + if (otherAction instanceof TsQuickFixAllCodeAction) { + return true; + } + + const otherFixPriority = preferredFixes.get(otherAction.tsAction.fixName); + if (!otherFixPriority || otherFixPriority.priority < fixPriority.priority) { + return true; + } else if (otherFixPriority.priority > fixPriority.priority) { + return false; + } + + if (fixPriority.thereCanOnlyBeOne && action.tsAction.fixName === otherAction.tsAction.fixName) { + return false; + } + + return true; + }); +} diff --git a/src/features/fix-all.ts b/src/features/fix-all.ts deleted file mode 100644 index 9807d030..00000000 --- a/src/features/fix-all.ts +++ /dev/null @@ -1,207 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as lsp from 'vscode-languageserver'; -import { toTextDocumentEdit } from '../protocol-translation.js'; -import { CommandTypes } from '../ts-protocol.js'; -import type { ts } from '../ts-protocol.js'; -import { TsClient } from '../ts-client.js'; -import * as errorCodes from '../utils/errorCodes.js'; -import * as fixNames from '../utils/fixNames.js'; -import { CodeActionKind } from '../utils/types.js'; -import { Range } from '../utils/typeConverters.js'; - -interface AutoFix { - readonly codes: Set; - readonly fixName: string; -} - -async function buildIndividualFixes( - fixes: readonly AutoFix[], - client: TsClient, - file: string, - diagnostics: readonly lsp.Diagnostic[], -): Promise { - const edits: lsp.TextDocumentEdit[] = []; - for (const diagnostic of diagnostics) { - for (const { codes, fixName } of fixes) { - if (!codes.has(diagnostic.code as number)) { - continue; - } - - const args: ts.server.protocol.CodeFixRequestArgs = { - ...Range.toFileRangeRequestArgs(file, diagnostic.range), - errorCodes: [+diagnostic.code!], - }; - - const response = await client.execute(CommandTypes.GetCodeFixes, args); - if (response.type !== 'response') { - continue; - } - - const fix = response.body?.find(fix => fix.fixName === fixName); - if (fix) { - edits.push(...fix.changes.map(change => toTextDocumentEdit(change, client))); - break; - } - } - } - return edits; -} - -async function buildCombinedFix( - fixes: readonly AutoFix[], - client: TsClient, - file: string, - diagnostics: readonly lsp.Diagnostic[], -): Promise { - const edits: lsp.TextDocumentEdit[] = []; - for (const diagnostic of diagnostics) { - for (const { codes, fixName } of fixes) { - if (!codes.has(diagnostic.code as number)) { - continue; - } - - const args: ts.server.protocol.CodeFixRequestArgs = { - ...Range.toFileRangeRequestArgs(file, diagnostic.range), - errorCodes: [+diagnostic.code!], - }; - - const response = await client.execute(CommandTypes.GetCodeFixes, args); - if (response.type !== 'response' || !response.body?.length) { - continue; - } - - const fix = response.body?.find(fix => fix.fixName === fixName); - if (!fix) { - continue; - } - - if (!fix.fixId) { - edits.push(...fix.changes.map(change => toTextDocumentEdit(change, client))); - return edits; - } - - const combinedArgs: ts.server.protocol.GetCombinedCodeFixRequestArgs = { - scope: { - type: 'file', - args: { file }, - }, - fixId: fix.fixId, - }; - - const combinedResponse = await client.execute(CommandTypes.GetCombinedCodeFix, combinedArgs); - if (combinedResponse.type !== 'response' || !combinedResponse.body) { - return edits; - } - - edits.push(...combinedResponse.body.changes.map(change => toTextDocumentEdit(change, client))); - return edits; - } - } - return edits; -} - -// #region Source Actions - -abstract class SourceAction { - abstract build( - client: TsClient, - file: string, - diagnostics: readonly lsp.Diagnostic[] - ): Promise; -} - -class SourceFixAll extends SourceAction { - private readonly title = 'Fix all'; - static readonly kind = CodeActionKind.SourceFixAllTs; - - async build( - client: TsClient, - file: string, - diagnostics: readonly lsp.Diagnostic[], - ): Promise { - const edits: lsp.TextDocumentEdit[] = []; - edits.push(...await buildIndividualFixes([ - { codes: errorCodes.incorrectlyImplementsInterface, fixName: fixNames.classIncorrectlyImplementsInterface }, - { codes: errorCodes.asyncOnlyAllowedInAsyncFunctions, fixName: fixNames.awaitInSyncFunction }, - ], client, file, diagnostics)); - edits.push(...await buildCombinedFix([ - { codes: errorCodes.unreachableCode, fixName: fixNames.unreachableCode }, - ], client, file, diagnostics)); - if (!edits.length) { - return null; - } - return lsp.CodeAction.create(this.title, { documentChanges: edits }, SourceFixAll.kind.value); - } -} - -class SourceRemoveUnused extends SourceAction { - private readonly title = 'Remove all unused code'; - static readonly kind = CodeActionKind.SourceRemoveUnusedTs; - - async build( - client: TsClient, - file: string, - diagnostics: readonly lsp.Diagnostic[], - ): Promise { - const edits = await buildCombinedFix([ - { codes: errorCodes.variableDeclaredButNeverUsed, fixName: fixNames.unusedIdentifier }, - ], client, file, diagnostics); - if (!edits.length) { - return null; - } - return lsp.CodeAction.create(this.title, { documentChanges: edits }, SourceRemoveUnused.kind.value); - } -} - -class SourceAddMissingImports extends SourceAction { - private readonly title = 'Add all missing imports'; - static readonly kind = CodeActionKind.SourceAddMissingImportsTs; - - async build( - client: TsClient, - file: string, - diagnostics: readonly lsp.Diagnostic[], - ): Promise { - const edits = await buildCombinedFix([ - { codes: errorCodes.cannotFindName, fixName: fixNames.fixImport }, - ], client, file, diagnostics); - if (!edits.length) { - return null; - } - return lsp.CodeAction.create(this.title, { documentChanges: edits }, SourceAddMissingImports.kind.value); - } -} - -//#endregion - -export class TypeScriptAutoFixProvider { - private static kindProviders = [ - SourceFixAll, - SourceRemoveUnused, - SourceAddMissingImports, - ]; - - public static get kinds(): CodeActionKind[] { - return TypeScriptAutoFixProvider.kindProviders.map(provider => provider.kind); - } - - constructor(private readonly client: TsClient) {} - - public async provideCodeActions( - kinds: CodeActionKind[], - file: string, - diagnostics: lsp.Diagnostic[], - ): Promise { - const results: Promise[] = []; - for (const provider of TypeScriptAutoFixProvider.kindProviders) { - if (kinds.some(kind => kind.contains(provider.kind))) { - results.push((new provider).build(this.client, file, diagnostics)); - } - } - return (await Promise.all(results)).flatMap(result => result || []); - } -} diff --git a/src/features/util/codeAction.ts b/src/features/util/codeAction.ts new file mode 100644 index 00000000..cc0b6421 --- /dev/null +++ b/src/features/util/codeAction.ts @@ -0,0 +1,54 @@ +// sync: file[extensions/typescript-language-features/src/languageFeatures/util/codeAction.ts] sha[f76ac124233270762d11ec3afaaaafcba53b3bbf] +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +/* + * Copyright (C) 2022 TypeFox and others. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + */ + +import * as lsp from 'vscode-languageserver'; +import { CommandTypes, type ts } from '../../ts-protocol.js'; +import { ITypeScriptServiceClient } from '../../typescriptService.js'; +import { toTextDocumentEdit } from '../../protocol-translation.js'; +import { LspClient } from '../../lsp-client.js'; + +export function getEditForCodeAction( + client: ITypeScriptServiceClient, + action: ts.server.protocol.CodeAction, +): lsp.WorkspaceEdit | undefined { + return action.changes?.length + ? { documentChanges: action.changes.map(change => toTextDocumentEdit(change, client)) } + : undefined; +} + +export async function applyCodeAction( + client: ITypeScriptServiceClient, + lspClient: LspClient, + action: ts.server.protocol.CodeAction, + token: lsp.CancellationToken, +): Promise { + const workspaceEdit = getEditForCodeAction(client, action); + if (workspaceEdit) { + if (!await lspClient.applyWorkspaceEdit({ edit: workspaceEdit })) { + return false; + } + } + return applyCodeActionCommands(client, action.commands, token); +} + +export async function applyCodeActionCommands( + client: ITypeScriptServiceClient, + commands: ReadonlyArray | undefined, + token?: lsp.CancellationToken, +): Promise { + if (commands?.length) { + for (const command of commands) { + await client.execute(CommandTypes.ApplyCodeActionCommand, { command }, token); + } + } + return true; +} diff --git a/src/lsp-connection.ts b/src/lsp-connection.ts index 0f035333..a1ce12a7 100644 --- a/src/lsp-connection.ts +++ b/src/lsp-connection.ts @@ -33,6 +33,7 @@ export function createLspConnection(options: LspConnectionOptions): lsp.Connecti connection.onDidChangeTextDocument(server.didChangeTextDocument.bind(server)); connection.onCodeAction(server.codeAction.bind(server)); + connection.onCodeActionResolve(server.codeActionResolve.bind(server)); connection.onCodeLens(server.codeLens.bind(server)); connection.onCodeLensResolve(server.codeLensResolve.bind(server)); connection.onCompletion(server.completion.bind(server)); diff --git a/src/lsp-server.test.ts b/src/lsp-server.test.ts index 6d7fc4d5..bcdcd8f2 100644 --- a/src/lsp-server.test.ts +++ b/src/lsp-server.test.ts @@ -1298,7 +1298,7 @@ describe('code actions', () => { it('can provide quickfix code actions', async () => { await openDocumentAndWaitForDiagnostics(server, doc); - const result = (await server.codeAction({ + const result = await server.codeAction({ textDocument: doc, range: { start: { line: 1, character: 25 }, @@ -1314,7 +1314,7 @@ describe('code actions', () => { message: 'unused arg', }], }, - }))!; + }) as lsp.CodeAction[]; // 1 quickfix + 2 refactorings expect(result).toHaveLength(3); @@ -1788,7 +1788,7 @@ describe('executeCommand', () => { text: 'export function fn(): void {}\nexport function newFn(): void {}', }; await openDocumentAndWaitForDiagnostics(server, doc); - const codeActions = (await server.codeAction({ + const codeActions = await server.codeAction({ textDocument: doc, range: { start: position(doc, 'newFn'), @@ -1797,7 +1797,7 @@ describe('executeCommand', () => { context: { diagnostics: [], }, - }))!; + }) as lsp.CodeAction[]; // Find refactoring code action. const applyRefactoringAction = codeActions.find(action => action.command?.command === Commands.APPLY_REFACTORING); expect(applyRefactoringAction).toBeDefined(); @@ -2426,6 +2426,71 @@ describe('fileOperations', () => { }, ]); }); + + it('willRenameFiles - new', async () => { + const filesDirectory = 'rename'; + const import1FileName = 'import1.ts'; + const import2FileName = 'import2.ts'; + const import1FilePath = filePath(filesDirectory, import1FileName); + const import2FilePath = filePath(filesDirectory, import2FileName); + const import1Uri = uri(filesDirectory, import1FileName); + const import2Uri = uri(filesDirectory, import2FileName); + const exportFileName = 'export.ts'; + const exportNewFileName = 'export2.ts'; + + // Open files 1 and 2. + + const import1Document = { + uri: import1Uri, + languageId: 'typescript', + version: 1, + text: readContents(import1FilePath), + }; + server.didOpenTextDocument({ textDocument: import1Document }); + + const import2Document = { + uri: import2Uri, + languageId: 'typescript', + version: 1, + text: readContents(import2FilePath), + }; + server.didOpenTextDocument({ textDocument: import2Document }); + + // Close file 1. + + server.didCloseTextDocument({ textDocument: import1Document }); + + const edit = await server.willRenameFiles({ + files: [{ + oldUri: uri(filesDirectory, exportFileName), + newUri: uri(filesDirectory, exportNewFileName), + }], + }); + expect(edit.changes).toBeDefined(); + expect(Object.keys(edit.changes!)).toHaveLength(2); + expect(edit.changes!).toStrictEqual( + { + [import1Uri]: [ + { + range: { + start:{ line: 0, character: 19 }, + end: { line: 0, character: 27 }, + }, + newText:'./export2', + }, + ], + [import2Uri]: [ + { + range: { + start:{ line: 0, character: 19 }, + end: { line: 0, character: 27 }, + }, + newText:'./export2', + }, + ], + }, + ); + }); }); describe('linked editing', () => { diff --git a/src/lsp-server.ts b/src/lsp-server.ts index ef5907d2..3f0bd822 100644 --- a/src/lsp-server.ts +++ b/src/lsp-server.ts @@ -10,13 +10,12 @@ import fs from 'fs-extra'; import { URI } from 'vscode-uri'; import * as lsp from 'vscode-languageserver'; import { getDignosticsKind, TsClient } from './ts-client.js'; -import { DiagnosticEventQueue } from './diagnostic-queue.js'; +import { DiagnosticsManager } from './diagnostic-queue.js'; import { toDocumentHighlight, toSymbolKind, toLocation, toSelectionRange, toTextEdit } from './protocol-translation.js'; import { LspDocument } from './document.js'; import { asCompletionItems, asResolvedCompletionItem, CompletionContext, CompletionDataCache, getCompletionTriggerCharacter } from './completion.js'; import { asSignatureHelp, toTsTriggerReason } from './hover.js'; import { Commands, TypescriptVersionNotification } from './commands.js'; -import { provideQuickFix } from './quickfix.js'; import { provideRefactors } from './refactor.js'; import { organizeImportsCommands, provideOrganizeImports } from './organize-imports.js'; import { CommandTypes, EventName, OrganizeImportsMode, TypeScriptInitializeParams, TypeScriptInitializationOptions, SupportedFeatures } from './ts-protocol.js'; @@ -24,7 +23,6 @@ import type { ts } from './ts-protocol.js'; import { collectDocumentSymbols, collectSymbolInformation } from './document-symbol.js'; import { fromProtocolCallHierarchyItem, fromProtocolCallHierarchyIncomingCall, fromProtocolCallHierarchyOutgoingCall } from './features/call-hierarchy.js'; import FileConfigurationManager from './features/fileConfigurationManager.js'; -import { TypeScriptAutoFixProvider } from './features/fix-all.js'; import { CodeLensType, type ReferencesCodeLens } from './features/code-lens/baseCodeLensProvider.js'; import TypeScriptImplementationsCodeLensProvider from './features/code-lens/implementationsCodeLens.js'; import { TypeScriptReferencesCodeLensProvider } from './features/code-lens/referencesCodeLens.js'; @@ -43,16 +41,19 @@ import { MarkdownString } from './utils/MarkdownString.js'; import * as Previewer from './utils/previewer.js'; import { Position, Range } from './utils/typeConverters.js'; import { CodeActionKind } from './utils/types.js'; +import { CommandManager } from './commands/commandManager.js'; +import { CodeActionManager } from './features/codeActions/codeActionManager.js'; export class LspServer { private tsClient: TsClient; private fileConfigurationManager: FileConfigurationManager; private initializeParams: TypeScriptInitializeParams | null = null; - private diagnosticQueue: DiagnosticEventQueue; + private diagnosticsManager: DiagnosticsManager; private completionDataCache = new CompletionDataCache(); private logger: Logger; + private codeActionsManager: CodeActionManager; + private commandManager: CommandManager; private workspaceRoot: string | undefined; - private typeScriptAutoFixProvider: TypeScriptAutoFixProvider | null = null; private features: SupportedFeatures = {}; // Caching for navTree response shared by multiple requests. private cachedNavTreeResponse = new CachedResponse(); @@ -63,12 +64,14 @@ export class LspServer { this.logger = new PrefixingLogger(options.logger, '[lspserver]'); this.tsClient = new TsClient(onCaseInsensitiveFileSystem(), this.logger, options.lspClient); this.fileConfigurationManager = new FileConfigurationManager(this.tsClient, onCaseInsensitiveFileSystem()); - this.diagnosticQueue = new DiagnosticEventQueue( + this.commandManager = new CommandManager(); + this.diagnosticsManager = new DiagnosticsManager( diagnostics => this.options.lspClient.publishDiagnostics(diagnostics), this.tsClient, this.features, this.logger, ); + this.codeActionsManager = new CodeActionManager(this.tsClient, this.fileConfigurationManager, this.commandManager, this.diagnosticsManager, this.features); } closeAllForTesting(): void { @@ -82,7 +85,7 @@ export class LspServer { if (!document) { throw new Error(`Document not open: ${uri}`); } - await this.diagnosticQueue.waitForDiagnosticsForTesting(document.filepath); + await this.diagnosticsManager.waitForDiagnosticsForTesting(document.filepath); } shutdown(): void { @@ -113,6 +116,7 @@ export class LspServer { const { codeAction, completion, definition, publishDiagnostics } = textDocument; if (codeAction) { this.features.codeActionDisabledSupport = codeAction.disabledSupport; + this.features.codeActionResolveSupport = codeAction.resolveSupport; } if (completion) { const { completionItem } = completion; @@ -169,11 +173,11 @@ export class LspServer { process.exit(); }); - this.typeScriptAutoFixProvider = new TypeScriptAutoFixProvider(this.tsClient); this.fileConfigurationManager.setGlobalConfiguration(this.workspaceRoot, hostInfo); this.registerHandlers(); const prepareSupport = textDocument?.rename?.prepareSupport && this.tsClient.apiVersion.gte(API.v310); + const { codeActionLiteralSupport, resolveSupport: codeActionResolveSupport } = textDocument?.codeAction || {}; const initializeResult: lsp.InitializeResult = { capabilities: { textDocumentSync: lsp.TextDocumentSyncKind.Incremental, @@ -181,17 +185,20 @@ export class LspServer { triggerCharacters: ['.', '"', '\'', '/', '@', '<'], resolveProvider: true, }, - codeActionProvider: clientCapabilities.textDocument?.codeAction?.codeActionLiteralSupport + codeActionProvider: codeActionLiteralSupport || codeActionResolveSupport ? { - codeActionKinds: [ - ...TypeScriptAutoFixProvider.kinds.map(kind => kind.value), - CodeActionKind.SourceOrganizeImportsTs.value, - CodeActionKind.SourceRemoveUnusedImportsTs.value, - CodeActionKind.SourceSortImportsTs.value, - CodeActionKind.QuickFix.value, - CodeActionKind.Refactor.value, - ], - } : true, + ...codeActionLiteralSupport ? { + codeActionKinds: [ + ...this.codeActionsManager.kinds, + CodeActionKind.SourceOrganizeImportsTs.value, + CodeActionKind.SourceRemoveUnusedImportsTs.value, + CodeActionKind.SourceSortImportsTs.value, + CodeActionKind.Refactor.value, + ], + } : {}, + ...codeActionResolveSupport ? { resolveProvider: true } : {}, + } + : true, codeLensProvider: { resolveProvider: true, }, @@ -353,7 +360,7 @@ export class LspServer { didChangeConfiguration(params: lsp.DidChangeConfigurationParams): void { this.fileConfigurationManager.setWorkspaceConfiguration(params.settings || {}); const ignoredDiagnosticCodes = this.fileConfigurationManager.workspaceConfiguration.diagnostics?.ignoredCodes || []; - this.tsClient.interruptGetErr(() => this.diagnosticQueue.updateIgnoredDiagnosticCodes(ignoredDiagnosticCodes)); + this.tsClient.interruptGetErr(() => this.diagnosticsManager.updateIgnoredDiagnosticCodes(ignoredDiagnosticCodes)); } didOpenTextDocument(params: lsp.DidOpenTextDocumentParams): void { @@ -377,7 +384,7 @@ export class LspServer { } this.cachedNavTreeResponse.onDocumentClose(document); this.tsClient.onDidCloseTextDocument(uri); - this.diagnosticQueue.onDidCloseFile(document.filepath); + this.diagnosticsManager.onDidCloseFile(document.filepath); this.fileConfigurationManager.onDidCloseTextDocument(document.uri); } @@ -756,20 +763,16 @@ export class LspServer { return asSignatureHelp(response.body, params.context, this.tsClient); } - async codeAction(params: lsp.CodeActionParams, token?: lsp.CancellationToken): Promise { + async codeAction(params: lsp.CodeActionParams, token?: lsp.CancellationToken): Promise<(lsp.CodeAction | lsp.Command)[]> { const document = this.tsClient.toOpenDocument(params.textDocument.uri); if (!document) { return []; } - await this.tsClient.interruptGetErr(() => this.fileConfigurationManager.ensureConfigurationForDocument(document)); + const actions = await this.codeActionsManager.provideCodeActions(document, params.range, params.context, token); const fileRangeArgs = Range.toFileRangeRequestArgs(document.filepath, params.range); - const actions: lsp.CodeAction[] = []; const kinds = params.context.only?.map(kind => new CodeActionKind(kind)); - if (!kinds || kinds.some(kind => kind.contains(CodeActionKind.QuickFix))) { - actions.push(...provideQuickFix(await this.getCodeFixes(fileRangeArgs, params.context, token), this.tsClient)); - } if (!kinds || kinds.some(kind => kind.contains(CodeActionKind.Refactor))) { actions.push(...provideRefactors(await this.getRefactors(fileRangeArgs, params.context, token), fileRangeArgs, this.features)); } @@ -803,28 +806,8 @@ export class LspServer { } } - // TODO: Since we rely on diagnostics pointing at errors in the correct places, we can't proceed if we are not - // sure that diagnostics are up-to-date. Thus we check if there are pending diagnostic requests for the file. - // In general would be better to replace the whole diagnostics handling logic with the one from - // bufferSyncSupport.ts in VSCode's typescript language features. - if (kinds && !this.tsClient.hasPendingDiagnostics(document.uri)) { - const diagnostics = this.diagnosticQueue.getDiagnosticsForFile(document.filepath) || []; - if (diagnostics.length) { - actions.push(...await this.typeScriptAutoFixProvider!.provideCodeActions(kinds, document.filepath, diagnostics)); - } - } - return actions; } - protected async getCodeFixes(fileRangeArgs: ts.server.protocol.FileRangeRequestArgs, context: lsp.CodeActionContext, token?: lsp.CancellationToken): Promise { - const errorCodes = context.diagnostics.map(diagnostic => Number(diagnostic.code)); - const args: ts.server.protocol.CodeFixRequestArgs = { - ...fileRangeArgs, - errorCodes, - }; - const response = await this.tsClient.execute(CommandTypes.GetCodeFixes, args, token); - return response.type === 'response' ? response : undefined; - } protected async getRefactors(fileRangeArgs: ts.server.protocol.FileRangeRequestArgs, context: lsp.CodeActionContext, token?: lsp.CancellationToken): Promise { const args: ts.server.protocol.GetApplicableRefactorsRequestArgs = { ...fileRangeArgs, @@ -835,6 +818,10 @@ export class LspServer { return response.type === 'response' ? response : undefined; } + async codeActionResolve(params: lsp.CodeAction, _token?: lsp.CancellationToken): Promise { + return this.codeActionsManager.resolveCodeAction(params); + } + async executeCommand(params: lsp.ExecuteCommandParams, token?: lsp.CancellationToken, workDoneProgress?: lsp.WorkDoneProgressReporter): Promise { if (params.command === Commands.APPLY_WORKSPACE_EDIT && params.arguments) { const edit = params.arguments[0] as lsp.WorkspaceEdit; @@ -1137,7 +1124,7 @@ export class LspServer { const diagnosticEvent = event as ts.server.protocol.DiagnosticEvent; if (diagnosticEvent.body?.diagnostics) { const { file, diagnostics } = diagnosticEvent.body; - this.diagnosticQueue.updateDiagnostics(getDignosticsKind(event), file, diagnostics); + this.diagnosticsManager.updateDiagnostics(getDignosticsKind(event), file, diagnostics); } } } diff --git a/src/protocol-translation.ts b/src/protocol-translation.ts index b1b45360..1e588ac3 100644 --- a/src/protocol-translation.ts +++ b/src/protocol-translation.ts @@ -10,6 +10,7 @@ import { type TsClient } from './ts-client.js'; import { HighlightSpanKind, SupportedFeatures } from './ts-protocol.js'; import type { ts } from './ts-protocol.js'; import { Position, Range } from './utils/typeConverters.js'; +import { type ITypeScriptServiceClient } from './typescriptService.js'; export function toLocation(fileSpan: ts.server.protocol.FileSpan, client: TsClient): lsp.Location { const uri = client.toResourceUri(fileSpan.file); @@ -124,7 +125,7 @@ export function toTextEdit(edit: ts.server.protocol.CodeEdit): lsp.TextEdit { }; } -export function toTextDocumentEdit(change: ts.server.protocol.FileCodeEdits, client: TsClient): lsp.TextDocumentEdit { +export function toTextDocumentEdit(change: ts.server.protocol.FileCodeEdits, client: ITypeScriptServiceClient): lsp.TextDocumentEdit { const uri = client.toResourceUri(change.fileName); const document = client.toOpenDocument(uri); return { diff --git a/src/quickfix.ts b/src/quickfix.ts deleted file mode 100644 index 25828062..00000000 --- a/src/quickfix.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright (C) 2018 TypeFox and others. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. - * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 - */ - -import * as lsp from 'vscode-languageserver'; -import { Commands } from './commands.js'; -import { toTextDocumentEdit } from './protocol-translation.js'; -import { type TsClient } from './ts-client.js'; -import type { ts } from './ts-protocol.js'; - -export function provideQuickFix(response: ts.server.protocol.GetCodeFixesResponse | undefined, client: TsClient): Array { - if (!response?.body) { - return []; - } - return response.body.map(fix => lsp.CodeAction.create( - fix.description, - { - title: fix.description, - command: Commands.APPLY_WORKSPACE_EDIT, - arguments: [{ documentChanges: fix.changes.map(c => toTextDocumentEdit(c, client)) }], - }, - lsp.CodeActionKind.QuickFix, - )); -} diff --git a/src/ts-protocol.ts b/src/ts-protocol.ts index 7f1ec1cd..03dc8b8c 100644 --- a/src/ts-protocol.ts +++ b/src/ts-protocol.ts @@ -325,6 +325,7 @@ export function toSymbolDisplayPartKind(kind: string): ts.SymbolDisplayPartKind export interface SupportedFeatures { codeActionDisabledSupport?: boolean; + codeActionResolveSupport?: lsp.CodeActionClientCapabilities['resolveSupport']; completionCommitCharactersSupport?: boolean; completionInsertReplaceSupport?: boolean; completionLabelDetails?: boolean; diff --git a/src/tsServer/server.ts b/src/tsServer/server.ts index 39fdf201..9040dc1f 100644 --- a/src/tsServer/server.ts +++ b/src/tsServer/server.ts @@ -199,7 +199,7 @@ export class SingleTsServer implements ITypeScriptServer { private tryCancelRequest(seq: number, command: string): boolean { try { if (this._requestQueue.tryDeletePendingRequest(seq)) { - this.logTrace(`Canceled request with sequence number ${seq}`); + this.logTrace(`Canceled pending request with sequence number ${seq}`); return true; } diff --git a/src/typescriptService.ts b/src/typescriptService.ts index 465409da..6175b1bc 100644 --- a/src/typescriptService.ts +++ b/src/typescriptService.ts @@ -91,6 +91,8 @@ export interface ITypeScriptServiceClient { suppressAlertOnFailure?: boolean; }): LspDocument | undefined; + hasPendingDiagnostics(resource: URI): boolean; + /** * Checks if `resource` has a given capability. */ diff --git a/src/utils/cancellation.ts b/src/utils/cancellation.ts new file mode 100644 index 00000000..4da16914 --- /dev/null +++ b/src/utils/cancellation.ts @@ -0,0 +1,20 @@ +// sync: file[extensions/typescript-language-features/src/utils/cancellation.ts] sha[f76ac124233270762d11ec3afaaaafcba53b3bbf] +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +/* + * Copyright (C) 2024 TypeFox and others. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + */ + +import * as lsp from 'vscode-languageserver'; + +const noopDisposable = lsp.Disposable.create(() => {}); + +export const nulToken: lsp.CancellationToken = { + isCancellationRequested: false, + onCancellationRequested: () => noopDisposable, +}; diff --git a/src/utils/fixNames.ts b/src/utils/fixNames.ts index c2f8ce67..da674888 100644 --- a/src/utils/fixNames.ts +++ b/src/utils/fixNames.ts @@ -1,18 +1,25 @@ +// sync: file[extensions/typescript-language-features/src/tsServer/protocol/fixNames.ts] sha[f76ac124233270762d11ec3afaaaafcba53b3bbf] /*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +export const addMissingAwait = 'addMissingAwait'; +export const addMissingNewOperator = 'addMissingNewOperator'; +export const addMissingOverride = 'fixOverrideModifier'; export const annotateWithTypeFromJSDoc = 'annotateWithTypeFromJSDoc'; -export const constructorForDerivedNeedSuperCall = 'constructorForDerivedNeedSuperCall'; -export const extendsInterfaceBecomesImplements = 'extendsInterfaceBecomesImplements'; export const awaitInSyncFunction = 'fixAwaitInSyncFunction'; -export const classIncorrectlyImplementsInterface = 'fixClassIncorrectlyImplementsInterface'; export const classDoesntImplementInheritedAbstractMember = 'fixClassDoesntImplementInheritedAbstractMember'; -export const unreachableCode = 'fixUnreachableCode'; -export const unusedIdentifier = 'unusedIdentifier'; +export const classIncorrectlyImplementsInterface = 'fixClassIncorrectlyImplementsInterface'; +export const constructorForDerivedNeedSuperCall = 'constructorForDerivedNeedSuperCall'; +export const extendsInterfaceBecomesImplements = 'extendsInterfaceBecomesImplements'; +export const fixImport = 'import'; export const forgottenThisPropertyAccess = 'forgottenThisPropertyAccess'; +export const removeUnnecessaryAwait = 'removeUnnecessaryAwait'; export const spelling = 'spelling'; -export const fixImport = 'import'; -export const addMissingAwait = 'addMissingAwait'; -export const addMissingOverride = 'fixOverrideModifier'; +export const inferFromUsage = 'inferFromUsage'; +export const addNameToNamelessParameter = 'addNameToNamelessParameter'; +export const fixMissingFunctionDeclaration = 'fixMissingFunctionDeclaration'; +export const fixClassDoesntImplementInheritedAbstractMember = 'fixClassDoesntImplementInheritedAbstractMember'; +export const unreachableCode = 'fixUnreachableCode'; +export const unusedIdentifier = 'unusedIdentifier'; diff --git a/src/utils/memoize.ts b/src/utils/memoize.ts new file mode 100644 index 00000000..015893f1 --- /dev/null +++ b/src/utils/memoize.ts @@ -0,0 +1,42 @@ +// sync: file[extensions/typescript-language-features/src/utils/memoize.ts] sha[f76ac124233270762d11ec3afaaaafcba53b3bbf] +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +/* + * Copyright (C) 2024 TypeFox and others. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + */ + +export function memoize(_target: any, key: string, descriptor: any): void { + let fnKey: string | undefined; + let fn: (...args: any[]) => void | undefined; + + if (typeof descriptor.value === 'function') { + fnKey = 'value'; + fn = descriptor.value; + } else if (typeof descriptor.get === 'function') { + fnKey = 'get'; + fn = descriptor.get; + } else { + throw new Error('not supported'); + } + + const memoizeKey = `$memoize$${key}`; + + descriptor[fnKey] = function(...args: any[]) { + // eslint-disable-next-line no-prototype-builtins + if (!this.hasOwnProperty(memoizeKey)) { + Object.defineProperty(this, memoizeKey, { + configurable: false, + enumerable: false, + writable: false, + value: fn!.apply(this, args), + }); + } + + return this[memoizeKey]; + }; +} diff --git a/test-data/rename/export.ts b/test-data/rename/export.ts new file mode 100644 index 00000000..30e9636d --- /dev/null +++ b/test-data/rename/export.ts @@ -0,0 +1 @@ +export const a = 10; diff --git a/test-data/rename/import1.ts b/test-data/rename/import1.ts new file mode 100644 index 00000000..0a240c3f --- /dev/null +++ b/test-data/rename/import1.ts @@ -0,0 +1 @@ +import { a } from './export'; diff --git a/test-data/rename/import2.ts b/test-data/rename/import2.ts new file mode 100644 index 00000000..0a240c3f --- /dev/null +++ b/test-data/rename/import2.ts @@ -0,0 +1 @@ +import { a } from './export'; diff --git a/test-data/rename/import3.ts b/test-data/rename/import3.ts new file mode 100644 index 00000000..968ca3f2 --- /dev/null +++ b/test-data/rename/import3.ts @@ -0,0 +1 @@ +import { a } from './exporta'; diff --git a/tsconfig.json b/tsconfig.json index 06fe7820..56d606fc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,7 @@ "allowJs": true, "declaration": false, "declarationMap": false, + "experimentalDecorators": true, "noEmit": true, "esModuleInterop": true, "lib": ["es2020"],