Skip to content

Commit 6f30321

Browse files
authored
(feat) extract component refactoring (#262)
* (feat) extract component refactoring #187 * (feat) extract component now with prompt for path/name - Is no longer part of the getCodeActions now, which might also improve performance a little * tests * docs * lint * (feat) update relative imports
1 parent 30e1c44 commit 6f30321

File tree

16 files changed

+530
-64
lines changed

16 files changed

+530
-64
lines changed

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { clamp, isInRange, regexLastIndexOf } from '../../utils';
22
import { Position, Range } from 'vscode-languageserver';
33
import { Node, getLanguageService } from 'vscode-html-languageservice';
4+
import * as path from 'path';
45

56
export interface TagInformation {
67
content: string;
@@ -254,3 +255,20 @@ export function getLineAtPosition(position: Position, text: string) {
254255
offsetAt({ line: position.line, character: Number.MAX_VALUE }, text),
255256
);
256257
}
258+
259+
/**
260+
* Updates a relative import
261+
*
262+
* @param oldPath Old absolute path
263+
* @param newPath New absolute path
264+
* @param relativeImportPath Import relative to the old path
265+
*/
266+
export function updateRelativeImport(oldPath: string, newPath: string, relativeImportPath: string) {
267+
let newImportPath = path
268+
.join(path.relative(newPath, oldPath), relativeImportPath)
269+
.replace(/\\/g, '/');
270+
if (!newImportPath.startsWith('.')) {
271+
newImportPath = './' + newImportPath;
272+
}
273+
return newImportPath;
274+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ export class PluginHost implements LSProvider, OnWatchFileChanges {
253253
textDocument: TextDocumentIdentifier,
254254
command: string,
255255
args?: any[],
256-
): Promise<WorkspaceEdit | null> {
256+
): Promise<WorkspaceEdit | string | null> {
257257
const document = this.getDocument(textDocument.uri);
258258
if (!document) {
259259
throw new Error('Cannot call methods on an unopened document');

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ export interface CodeActionsProvider {
8888
document: Document,
8989
command: string,
9090
args?: any[],
91-
): Resolvable<WorkspaceEdit | null>;
91+
): Resolvable<WorkspaceEdit | string | null>;
9292
}
9393

9494
export interface FileRename {

packages/language-server/src/plugins/svelte/SvelteDocument.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,15 @@ export class SvelteDocument {
3838
private compileResult: SvelteCompileResult | undefined;
3939

4040
public script: TagInformation | null;
41+
public moduleScript: TagInformation | null;
4142
public style: TagInformation | null;
4243
public languageId = 'svelte';
4344
public version = 0;
45+
public uri = this.parent.uri;
4446

4547
constructor(private parent: Document, public config: SvelteConfig) {
4648
this.script = this.parent.scriptInfo;
49+
this.moduleScript = this.parent.moduleScriptInfo;
4750
this.style = this.parent.styleInfo;
4851
this.version = this.parent.version;
4952
}

packages/language-server/src/plugins/svelte/SveltePlugin.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
Position,
1010
Range,
1111
TextEdit,
12+
WorkspaceEdit,
1213
} from 'vscode-languageserver';
1314
import { Document } from '../../lib/documents';
1415
import { Logger } from '../../logger';
@@ -21,7 +22,7 @@ import {
2122
FormattingProvider,
2223
HoverProvider,
2324
} from '../interfaces';
24-
import { getCodeActions } from './features/getCodeActions';
25+
import { getCodeActions, executeCommand } from './features/getCodeActions';
2526
import { getCompletions } from './features/getCompletions';
2627
import { getDiagnostics } from './features/getDiagnostics';
2728
import { getHoverInfo } from './features/getHoverInfo';
@@ -108,7 +109,7 @@ export class SveltePlugin
108109

109110
async getCodeActions(
110111
document: Document,
111-
_range: Range,
112+
range: Range,
112113
context: CodeActionContext,
113114
): Promise<CodeAction[]> {
114115
if (!this.featureEnabled('codeActions')) {
@@ -117,12 +118,29 @@ export class SveltePlugin
117118

118119
const svelteDoc = await this.getSvelteDoc(document);
119120
try {
120-
return getCodeActions(svelteDoc, context);
121+
return getCodeActions(svelteDoc, range, context);
121122
} catch (error) {
122123
return [];
123124
}
124125
}
125126

127+
async executeCommand(
128+
document: Document,
129+
command: string,
130+
args?: any[],
131+
): Promise<WorkspaceEdit | string | null> {
132+
if (!this.featureEnabled('codeActions')) {
133+
return null;
134+
}
135+
136+
const svelteDoc = await this.getSvelteDoc(document);
137+
try {
138+
return executeCommand(svelteDoc, command, args);
139+
} catch (error) {
140+
return null;
141+
}
142+
}
143+
126144
private featureEnabled(feature: keyof LSSvelteConfig) {
127145
return (
128146
this.configManager.enabled('svelte.enable') &&

packages/language-server/src/plugins/svelte/features/getCodeActions.ts renamed to packages/language-server/src/plugins/svelte/features/getCodeActions/getQuickfixes.ts

Lines changed: 27 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
1+
import { walk } from 'estree-walker';
2+
import { EOL } from 'os';
3+
import { Ast } from 'svelte/types/compiler/interfaces';
14
import {
2-
Diagnostic,
3-
CodeActionContext,
45
CodeAction,
5-
TextEdit,
6-
TextDocumentEdit,
7-
Position,
86
CodeActionKind,
9-
VersionedTextDocumentIdentifier,
7+
Diagnostic,
108
DiagnosticSeverity,
9+
Position,
10+
TextDocumentEdit,
11+
TextEdit,
12+
VersionedTextDocumentIdentifier,
1113
} from 'vscode-languageserver';
12-
import { walk } from 'estree-walker';
13-
import { EOL } from 'os';
14-
import { SvelteDocument } from '../SvelteDocument';
15-
import { pathToUrl } from '../../../utils';
16-
import { positionAt, offsetAt, mapTextEditToOriginal } from '../../../lib/documents';
17-
import { Ast } from 'svelte/types/compiler/interfaces';
14+
import { mapTextEditToOriginal, offsetAt, positionAt } from '../../../../lib/documents';
15+
import { pathToUrl } from '../../../../utils';
16+
import { SvelteDocument } from '../../SvelteDocument';
17+
import ts from 'typescript';
1818
// There are multiple estree-walker versions in the monorepo.
1919
// The newer versions don't have start/end in their public interface,
2020
// but the AST returned by svelte/compiler does.
@@ -23,26 +23,23 @@ import { Ast } from 'svelte/types/compiler/interfaces';
2323
// all depend on the same estree(-walker) version, this should be revisited.
2424
type Node = any;
2525

26-
interface OffsetRange {
27-
start: number;
28-
end: number;
29-
}
30-
31-
export async function getCodeActions(
26+
/**
27+
* Get applicable quick fixes.
28+
*/
29+
export async function getQuickfixActions(
3230
svelteDoc: SvelteDocument,
33-
context: CodeActionContext,
34-
): Promise<CodeAction[]> {
31+
svelteDiagnostics: Diagnostic[],
32+
) {
3533
const { ast } = await svelteDoc.getCompiled();
36-
const svelteDiagnostics = context.diagnostics.filter(isIgnorableSvelteDiagnostic);
3734

3835
return Promise.all(
3936
svelteDiagnostics.map(
40-
async (diagnostic) => await createCodeAction(diagnostic, svelteDoc, ast),
37+
async (diagnostic) => await createQuickfixAction(diagnostic, svelteDoc, ast),
4138
),
4239
);
4340
}
4441

45-
async function createCodeAction(
42+
async function createQuickfixAction(
4643
diagnostic: Diagnostic,
4744
svelteDoc: SvelteDocument,
4845
ast: Ast,
@@ -70,7 +67,7 @@ function getCodeActionTitle(diagnostic: Diagnostic) {
7067
return `(svelte) Disable ${diagnostic.code} for this line`;
7168
}
7269

73-
function isIgnorableSvelteDiagnostic(diagnostic: Diagnostic) {
70+
export function isIgnorableSvelteDiagnostic(diagnostic: Diagnostic) {
7471
const { source, severity, code } = diagnostic;
7572
return code && source === 'svelte' && severity !== DiagnosticSeverity.Error;
7673
}
@@ -86,12 +83,12 @@ async function getSvelteIgnoreEdit(svelteDoc: SvelteDocument, ast: Ast, diagnost
8683

8784
const diagnosticStartOffset = offsetAt(start, transpiled.getText());
8885
const diagnosticEndOffset = offsetAt(end, transpiled.getText());
89-
const OffsetRange = {
90-
start: diagnosticStartOffset,
86+
const offsetRange: ts.TextRange = {
87+
pos: diagnosticStartOffset,
9188
end: diagnosticEndOffset,
9289
};
9390

94-
const node = findTagForRange(html, OffsetRange);
91+
const node = findTagForRange(html, offsetRange);
9592

9693
const nodeStartPosition = positionAt(node.start, content);
9794
const nodeLineStart = offsetAt(
@@ -113,7 +110,7 @@ async function getSvelteIgnoreEdit(svelteDoc: SvelteDocument, ast: Ast, diagnost
113110

114111
const elementOrComponent = ['Component', 'Element', 'InlineComponent'];
115112

116-
function findTagForRange(html: Node, range: OffsetRange) {
113+
function findTagForRange(html: Node, range: ts.TextRange) {
117114
let nearest = html;
118115

119116
walk(html, {
@@ -136,6 +133,6 @@ function findTagForRange(html: Node, range: OffsetRange) {
136133
return nearest;
137134
}
138135

139-
function within(node: Node, range: OffsetRange) {
140-
return node.end >= range.end && node.start <= range.start;
136+
function within(node: Node, range: ts.TextRange) {
137+
return node.end >= range.end && node.start <= range.pos;
141138
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import * as path from 'path';
2+
import {
3+
CreateFile,
4+
Position,
5+
Range,
6+
TextDocumentEdit,
7+
TextEdit,
8+
VersionedTextDocumentIdentifier,
9+
WorkspaceEdit,
10+
} from 'vscode-languageserver';
11+
import { isRangeInTag, TagInformation, updateRelativeImport } from '../../../../lib/documents';
12+
import { pathToUrl } from '../../../../utils';
13+
import { SvelteDocument } from '../../SvelteDocument';
14+
15+
export interface ExtractComponentArgs {
16+
uri: string;
17+
range: Range;
18+
filePath: string;
19+
}
20+
21+
export const extractComponentCommand = 'extract_to_svelte_component';
22+
23+
export async function executeRefactoringCommand(
24+
svelteDoc: SvelteDocument,
25+
command: string,
26+
args?: any[],
27+
): Promise<WorkspaceEdit | string | null> {
28+
if (command === extractComponentCommand && args) {
29+
return executeExtractComponentCommand(svelteDoc, args[1]);
30+
}
31+
32+
return null;
33+
}
34+
35+
async function executeExtractComponentCommand(
36+
svelteDoc: SvelteDocument,
37+
refactorArgs: ExtractComponentArgs,
38+
): Promise<WorkspaceEdit | string | null> {
39+
const { range } = refactorArgs;
40+
41+
if (isInvalidSelectionRange()) {
42+
return 'Invalid selection range';
43+
}
44+
45+
let filePath = refactorArgs.filePath || './NewComponent.svelte';
46+
if (!filePath.endsWith('.svelte')) {
47+
filePath += '.svelte';
48+
}
49+
if (!filePath.startsWith('.')) {
50+
filePath = './' + filePath;
51+
}
52+
const componentName = filePath.split('/').pop()?.split('.svelte')[0] || '';
53+
const newFileUri = pathToUrl(path.join(path.dirname(svelteDoc.getFilePath()), filePath));
54+
55+
return <WorkspaceEdit>{
56+
documentChanges: [
57+
TextDocumentEdit.create(VersionedTextDocumentIdentifier.create(svelteDoc.uri, null), [
58+
TextEdit.replace(range, `<${componentName}></${componentName}>`),
59+
createComponentImportTextEdit(),
60+
]),
61+
CreateFile.create(newFileUri, { overwrite: true }),
62+
createNewFileEdit(),
63+
],
64+
};
65+
66+
function isInvalidSelectionRange() {
67+
const text = svelteDoc.getText();
68+
const offsetStart = svelteDoc.offsetAt(range.start);
69+
const offsetEnd = svelteDoc.offsetAt(range.end);
70+
const validStart = offsetStart === 0 || /[\s\W]/.test(text[offsetStart - 1]);
71+
const validEnd = offsetEnd === text.length - 1 || /[\s\W]/.test(text[offsetEnd]);
72+
return (
73+
!validStart ||
74+
!validEnd ||
75+
isRangeInTag(range, svelteDoc.style) ||
76+
isRangeInTag(range, svelteDoc.script) ||
77+
isRangeInTag(range, svelteDoc.moduleScript)
78+
);
79+
}
80+
81+
function createNewFileEdit() {
82+
const text = svelteDoc.getText();
83+
const newText = [
84+
getTemplate(),
85+
getTag(svelteDoc.script, false),
86+
getTag(svelteDoc.moduleScript, false),
87+
getTag(svelteDoc.style, true),
88+
]
89+
.filter((tag) => tag.start >= 0)
90+
.sort((a, b) => a.start - b.start)
91+
.map((tag) => tag.text)
92+
.join('');
93+
94+
return TextDocumentEdit.create(VersionedTextDocumentIdentifier.create(newFileUri, null), [
95+
TextEdit.insert(Position.create(0, 0), newText),
96+
]);
97+
98+
function getTemplate() {
99+
const startOffset = svelteDoc.offsetAt(range.start);
100+
return {
101+
text: text.substring(startOffset, svelteDoc.offsetAt(range.end)) + '\n\n',
102+
start: startOffset,
103+
};
104+
}
105+
106+
function getTag(tag: TagInformation | null, isStyleTag: boolean) {
107+
if (!tag) {
108+
return { text: '', start: -1 };
109+
}
110+
111+
const tagText = updateRelativeImports(
112+
svelteDoc,
113+
text.substring(tag.container.start, tag.container.end),
114+
filePath,
115+
isStyleTag,
116+
);
117+
return {
118+
text: `${tagText}\n\n`,
119+
start: tag.container.start,
120+
};
121+
}
122+
}
123+
124+
function createComponentImportTextEdit(): TextEdit {
125+
const startPos = (svelteDoc.script || svelteDoc.moduleScript)?.startPos;
126+
const importText = `\n import ${componentName} from '${filePath}';\n`;
127+
return TextEdit.insert(
128+
startPos || Position.create(0, 0),
129+
startPos ? importText : `<script>\n${importText}</script>`,
130+
);
131+
}
132+
}
133+
134+
// `import {...} from '..'` or `import ... from '..'`
135+
// eslint-disable-next-line max-len
136+
const scriptRelativeImportRegex = /import\s+{[^}]*}.*['"`](((\.\/)|(\.\.\/)).*?)['"`]|import\s+\w+\s+from\s+['"`](((\.\/)|(\.\.\/)).*?)['"`]/g;
137+
// `@import '..'`
138+
const styleRelativeImportRege = /@import\s+['"`](((\.\/)|(\.\.\/)).*?)['"`]/g;
139+
140+
function updateRelativeImports(
141+
svelteDoc: SvelteDocument,
142+
tagText: string,
143+
newComponentRelativePath: string,
144+
isStyleTag: boolean,
145+
) {
146+
const oldPath = path.dirname(svelteDoc.getFilePath());
147+
const newPath = path.dirname(path.join(oldPath, newComponentRelativePath));
148+
const regex = isStyleTag ? styleRelativeImportRege : scriptRelativeImportRegex;
149+
let match = regex.exec(tagText);
150+
while (match) {
151+
// match[1]: match before | and style regex. match[5]: match after | (script regex)
152+
const importPath = match[1] || match[5];
153+
const newImportPath = updateRelativeImport(oldPath, newPath, importPath);
154+
tagText = tagText.replace(importPath, newImportPath);
155+
match = regex.exec(tagText);
156+
}
157+
return tagText;
158+
}

0 commit comments

Comments
 (0)