Skip to content

Commit b1a5172

Browse files
authored
(feat) add extract into const/function refactorings (#230)
* (feat) add extract into const/function refactorings * fix todo, add tests, cleanup
1 parent a52da9d commit b1a5172

File tree

8 files changed

+431
-6
lines changed

8 files changed

+431
-6
lines changed

packages/language-server/src/lib/documents/utils.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,10 +233,20 @@ function getLineOffsets(text: string) {
233233
return lineOffsets;
234234
}
235235

236-
export function isInTag(position: Position, tagInfo: TagInformation | null): boolean {
236+
export function isInTag(
237+
position: Position,
238+
tagInfo: TagInformation | null,
239+
): tagInfo is TagInformation {
237240
return !!tagInfo && isInRange(Range.create(tagInfo.startPos, tagInfo.endPos), position);
238241
}
239242

243+
export function isRangeInTag(
244+
range: Range,
245+
tagInfo: TagInformation | null,
246+
): tagInfo is TagInformation {
247+
return isInTag(range.start, tagInfo) && isInTag(range.end, tagInfo);
248+
}
249+
240250
export function getTextInRange(range: Range, text: string) {
241251
return text.substring(offsetAt(range.start, text), offsetAt(range.end, text));
242252
}

packages/language-server/src/plugins/PluginHost.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,23 @@ export class PluginHost implements LSProvider, OnWatchFileChanges {
249249
);
250250
}
251251

252+
async executeCommand(
253+
textDocument: TextDocumentIdentifier,
254+
command: string,
255+
args?: any[],
256+
): Promise<WorkspaceEdit | null> {
257+
const document = this.getDocument(textDocument.uri);
258+
if (!document) {
259+
throw new Error('Cannot call methods on an unopened document');
260+
}
261+
262+
return await this.execute<WorkspaceEdit>(
263+
'executeCommand',
264+
[document, command, args],
265+
ExecuteMode.FirstNonNull,
266+
);
267+
}
268+
252269
async updateImports(fileRename: FileRename): Promise<WorkspaceEdit | null> {
253270
return await this.execute<WorkspaceEdit>(
254271
'updateImports',

packages/language-server/src/plugins/interfaces.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,11 @@ export interface CodeActionsProvider {
8484
range: Range,
8585
context: CodeActionContext,
8686
): Resolvable<CodeAction[]>;
87+
executeCommand?(
88+
document: Document,
89+
command: string,
90+
args?: any[],
91+
): Resolvable<WorkspaceEdit | null>;
8792
}
8893

8994
export interface FileRename {

packages/language-server/src/plugins/typescript/TypeScriptPlugin.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,18 @@ export class TypeScriptPlugin
264264
return this.codeActionsProvider.getCodeActions(document, range, context);
265265
}
266266

267+
async executeCommand(
268+
document: Document,
269+
command: string,
270+
args?: any[],
271+
): Promise<WorkspaceEdit | null> {
272+
if (!this.featureEnabled('codeActions')) {
273+
return null;
274+
}
275+
276+
return this.codeActionsProvider.executeCommand(document, command, args);
277+
}
278+
267279
async updateImports(fileRename: FileRename): Promise<WorkspaceEdit | null> {
268280
if (!this.featureEnabled('rename')) {
269281
return null;

packages/language-server/src/plugins/typescript/features/CodeActionsProvider.ts

Lines changed: 163 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,23 @@ import {
66
TextDocumentEdit,
77
TextEdit,
88
VersionedTextDocumentIdentifier,
9+
WorkspaceEdit,
910
} from 'vscode-languageserver';
10-
import { Document, mapRangeToOriginal } from '../../../lib/documents';
11+
import { Document, mapRangeToOriginal, isRangeInTag } from '../../../lib/documents';
1112
import { pathToUrl } from '../../../utils';
1213
import { CodeActionsProvider } from '../../interfaces';
1314
import { SnapshotFragment } from '../DocumentSnapshot';
1415
import { LSAndTSDocResolver } from '../LSAndTSDocResolver';
1516
import { convertRange } from '../utils';
17+
import { flatten } from '../../../utils';
18+
import ts from 'typescript';
19+
20+
interface RefactorArgs {
21+
type: 'refactor';
22+
refactorName: string;
23+
textRange: ts.TextRange;
24+
originalRange: Range;
25+
}
1626

1727
export class CodeActionsProviderImpl implements CodeActionsProvider {
1828
constructor(private readonly lsAndTsDocResolver: LSAndTSDocResolver) {}
@@ -26,10 +36,17 @@ export class CodeActionsProviderImpl implements CodeActionsProvider {
2636
return await this.organizeImports(document);
2737
}
2838

29-
if (!context.only || context.only.includes(CodeActionKind.QuickFix)) {
39+
if (
40+
context.diagnostics.length &&
41+
(!context.only || context.only.includes(CodeActionKind.QuickFix))
42+
) {
3043
return await this.applyQuickfix(document, range, context);
3144
}
3245

46+
if (!context.only || context.only.includes(CodeActionKind.Refactor)) {
47+
return await this.getApplicableRefactors(document, range);
48+
}
49+
3350
return [];
3451
}
3552

@@ -124,6 +141,150 @@ export class CodeActionsProviderImpl implements CodeActionsProvider {
124141
);
125142
}
126143

144+
private async getApplicableRefactors(document: Document, range: Range): Promise<CodeAction[]> {
145+
if (
146+
!isRangeInTag(range, document.scriptInfo) &&
147+
!isRangeInTag(range, document.moduleScriptInfo)
148+
) {
149+
return [];
150+
}
151+
152+
const { lang, tsDoc } = this.getLSAndTSDoc(document);
153+
const fragment = await tsDoc.getFragment();
154+
const textRange = {
155+
pos: fragment.offsetAt(fragment.getGeneratedPosition(range.start)),
156+
end: fragment.offsetAt(fragment.getGeneratedPosition(range.end)),
157+
};
158+
const applicableRefactors = lang.getApplicableRefactors(
159+
document.getFilePath() || '',
160+
textRange,
161+
undefined,
162+
);
163+
164+
return (
165+
this.applicableRefactorsToCodeActions(applicableRefactors, document, range, textRange)
166+
// Only allow refactorings from which we know they work
167+
.filter(
168+
(refactor) =>
169+
refactor.command?.command.includes('function_scope') ||
170+
refactor.command?.command.includes('constant_scope'),
171+
)
172+
// The language server also proposes extraction into const/function in module scope,
173+
// which is outside of the render function, which is svelte2tsx-specific and unmapped,
174+
// so it would both not work and confuse the user ("What is this render? Never declared that").
175+
// So filter out the module scope proposal and rename the render-title
176+
.filter((refactor) => !refactor.title.includes('module scope'))
177+
.map((refactor) => ({
178+
...refactor,
179+
title: refactor.title
180+
.replace(
181+
`Extract to inner function in function 'render'`,
182+
'Extract to function',
183+
)
184+
.replace(`Extract to constant in function 'render'`, 'Extract to constant'),
185+
}))
186+
);
187+
}
188+
189+
private applicableRefactorsToCodeActions(
190+
applicableRefactors: ts.ApplicableRefactorInfo[],
191+
document: Document,
192+
originalRange: Range,
193+
textRange: { pos: number; end: number },
194+
) {
195+
return flatten(
196+
applicableRefactors.map((applicableRefactor) => {
197+
if (applicableRefactor.inlineable === false) {
198+
return [
199+
CodeAction.create(applicableRefactor.description, {
200+
title: applicableRefactor.description,
201+
command: applicableRefactor.name,
202+
arguments: [
203+
document.uri,
204+
<RefactorArgs>{
205+
type: 'refactor',
206+
textRange,
207+
originalRange,
208+
refactorName: 'Extract Symbol',
209+
},
210+
],
211+
}),
212+
];
213+
}
214+
215+
return applicableRefactor.actions.map((action) => {
216+
return CodeAction.create(action.description, {
217+
title: action.description,
218+
command: action.name,
219+
arguments: [
220+
document.uri,
221+
<RefactorArgs>{
222+
type: 'refactor',
223+
textRange,
224+
originalRange,
225+
refactorName: applicableRefactor.name,
226+
},
227+
],
228+
});
229+
});
230+
}),
231+
);
232+
}
233+
234+
async executeCommand(
235+
document: Document,
236+
command: string,
237+
args?: any[],
238+
): Promise<WorkspaceEdit | null> {
239+
if (!(args?.[1]?.type === 'refactor')) {
240+
return null;
241+
}
242+
243+
const { lang, tsDoc } = this.getLSAndTSDoc(document);
244+
const fragment = await tsDoc.getFragment();
245+
const path = document.getFilePath() || '';
246+
const { refactorName, originalRange, textRange } = <RefactorArgs>args[1];
247+
248+
const edits = lang.getEditsForRefactor(
249+
path,
250+
{},
251+
textRange,
252+
refactorName,
253+
command,
254+
undefined,
255+
);
256+
if (!edits || edits.edits.length === 0) {
257+
return null;
258+
}
259+
260+
const documentChanges = edits?.edits.map((edit) =>
261+
TextDocumentEdit.create(
262+
VersionedTextDocumentIdentifier.create(document.uri, null),
263+
edit.textChanges.map((edit) => {
264+
let range = mapRangeToOriginal(fragment, convertRange(fragment, edit.span));
265+
// Some refactorings place the new code at the end of svelte2tsx' render function,
266+
// which is unmapped. In this case, add it to the end of the script tag ourselves.
267+
if (range.start.line < 0 || range.end.line < 0) {
268+
if (isRangeInTag(originalRange, document.scriptInfo)) {
269+
range = Range.create(
270+
document.scriptInfo.endPos,
271+
document.scriptInfo.endPos,
272+
);
273+
} else if (isRangeInTag(originalRange, document.moduleScriptInfo)) {
274+
range = Range.create(
275+
document.moduleScriptInfo.endPos,
276+
document.moduleScriptInfo.endPos,
277+
);
278+
}
279+
}
280+
return TextEdit.replace(range, edit.newText);
281+
}),
282+
),
283+
);
284+
285+
return { documentChanges };
286+
}
287+
127288
private getLSAndTSDoc(document: Document) {
128289
return this.lsAndTsDocResolver.getLSAndTSDoc(document);
129290
}

packages/language-server/src/server.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import {
1010
CodeActionKind,
1111
RenameFile,
1212
DocumentUri,
13+
ApplyWorkspaceEditRequest,
14+
ApplyWorkspaceEditParams,
1315
} from 'vscode-languageserver';
1416
import { DocumentManager, Document } from './lib/documents';
1517
import {
@@ -90,6 +92,8 @@ export function startServer(options?: LSOptions) {
9092
pluginHost.register(new CSSPlugin(docManager, configManager));
9193
pluginHost.register(new TypeScriptPlugin(docManager, configManager, workspacePath));
9294

95+
const clientSupportApplyEditCommand = !!evt.capabilities.workspace?.applyEdit;
96+
9397
return {
9498
capabilities: {
9599
textDocumentSync: {
@@ -137,9 +141,24 @@ export function startServer(options?: LSOptions) {
137141
codeActionKinds: [
138142
CodeActionKind.QuickFix,
139143
CodeActionKind.SourceOrganizeImports,
144+
...(clientSupportApplyEditCommand ? [CodeActionKind.Refactor] : []),
140145
],
141146
}
142147
: true,
148+
executeCommandProvider: clientSupportApplyEditCommand
149+
? {
150+
commands: [
151+
'function_scope_0',
152+
'function_scope_1',
153+
'function_scope_2',
154+
'function_scope_3',
155+
'constant_scope_0',
156+
'constant_scope_1',
157+
'constant_scope_2',
158+
'constant_scope_3',
159+
],
160+
}
161+
: undefined,
143162
renameProvider: evt.capabilities.textDocument?.rename?.prepareSupport
144163
? { prepareProvider: true }
145164
: true,
@@ -175,9 +194,22 @@ export function startServer(options?: LSOptions) {
175194
);
176195
connection.onDocumentSymbol((evt) => pluginHost.getDocumentSymbols(evt.textDocument));
177196
connection.onDefinition((evt) => pluginHost.getDefinitions(evt.textDocument, evt.position));
197+
178198
connection.onCodeAction((evt) =>
179199
pluginHost.getCodeActions(evt.textDocument, evt.range, evt.context),
180200
);
201+
connection.onExecuteCommand(async (evt) => {
202+
const result = await pluginHost.executeCommand(
203+
{ uri: evt.arguments?.[0] },
204+
evt.command,
205+
evt.arguments,
206+
);
207+
if (result) {
208+
const edit: ApplyWorkspaceEditParams = { edit: result };
209+
connection?.sendRequest(ApplyWorkspaceEditRequest.type.method, edit);
210+
}
211+
});
212+
181213
connection.onCompletionResolve((completionItem) => {
182214
const data = (completionItem as AppCompletionItem).data as TextDocumentIdentifier;
183215

0 commit comments

Comments
 (0)