diff --git a/packages/language-server/src/lib/documents/utils.ts b/packages/language-server/src/lib/documents/utils.ts index 5d88b9f44..ec5b8153c 100644 --- a/packages/language-server/src/lib/documents/utils.ts +++ b/packages/language-server/src/lib/documents/utils.ts @@ -233,10 +233,20 @@ function getLineOffsets(text: string) { return lineOffsets; } -export function isInTag(position: Position, tagInfo: TagInformation | null): boolean { +export function isInTag( + position: Position, + tagInfo: TagInformation | null, +): tagInfo is TagInformation { return !!tagInfo && isInRange(Range.create(tagInfo.startPos, tagInfo.endPos), position); } +export function isRangeInTag( + range: Range, + tagInfo: TagInformation | null, +): tagInfo is TagInformation { + return isInTag(range.start, tagInfo) && isInTag(range.end, tagInfo); +} + export function getTextInRange(range: Range, text: string) { return text.substring(offsetAt(range.start, text), offsetAt(range.end, text)); } diff --git a/packages/language-server/src/plugins/PluginHost.ts b/packages/language-server/src/plugins/PluginHost.ts index 0ac84b19e..8069db18f 100644 --- a/packages/language-server/src/plugins/PluginHost.ts +++ b/packages/language-server/src/plugins/PluginHost.ts @@ -249,6 +249,23 @@ export class PluginHost implements LSProvider, OnWatchFileChanges { ); } + async executeCommand( + textDocument: TextDocumentIdentifier, + command: string, + args?: any[], + ): Promise { + const document = this.getDocument(textDocument.uri); + if (!document) { + throw new Error('Cannot call methods on an unopened document'); + } + + return await this.execute( + 'executeCommand', + [document, command, args], + ExecuteMode.FirstNonNull, + ); + } + async updateImports(fileRename: FileRename): Promise { return await this.execute( 'updateImports', diff --git a/packages/language-server/src/plugins/interfaces.ts b/packages/language-server/src/plugins/interfaces.ts index fc5fd9de1..597f9f0d8 100644 --- a/packages/language-server/src/plugins/interfaces.ts +++ b/packages/language-server/src/plugins/interfaces.ts @@ -84,6 +84,11 @@ export interface CodeActionsProvider { range: Range, context: CodeActionContext, ): Resolvable; + executeCommand?( + document: Document, + command: string, + args?: any[], + ): Resolvable; } export interface FileRename { diff --git a/packages/language-server/src/plugins/typescript/TypeScriptPlugin.ts b/packages/language-server/src/plugins/typescript/TypeScriptPlugin.ts index 95fe9b2f4..4cf990393 100644 --- a/packages/language-server/src/plugins/typescript/TypeScriptPlugin.ts +++ b/packages/language-server/src/plugins/typescript/TypeScriptPlugin.ts @@ -264,6 +264,18 @@ export class TypeScriptPlugin return this.codeActionsProvider.getCodeActions(document, range, context); } + async executeCommand( + document: Document, + command: string, + args?: any[], + ): Promise { + if (!this.featureEnabled('codeActions')) { + return null; + } + + return this.codeActionsProvider.executeCommand(document, command, args); + } + async updateImports(fileRename: FileRename): Promise { if (!this.featureEnabled('rename')) { return null; diff --git a/packages/language-server/src/plugins/typescript/features/CodeActionsProvider.ts b/packages/language-server/src/plugins/typescript/features/CodeActionsProvider.ts index 942dc58a7..641b5de45 100644 --- a/packages/language-server/src/plugins/typescript/features/CodeActionsProvider.ts +++ b/packages/language-server/src/plugins/typescript/features/CodeActionsProvider.ts @@ -6,13 +6,23 @@ import { TextDocumentEdit, TextEdit, VersionedTextDocumentIdentifier, + WorkspaceEdit, } from 'vscode-languageserver'; -import { Document, mapRangeToOriginal } from '../../../lib/documents'; +import { Document, mapRangeToOriginal, isRangeInTag } from '../../../lib/documents'; import { pathToUrl } from '../../../utils'; import { CodeActionsProvider } from '../../interfaces'; import { SnapshotFragment } from '../DocumentSnapshot'; import { LSAndTSDocResolver } from '../LSAndTSDocResolver'; import { convertRange } from '../utils'; +import { flatten } from '../../../utils'; +import ts from 'typescript'; + +interface RefactorArgs { + type: 'refactor'; + refactorName: string; + textRange: ts.TextRange; + originalRange: Range; +} export class CodeActionsProviderImpl implements CodeActionsProvider { constructor(private readonly lsAndTsDocResolver: LSAndTSDocResolver) {} @@ -26,10 +36,17 @@ export class CodeActionsProviderImpl implements CodeActionsProvider { return await this.organizeImports(document); } - if (!context.only || context.only.includes(CodeActionKind.QuickFix)) { + if ( + context.diagnostics.length && + (!context.only || context.only.includes(CodeActionKind.QuickFix)) + ) { return await this.applyQuickfix(document, range, context); } + if (!context.only || context.only.includes(CodeActionKind.Refactor)) { + return await this.getApplicableRefactors(document, range); + } + return []; } @@ -124,6 +141,150 @@ export class CodeActionsProviderImpl implements CodeActionsProvider { ); } + private async getApplicableRefactors(document: Document, range: Range): Promise { + if ( + !isRangeInTag(range, document.scriptInfo) && + !isRangeInTag(range, document.moduleScriptInfo) + ) { + return []; + } + + const { lang, tsDoc } = this.getLSAndTSDoc(document); + const fragment = await tsDoc.getFragment(); + const textRange = { + pos: fragment.offsetAt(fragment.getGeneratedPosition(range.start)), + end: fragment.offsetAt(fragment.getGeneratedPosition(range.end)), + }; + const applicableRefactors = lang.getApplicableRefactors( + document.getFilePath() || '', + textRange, + undefined, + ); + + return ( + this.applicableRefactorsToCodeActions(applicableRefactors, document, range, textRange) + // Only allow refactorings from which we know they work + .filter( + (refactor) => + refactor.command?.command.includes('function_scope') || + refactor.command?.command.includes('constant_scope'), + ) + // The language server also proposes extraction into const/function in module scope, + // which is outside of the render function, which is svelte2tsx-specific and unmapped, + // so it would both not work and confuse the user ("What is this render? Never declared that"). + // So filter out the module scope proposal and rename the render-title + .filter((refactor) => !refactor.title.includes('module scope')) + .map((refactor) => ({ + ...refactor, + title: refactor.title + .replace( + `Extract to inner function in function 'render'`, + 'Extract to function', + ) + .replace(`Extract to constant in function 'render'`, 'Extract to constant'), + })) + ); + } + + private applicableRefactorsToCodeActions( + applicableRefactors: ts.ApplicableRefactorInfo[], + document: Document, + originalRange: Range, + textRange: { pos: number; end: number }, + ) { + return flatten( + applicableRefactors.map((applicableRefactor) => { + if (applicableRefactor.inlineable === false) { + return [ + CodeAction.create(applicableRefactor.description, { + title: applicableRefactor.description, + command: applicableRefactor.name, + arguments: [ + document.uri, + { + type: 'refactor', + textRange, + originalRange, + refactorName: 'Extract Symbol', + }, + ], + }), + ]; + } + + return applicableRefactor.actions.map((action) => { + return CodeAction.create(action.description, { + title: action.description, + command: action.name, + arguments: [ + document.uri, + { + type: 'refactor', + textRange, + originalRange, + refactorName: applicableRefactor.name, + }, + ], + }); + }); + }), + ); + } + + async executeCommand( + document: Document, + command: string, + args?: any[], + ): Promise { + if (!(args?.[1]?.type === 'refactor')) { + return null; + } + + const { lang, tsDoc } = this.getLSAndTSDoc(document); + const fragment = await tsDoc.getFragment(); + const path = document.getFilePath() || ''; + const { refactorName, originalRange, textRange } = args[1]; + + const edits = lang.getEditsForRefactor( + path, + {}, + textRange, + refactorName, + command, + undefined, + ); + if (!edits || edits.edits.length === 0) { + return null; + } + + const documentChanges = edits?.edits.map((edit) => + TextDocumentEdit.create( + VersionedTextDocumentIdentifier.create(document.uri, null), + edit.textChanges.map((edit) => { + let range = mapRangeToOriginal(fragment, convertRange(fragment, edit.span)); + // Some refactorings place the new code at the end of svelte2tsx' render function, + // which is unmapped. In this case, add it to the end of the script tag ourselves. + if (range.start.line < 0 || range.end.line < 0) { + if (isRangeInTag(originalRange, document.scriptInfo)) { + range = Range.create( + document.scriptInfo.endPos, + document.scriptInfo.endPos, + ); + } else if (isRangeInTag(originalRange, document.moduleScriptInfo)) { + range = Range.create( + document.moduleScriptInfo.endPos, + document.moduleScriptInfo.endPos, + ); + } + } + return TextEdit.replace(range, edit.newText); + }), + ), + ); + + return { documentChanges }; + } + private getLSAndTSDoc(document: Document) { return this.lsAndTsDocResolver.getLSAndTSDoc(document); } diff --git a/packages/language-server/src/server.ts b/packages/language-server/src/server.ts index 350f0142c..4396eda86 100644 --- a/packages/language-server/src/server.ts +++ b/packages/language-server/src/server.ts @@ -10,6 +10,8 @@ import { CodeActionKind, RenameFile, DocumentUri, + ApplyWorkspaceEditRequest, + ApplyWorkspaceEditParams, } from 'vscode-languageserver'; import { DocumentManager, Document } from './lib/documents'; import { @@ -90,6 +92,8 @@ export function startServer(options?: LSOptions) { pluginHost.register(new CSSPlugin(docManager, configManager)); pluginHost.register(new TypeScriptPlugin(docManager, configManager, workspacePath)); + const clientSupportApplyEditCommand = !!evt.capabilities.workspace?.applyEdit; + return { capabilities: { textDocumentSync: { @@ -137,9 +141,24 @@ export function startServer(options?: LSOptions) { codeActionKinds: [ CodeActionKind.QuickFix, CodeActionKind.SourceOrganizeImports, + ...(clientSupportApplyEditCommand ? [CodeActionKind.Refactor] : []), ], } : true, + executeCommandProvider: clientSupportApplyEditCommand + ? { + commands: [ + 'function_scope_0', + 'function_scope_1', + 'function_scope_2', + 'function_scope_3', + 'constant_scope_0', + 'constant_scope_1', + 'constant_scope_2', + 'constant_scope_3', + ], + } + : undefined, renameProvider: evt.capabilities.textDocument?.rename?.prepareSupport ? { prepareProvider: true } : true, @@ -175,9 +194,22 @@ export function startServer(options?: LSOptions) { ); connection.onDocumentSymbol((evt) => pluginHost.getDocumentSymbols(evt.textDocument)); connection.onDefinition((evt) => pluginHost.getDefinitions(evt.textDocument, evt.position)); + connection.onCodeAction((evt) => pluginHost.getCodeActions(evt.textDocument, evt.range, evt.context), ); + connection.onExecuteCommand(async (evt) => { + const result = await pluginHost.executeCommand( + { uri: evt.arguments?.[0] }, + evt.command, + evt.arguments, + ); + if (result) { + const edit: ApplyWorkspaceEditParams = { edit: result }; + connection?.sendRequest(ApplyWorkspaceEditRequest.type.method, edit); + } + }); + connection.onCompletionResolve((completionItem) => { const data = (completionItem as AppCompletionItem).data as TextDocumentIdentifier; diff --git a/packages/language-server/test/plugins/typescript/features/CodeActionsProvider.test.ts b/packages/language-server/test/plugins/typescript/features/CodeActionsProvider.test.ts index fc081f590..0c8d8540c 100644 --- a/packages/language-server/test/plugins/typescript/features/CodeActionsProvider.test.ts +++ b/packages/language-server/test/plugins/typescript/features/CodeActionsProvider.test.ts @@ -19,7 +19,7 @@ describe('CodeActionsProvider', () => { } function harmonizeNewLines(input: string) { - return input.replace(/\r\n/g, '~:~').replace(/\n/g, '~:~').replace(/~:~/g, ts.sys.newLine); + return input.replace(/\r\n/g, '~:~').replace(/\n/g, '~:~').replace(/~:~/g, '\n'); } function setup(filename: string) { @@ -31,7 +31,7 @@ describe('CodeActionsProvider', () => { const filePath = getFullPath(filename); const document = docManager.openDocument({ uri: pathToUrl(filePath), - text: ts.sys.readFile(filePath) || '', + text: harmonizeNewLines(ts.sys.readFile(filePath) || ''), }); return { provider, document, docManager }; } @@ -111,7 +111,7 @@ describe('CodeActionsProvider', () => { edits: [ { // eslint-disable-next-line max-len - newText: `import { A } from 'bla';${ts.sys.newLine}import { C } from 'blubb';${ts.sys.newLine}`, + newText: `import { A } from 'bla';\nimport { C } from 'blubb';\n`, range: { start: { character: 0, @@ -162,4 +162,191 @@ describe('CodeActionsProvider', () => { }, ]); }); + + it('should do extract into const refactor', async () => { + const { provider, document } = setup('codeactions.svelte'); + + const actions = await provider.getCodeActions( + document, + Range.create(Position.create(7, 8), Position.create(7, 42)), + { diagnostics: [], only: [CodeActionKind.Refactor] }, + ); + const action = actions[0]; + + assert.deepStrictEqual(action, { + command: { + arguments: [ + getUri('codeactions.svelte'), + { + type: 'refactor', + refactorName: 'Extract Symbol', + originalRange: { + start: { + character: 8, + line: 7, + }, + end: { + character: 42, + line: 7, + }, + }, + textRange: { + pos: 129, + end: 163, + }, + }, + ], + command: 'constant_scope_0', + title: 'Extract to constant in enclosing scope', + }, + title: 'Extract to constant in enclosing scope', + }); + + const edit = await provider.executeCommand( + document, + action.command?.command || '', + action.command?.arguments, + ); + + (edit?.documentChanges?.[0])?.edits.forEach( + (edit) => (edit.newText = harmonizeNewLines(edit.newText)), + ); + + assert.deepStrictEqual(edit, { + documentChanges: [ + { + edits: [ + { + // eslint-disable-next-line max-len + newText: `const newLocal=Math.random()>0.5? true:false;\n`, + range: { + start: { + character: 0, + line: 7, + }, + end: { + character: 0, + line: 7, + }, + }, + }, + { + newText: 'newLocal', + range: { + start: { + character: 8, + line: 7, + }, + end: { + character: 42, + line: 7, + }, + }, + }, + ], + textDocument: { + uri: getUri('codeactions.svelte'), + version: null, + }, + }, + ], + }); + }); + + it('should do extract into function refactor', async () => { + const { provider, document } = setup('codeactions.svelte'); + + const actions = await provider.getCodeActions( + document, + Range.create(Position.create(7, 8), Position.create(7, 42)), + { diagnostics: [], only: [CodeActionKind.Refactor] }, + ); + const action = actions[1]; + + assert.deepStrictEqual(action, { + command: { + arguments: [ + getUri('codeactions.svelte'), + { + type: 'refactor', + refactorName: 'Extract Symbol', + originalRange: { + start: { + character: 8, + line: 7, + }, + end: { + character: 42, + line: 7, + }, + }, + textRange: { + pos: 129, + end: 163, + }, + }, + ], + command: 'function_scope_0', + title: `Extract to inner function in function 'render'`, + }, + title: 'Extract to function', + }); + + const edit = await provider.executeCommand( + document, + action.command?.command || '', + action.command?.arguments, + ); + + (edit?.documentChanges?.[0])?.edits.forEach( + (edit) => (edit.newText = harmonizeNewLines(edit.newText)), + ); + + assert.deepStrictEqual(edit, { + documentChanges: [ + { + edits: [ + { + newText: 'newFunction()', + range: { + start: { + character: 8, + line: 7, + }, + end: { + character: 42, + line: 7, + }, + }, + }, + { + newText: + '\n' + + '\n' + + 'function newFunction() {' + + '\n' + + 'return Math.random()>0.5? true:false;' + + '\n' + + '}' + + '\n', + range: { + start: { + character: 0, + line: 8, + }, + end: { + character: 0, + line: 8, + }, + }, + }, + ], + textDocument: { + uri: getUri('codeactions.svelte'), + version: null, + }, + }, + ], + }); + }); }); diff --git a/packages/language-server/test/plugins/typescript/testfiles/codeactions.svelte b/packages/language-server/test/plugins/typescript/testfiles/codeactions.svelte index 1a86a90b9..4f7c0b474 100644 --- a/packages/language-server/test/plugins/typescript/testfiles/codeactions.svelte +++ b/packages/language-server/test/plugins/typescript/testfiles/codeactions.svelte @@ -5,4 +5,5 @@ import {A} from 'bla'; let a = true; A;C; +let b = Math.random() > 0.5 ? true : false; \ No newline at end of file