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"], pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy