diff --git a/packages/language-server/src/plugins/typescript/TypeScriptPlugin.ts b/packages/language-server/src/plugins/typescript/TypeScriptPlugin.ts index 1c8d1b867..dbcc2fa8b 100644 --- a/packages/language-server/src/plugins/typescript/TypeScriptPlugin.ts +++ b/packages/language-server/src/plugins/typescript/TypeScriptPlugin.ts @@ -12,16 +12,12 @@ import { Position, Range, SymbolInformation, - TextDocumentEdit, - TextEdit, - VersionedTextDocumentIdentifier, } from 'vscode-languageserver'; import { Document, DocumentManager, mapDiagnosticToOriginal, mapHoverToParent, - mapRangeToOriginal, mapSymbolInformationToOriginal, } from '../../lib/documents'; import { LSConfigManager, LSTypescriptConfig } from '../../ls-config'; @@ -38,6 +34,7 @@ import { OnWatchFileChanges, } from '../interfaces'; import { DocumentSnapshot, SnapshotFragment } from './DocumentSnapshot'; +import { CodeActionsProviderImpl } from './features/CodeActionsProvider'; import { CompletionEntryWithIdentifer, CompletionsProviderImpl, @@ -63,11 +60,13 @@ export class TypeScriptPlugin private configManager: LSConfigManager; private readonly lsAndTsDocResolver: LSAndTSDocResolver; private readonly completionProvider: CompletionsProviderImpl; + private readonly codeActionsProvider: CodeActionsProviderImpl; constructor(docManager: DocumentManager, configManager: LSConfigManager) { this.configManager = configManager; this.lsAndTsDocResolver = new LSAndTSDocResolver(docManager); this.completionProvider = new CompletionsProviderImpl(this.lsAndTsDocResolver); + this.codeActionsProvider = new CodeActionsProviderImpl(this.lsAndTsDocResolver); } async getDiagnostics(document: Document): Promise { @@ -255,54 +254,7 @@ export class TypeScriptPlugin return []; } - const { lang, tsDoc } = this.getLSAndTSDoc(document); - const fragment = await tsDoc.getFragment(); - - const start = fragment.offsetAt(fragment.getGeneratedPosition(range.start)); - const end = fragment.offsetAt(fragment.getGeneratedPosition(range.end)); - const errorCodes: number[] = context.diagnostics.map((diag) => Number(diag.code)); - const codeFixes = lang.getCodeFixesAtPosition( - tsDoc.filePath, - start, - end, - errorCodes, - {}, - {}, - ); - - const docs = new Map([[tsDoc.filePath, fragment]]); - return await Promise.all( - codeFixes.map(async (fix) => { - const documentChanges = await Promise.all( - fix.changes.map(async (change) => { - let doc = docs.get(change.fileName); - if (!doc) { - doc = await this.getSnapshot(change.fileName).getFragment(); - docs.set(change.fileName, doc); - } - return TextDocumentEdit.create( - VersionedTextDocumentIdentifier.create( - pathToUrl(change.fileName), - null, - ), - change.textChanges.map((edit) => { - return TextEdit.replace( - mapRangeToOriginal(doc!, convertRange(doc!, edit.span)), - edit.newText, - ); - }), - ); - }), - ); - return CodeAction.create( - fix.description, - { - documentChanges, - }, - fix.fixName, - ); - }), - ); + return this.codeActionsProvider.getCodeActions(document, range, context); } onWatchFileChanges(fileName: string, changeType: FileChangeType) { diff --git a/packages/language-server/src/plugins/typescript/features/CodeActionsProvider.ts b/packages/language-server/src/plugins/typescript/features/CodeActionsProvider.ts new file mode 100644 index 000000000..942dc58a7 --- /dev/null +++ b/packages/language-server/src/plugins/typescript/features/CodeActionsProvider.ts @@ -0,0 +1,134 @@ +import { + CodeAction, + CodeActionContext, + CodeActionKind, + Range, + TextDocumentEdit, + TextEdit, + VersionedTextDocumentIdentifier, +} from 'vscode-languageserver'; +import { Document, mapRangeToOriginal } from '../../../lib/documents'; +import { pathToUrl } from '../../../utils'; +import { CodeActionsProvider } from '../../interfaces'; +import { SnapshotFragment } from '../DocumentSnapshot'; +import { LSAndTSDocResolver } from '../LSAndTSDocResolver'; +import { convertRange } from '../utils'; + +export class CodeActionsProviderImpl implements CodeActionsProvider { + constructor(private readonly lsAndTsDocResolver: LSAndTSDocResolver) {} + + async getCodeActions( + document: Document, + range: Range, + context: CodeActionContext, + ): Promise { + if (context.only?.[0] === CodeActionKind.SourceOrganizeImports) { + return await this.organizeImports(document); + } + + if (!context.only || context.only.includes(CodeActionKind.QuickFix)) { + return await this.applyQuickfix(document, range, context); + } + + return []; + } + + private async organizeImports(document: Document): Promise { + if (!document.scriptInfo) { + return []; + } + + const { lang, tsDoc } = this.getLSAndTSDoc(document); + const fragment = await tsDoc.getFragment(); + + const changes = lang.organizeImports({ fileName: tsDoc.filePath, type: 'file' }, {}, {}); + + const documentChanges = await Promise.all( + changes.map(async (change) => { + // Organize Imports will only affect the current file, so no need to check the file path + return TextDocumentEdit.create( + VersionedTextDocumentIdentifier.create(document.url, null), + change.textChanges.map((edit) => { + let range = mapRangeToOriginal(fragment, convertRange(fragment, edit.span)); + // Handle svelte2tsx wrong import mapping: + // The character after the last import maps to the start of the script + // TODO find a way to fix this in svelte2tsx and then remove this + if (range.end.line === 0 && range.end.character === 1) { + edit.span.length -= 1; + range = mapRangeToOriginal(fragment, convertRange(fragment, edit.span)); + range.end.character += 1; + } + return TextEdit.replace(range, edit.newText); + }), + ); + }), + ); + + return [ + CodeAction.create( + 'Organize Imports', + { documentChanges }, + CodeActionKind.SourceOrganizeImports, + ), + ]; + } + + private async applyQuickfix(document: Document, range: Range, context: CodeActionContext) { + const { lang, tsDoc } = this.getLSAndTSDoc(document); + const fragment = await tsDoc.getFragment(); + + const start = fragment.offsetAt(fragment.getGeneratedPosition(range.start)); + const end = fragment.offsetAt(fragment.getGeneratedPosition(range.end)); + const errorCodes: number[] = context.diagnostics.map((diag) => Number(diag.code)); + const codeFixes = lang.getCodeFixesAtPosition( + tsDoc.filePath, + start, + end, + errorCodes, + {}, + {}, + ); + + const docs = new Map([[tsDoc.filePath, fragment]]); + return await Promise.all( + codeFixes.map(async (fix) => { + const documentChanges = await Promise.all( + fix.changes.map(async (change) => { + let doc = docs.get(change.fileName); + if (!doc) { + doc = await this.getSnapshot(change.fileName).getFragment(); + docs.set(change.fileName, doc); + } + return TextDocumentEdit.create( + VersionedTextDocumentIdentifier.create( + pathToUrl(change.fileName), + null, + ), + change.textChanges.map((edit) => { + return TextEdit.replace( + mapRangeToOriginal(doc!, convertRange(doc!, edit.span)), + edit.newText, + ); + }), + ); + }), + ); + return CodeAction.create( + fix.description, + { + documentChanges, + }, + CodeActionKind.QuickFix, + ); + }), + ); + } + + private getLSAndTSDoc(document: Document) { + return this.lsAndTsDocResolver.getLSAndTSDoc(document); + } + + private getSnapshot(filePath: string, document?: Document) { + return this.lsAndTsDocResolver.getSnapshot(filePath, document); + } +} diff --git a/packages/language-server/src/server.ts b/packages/language-server/src/server.ts index 99243049c..b52132108 100644 --- a/packages/language-server/src/server.ts +++ b/packages/language-server/src/server.ts @@ -7,6 +7,7 @@ import { TextDocumentPositionParams, TextDocumentIdentifier, IConnection, + CodeActionKind, } from 'vscode-languageserver'; import { DocumentManager, Document } from './lib/documents'; import { @@ -113,7 +114,15 @@ export function startServer(options?: LSOptions) { colorProvider: true, documentSymbolProvider: true, definitionProvider: true, - codeActionProvider: true, + codeActionProvider: evt.capabilities.textDocument?.codeAction + ?.codeActionLiteralSupport + ? { + codeActionKinds: [ + CodeActionKind.QuickFix, + CodeActionKind.SourceOrganizeImports, + ], + } + : true, }, }; }); diff --git a/packages/language-server/test/plugins/typescript/TypescriptPlugin.test.ts b/packages/language-server/test/plugins/typescript/TypescriptPlugin.test.ts index b1cd59ac7..e63044719 100644 --- a/packages/language-server/test/plugins/typescript/TypescriptPlugin.test.ts +++ b/packages/language-server/test/plugins/typescript/TypescriptPlugin.test.ts @@ -2,7 +2,7 @@ import * as assert from 'assert'; import * as path from 'path'; import { dirname, join } from 'path'; import ts from 'typescript'; -import { FileChangeType, Hover, Position, Range } from 'vscode-languageserver'; +import { FileChangeType, Hover, Position } from 'vscode-languageserver'; import { DocumentManager, Document } from '../../../src/lib/documents'; import { LSConfigManager } from '../../../src/ls-config'; import { TypeScriptPlugin } from '../../../src/plugins'; @@ -288,58 +288,6 @@ describe('TypescriptPlugin', () => { ]); }); - it('provides code actions', async () => { - const { plugin, document } = setup('codeactions.svelte'); - - const codeActions = await plugin.getCodeActions( - document, - Range.create(Position.create(1, 4), Position.create(1, 5)), - { - diagnostics: [ - { - code: 6133, - message: "'a' is declared but its value is never read.", - range: Range.create(Position.create(1, 4), Position.create(1, 5)), - source: 'ts', - }, - ], - only: ['quickfix'], - }, - ); - - assert.deepStrictEqual(codeActions, [ - { - edit: { - documentChanges: [ - { - edits: [ - { - newText: '', - range: { - start: { - character: 0, - line: 1, - }, - end: { - character: 0, - line: 2, - }, - }, - }, - ], - textDocument: { - uri: getUri('codeactions.svelte'), - version: null, - }, - }, - ], - }, - kind: 'unusedIdentifier', - title: "Remove unused declaration for: 'a'", - }, - ]); - }); - const setupForOnWatchedFileChanges = () => { const { plugin, document } = setup(''); const filePath = document.getFilePath()!; diff --git a/packages/language-server/test/plugins/typescript/features/CodeActionsProvider.test.ts b/packages/language-server/test/plugins/typescript/features/CodeActionsProvider.test.ts new file mode 100644 index 000000000..f61c3330b --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/CodeActionsProvider.test.ts @@ -0,0 +1,157 @@ +import { DocumentManager, Document } from '../../../../src/lib/documents'; +import { LSAndTSDocResolver } from '../../../../src/plugins/typescript/LSAndTSDocResolver'; +import { CodeActionsProviderImpl } from '../../../../src/plugins/typescript/features/CodeActionsProvider'; +import { pathToUrl } from '../../../../src/utils'; +import ts from 'typescript'; +import * as path from 'path'; +import * as assert from 'assert'; +import { Range, Position, CodeActionKind } from 'vscode-languageserver'; + +describe.only('CodeActionsProvider', () => { + function getFullPath(filename: string) { + return path.join(__dirname, '..', 'testfiles', filename); + } + + function getUri(filename: string) { + return pathToUrl(getFullPath(filename)); + } + + function setup(filename: string) { + const docManager = new DocumentManager( + (textDocument) => new Document(textDocument.uri, textDocument.text), + ); + const lsAndTsDocResolver = new LSAndTSDocResolver(docManager); + const provider = new CodeActionsProviderImpl(lsAndTsDocResolver); + const filePath = getFullPath(filename); + const document = docManager.openDocument({ + uri: pathToUrl(filePath), + text: ts.sys.readFile(filePath) || '', + }); + return { provider, document, docManager }; + } + + it('provides quickfix', async () => { + const { provider, document } = setup('codeactions.svelte'); + + const codeActions = await provider.getCodeActions( + document, + Range.create(Position.create(5, 4), Position.create(5, 5)), + { + diagnostics: [ + { + code: 6133, + message: "'a' is declared but its value is never read.", + range: Range.create(Position.create(5, 4), Position.create(5, 5)), + source: 'ts', + }, + ], + only: [CodeActionKind.QuickFix], + }, + ); + + assert.deepStrictEqual(codeActions, [ + { + edit: { + documentChanges: [ + { + edits: [ + { + newText: '', + range: { + start: { + character: 0, + line: 5, + }, + end: { + character: 0, + line: 6, + }, + }, + }, + ], + textDocument: { + uri: getUri('codeactions.svelte'), + version: null, + }, + }, + ], + }, + kind: CodeActionKind.QuickFix, + title: "Remove unused declaration for: 'a'", + }, + ]); + }) + // initial build might take longer + .timeout(8000); + + it('organizes imports', async () => { + const { provider, document } = setup('codeactions.svelte'); + + const codeActions = await provider.getCodeActions( + document, + Range.create(Position.create(1, 4), Position.create(1, 5)), // irrelevant + { + diagnostics: [], + only: [CodeActionKind.SourceOrganizeImports], + }, + ); + + assert.deepStrictEqual(codeActions, [ + { + edit: { + documentChanges: [ + { + edits: [ + { + newText: `import { A } from 'bla';${ts.sys.newLine}import { C } from 'blubb';${ts.sys.newLine}`, + range: { + start: { + character: 0, + line: 1, + }, + end: { + character: 0, + line: 2, + }, + }, + }, + { + newText: '', + range: { + start: { + character: 0, + line: 2, + }, + end: { + character: 0, + line: 3, + }, + }, + }, + { + newText: '', + range: { + start: { + character: 0, + line: 3, + }, + end: { + character: 22, + line: 3, + }, + }, + }, + ], + textDocument: { + uri: getUri('codeactions.svelte'), + version: null, + }, + }, + ], + }, + kind: CodeActionKind.SourceOrganizeImports, + title: 'Organize Imports', + }, + ]); + }); +}); diff --git a/packages/language-server/test/plugins/typescript/testfiles/codeactions.svelte b/packages/language-server/test/plugins/typescript/testfiles/codeactions.svelte index 11b83a4ed..1a86a90b9 100644 --- a/packages/language-server/test/plugins/typescript/testfiles/codeactions.svelte +++ b/packages/language-server/test/plugins/typescript/testfiles/codeactions.svelte @@ -1,3 +1,8 @@ \ No newline at end of file