Skip to content

Commit

Permalink
(feat) extract component refactoring (#262)
Browse files Browse the repository at this point in the history
* (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
  • Loading branch information
dummdidumm committed Jul 6, 2020
1 parent 30e1c44 commit 6f30321
Show file tree
Hide file tree
Showing 16 changed files with 530 additions and 64 deletions.
18 changes: 18 additions & 0 deletions packages/language-server/src/lib/documents/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { clamp, isInRange, regexLastIndexOf } from '../../utils';
import { Position, Range } from 'vscode-languageserver';
import { Node, getLanguageService } from 'vscode-html-languageservice';
import * as path from 'path';

export interface TagInformation {
content: string;
Expand Down Expand Up @@ -254,3 +255,20 @@ export function getLineAtPosition(position: Position, text: string) {
offsetAt({ line: position.line, character: Number.MAX_VALUE }, text),
);
}

/**
* Updates a relative import
*
* @param oldPath Old absolute path
* @param newPath New absolute path
* @param relativeImportPath Import relative to the old path
*/
export function updateRelativeImport(oldPath: string, newPath: string, relativeImportPath: string) {
let newImportPath = path
.join(path.relative(newPath, oldPath), relativeImportPath)
.replace(/\\/g, '/');
if (!newImportPath.startsWith('.')) {
newImportPath = './' + newImportPath;
}
return newImportPath;
}
2 changes: 1 addition & 1 deletion packages/language-server/src/plugins/PluginHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ export class PluginHost implements LSProvider, OnWatchFileChanges {
textDocument: TextDocumentIdentifier,
command: string,
args?: any[],
): Promise<WorkspaceEdit | null> {
): Promise<WorkspaceEdit | string | null> {
const document = this.getDocument(textDocument.uri);
if (!document) {
throw new Error('Cannot call methods on an unopened document');
Expand Down
2 changes: 1 addition & 1 deletion packages/language-server/src/plugins/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export interface CodeActionsProvider {
document: Document,
command: string,
args?: any[],
): Resolvable<WorkspaceEdit | null>;
): Resolvable<WorkspaceEdit | string | null>;
}

export interface FileRename {
Expand Down
3 changes: 3 additions & 0 deletions packages/language-server/src/plugins/svelte/SvelteDocument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,15 @@ export class SvelteDocument {
private compileResult: SvelteCompileResult | undefined;

public script: TagInformation | null;
public moduleScript: TagInformation | null;
public style: TagInformation | null;
public languageId = 'svelte';
public version = 0;
public uri = this.parent.uri;

constructor(private parent: Document, public config: SvelteConfig) {
this.script = this.parent.scriptInfo;
this.moduleScript = this.parent.moduleScriptInfo;
this.style = this.parent.styleInfo;
this.version = this.parent.version;
}
Expand Down
24 changes: 21 additions & 3 deletions packages/language-server/src/plugins/svelte/SveltePlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
Position,
Range,
TextEdit,
WorkspaceEdit,
} from 'vscode-languageserver';
import { Document } from '../../lib/documents';
import { Logger } from '../../logger';
Expand All @@ -21,7 +22,7 @@ import {
FormattingProvider,
HoverProvider,
} from '../interfaces';
import { getCodeActions } from './features/getCodeActions';
import { getCodeActions, executeCommand } from './features/getCodeActions';
import { getCompletions } from './features/getCompletions';
import { getDiagnostics } from './features/getDiagnostics';
import { getHoverInfo } from './features/getHoverInfo';
Expand Down Expand Up @@ -108,7 +109,7 @@ export class SveltePlugin

async getCodeActions(
document: Document,
_range: Range,
range: Range,
context: CodeActionContext,
): Promise<CodeAction[]> {
if (!this.featureEnabled('codeActions')) {
Expand All @@ -117,12 +118,29 @@ export class SveltePlugin

const svelteDoc = await this.getSvelteDoc(document);
try {
return getCodeActions(svelteDoc, context);
return getCodeActions(svelteDoc, range, context);
} catch (error) {
return [];
}
}

async executeCommand(
document: Document,
command: string,
args?: any[],
): Promise<WorkspaceEdit | string | null> {
if (!this.featureEnabled('codeActions')) {
return null;
}

const svelteDoc = await this.getSvelteDoc(document);
try {
return executeCommand(svelteDoc, command, args);
} catch (error) {
return null;
}
}

private featureEnabled(feature: keyof LSSvelteConfig) {
return (
this.configManager.enabled('svelte.enable') &&
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
import { walk } from 'estree-walker';
import { EOL } from 'os';
import { Ast } from 'svelte/types/compiler/interfaces';
import {
Diagnostic,
CodeActionContext,
CodeAction,
TextEdit,
TextDocumentEdit,
Position,
CodeActionKind,
VersionedTextDocumentIdentifier,
Diagnostic,
DiagnosticSeverity,
Position,
TextDocumentEdit,
TextEdit,
VersionedTextDocumentIdentifier,
} from 'vscode-languageserver';
import { walk } from 'estree-walker';
import { EOL } from 'os';
import { SvelteDocument } from '../SvelteDocument';
import { pathToUrl } from '../../../utils';
import { positionAt, offsetAt, mapTextEditToOriginal } from '../../../lib/documents';
import { Ast } from 'svelte/types/compiler/interfaces';
import { mapTextEditToOriginal, offsetAt, positionAt } from '../../../../lib/documents';
import { pathToUrl } from '../../../../utils';
import { SvelteDocument } from '../../SvelteDocument';
import ts from 'typescript';
// There are multiple estree-walker versions in the monorepo.
// The newer versions don't have start/end in their public interface,
// but the AST returned by svelte/compiler does.
Expand All @@ -23,26 +23,23 @@ import { Ast } from 'svelte/types/compiler/interfaces';
// all depend on the same estree(-walker) version, this should be revisited.
type Node = any;

interface OffsetRange {
start: number;
end: number;
}

export async function getCodeActions(
/**
* Get applicable quick fixes.
*/
export async function getQuickfixActions(
svelteDoc: SvelteDocument,
context: CodeActionContext,
): Promise<CodeAction[]> {
svelteDiagnostics: Diagnostic[],
) {
const { ast } = await svelteDoc.getCompiled();
const svelteDiagnostics = context.diagnostics.filter(isIgnorableSvelteDiagnostic);

return Promise.all(
svelteDiagnostics.map(
async (diagnostic) => await createCodeAction(diagnostic, svelteDoc, ast),
async (diagnostic) => await createQuickfixAction(diagnostic, svelteDoc, ast),
),
);
}

async function createCodeAction(
async function createQuickfixAction(
diagnostic: Diagnostic,
svelteDoc: SvelteDocument,
ast: Ast,
Expand Down Expand Up @@ -70,7 +67,7 @@ function getCodeActionTitle(diagnostic: Diagnostic) {
return `(svelte) Disable ${diagnostic.code} for this line`;
}

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

const diagnosticStartOffset = offsetAt(start, transpiled.getText());
const diagnosticEndOffset = offsetAt(end, transpiled.getText());
const OffsetRange = {
start: diagnosticStartOffset,
const offsetRange: ts.TextRange = {
pos: diagnosticStartOffset,
end: diagnosticEndOffset,
};

const node = findTagForRange(html, OffsetRange);
const node = findTagForRange(html, offsetRange);

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

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

function findTagForRange(html: Node, range: OffsetRange) {
function findTagForRange(html: Node, range: ts.TextRange) {
let nearest = html;

walk(html, {
Expand All @@ -136,6 +133,6 @@ function findTagForRange(html: Node, range: OffsetRange) {
return nearest;
}

function within(node: Node, range: OffsetRange) {
return node.end >= range.end && node.start <= range.start;
function within(node: Node, range: ts.TextRange) {
return node.end >= range.end && node.start <= range.pos;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import * as path from 'path';
import {
CreateFile,
Position,
Range,
TextDocumentEdit,
TextEdit,
VersionedTextDocumentIdentifier,
WorkspaceEdit,
} from 'vscode-languageserver';
import { isRangeInTag, TagInformation, updateRelativeImport } from '../../../../lib/documents';
import { pathToUrl } from '../../../../utils';
import { SvelteDocument } from '../../SvelteDocument';

export interface ExtractComponentArgs {
uri: string;
range: Range;
filePath: string;
}

export const extractComponentCommand = 'extract_to_svelte_component';

export async function executeRefactoringCommand(
svelteDoc: SvelteDocument,
command: string,
args?: any[],
): Promise<WorkspaceEdit | string | null> {
if (command === extractComponentCommand && args) {
return executeExtractComponentCommand(svelteDoc, args[1]);
}

return null;
}

async function executeExtractComponentCommand(
svelteDoc: SvelteDocument,
refactorArgs: ExtractComponentArgs,
): Promise<WorkspaceEdit | string | null> {
const { range } = refactorArgs;

if (isInvalidSelectionRange()) {
return 'Invalid selection range';
}

let filePath = refactorArgs.filePath || './NewComponent.svelte';
if (!filePath.endsWith('.svelte')) {
filePath += '.svelte';
}
if (!filePath.startsWith('.')) {
filePath = './' + filePath;
}
const componentName = filePath.split('/').pop()?.split('.svelte')[0] || '';
const newFileUri = pathToUrl(path.join(path.dirname(svelteDoc.getFilePath()), filePath));

return <WorkspaceEdit>{
documentChanges: [
TextDocumentEdit.create(VersionedTextDocumentIdentifier.create(svelteDoc.uri, null), [
TextEdit.replace(range, `<${componentName}></${componentName}>`),
createComponentImportTextEdit(),
]),
CreateFile.create(newFileUri, { overwrite: true }),
createNewFileEdit(),
],
};

function isInvalidSelectionRange() {
const text = svelteDoc.getText();
const offsetStart = svelteDoc.offsetAt(range.start);
const offsetEnd = svelteDoc.offsetAt(range.end);
const validStart = offsetStart === 0 || /[\s\W]/.test(text[offsetStart - 1]);
const validEnd = offsetEnd === text.length - 1 || /[\s\W]/.test(text[offsetEnd]);
return (
!validStart ||
!validEnd ||
isRangeInTag(range, svelteDoc.style) ||
isRangeInTag(range, svelteDoc.script) ||
isRangeInTag(range, svelteDoc.moduleScript)
);
}

function createNewFileEdit() {
const text = svelteDoc.getText();
const newText = [
getTemplate(),
getTag(svelteDoc.script, false),
getTag(svelteDoc.moduleScript, false),
getTag(svelteDoc.style, true),
]
.filter((tag) => tag.start >= 0)
.sort((a, b) => a.start - b.start)
.map((tag) => tag.text)
.join('');

return TextDocumentEdit.create(VersionedTextDocumentIdentifier.create(newFileUri, null), [
TextEdit.insert(Position.create(0, 0), newText),
]);

function getTemplate() {
const startOffset = svelteDoc.offsetAt(range.start);
return {
text: text.substring(startOffset, svelteDoc.offsetAt(range.end)) + '\n\n',
start: startOffset,
};
}

function getTag(tag: TagInformation | null, isStyleTag: boolean) {
if (!tag) {
return { text: '', start: -1 };
}

const tagText = updateRelativeImports(
svelteDoc,
text.substring(tag.container.start, tag.container.end),
filePath,
isStyleTag,
);
return {
text: `${tagText}\n\n`,
start: tag.container.start,
};
}
}

function createComponentImportTextEdit(): TextEdit {
const startPos = (svelteDoc.script || svelteDoc.moduleScript)?.startPos;
const importText = `\n import ${componentName} from '${filePath}';\n`;
return TextEdit.insert(
startPos || Position.create(0, 0),
startPos ? importText : `<script>\n${importText}</script>`,
);
}
}

// `import {...} from '..'` or `import ... from '..'`
// eslint-disable-next-line max-len
const scriptRelativeImportRegex = /import\s+{[^}]*}.*['"`](((\.\/)|(\.\.\/)).*?)['"`]|import\s+\w+\s+from\s+['"`](((\.\/)|(\.\.\/)).*?)['"`]/g;

This comment has been minimized.

Copy link
@aaomidi

aaomidi Jul 9, 2020

ouch - writing this was prob a PITA

// `@import '..'`
const styleRelativeImportRege = /@import\s+['"`](((\.\/)|(\.\.\/)).*?)['"`]/g;

function updateRelativeImports(
svelteDoc: SvelteDocument,
tagText: string,
newComponentRelativePath: string,
isStyleTag: boolean,
) {
const oldPath = path.dirname(svelteDoc.getFilePath());
const newPath = path.dirname(path.join(oldPath, newComponentRelativePath));
const regex = isStyleTag ? styleRelativeImportRege : scriptRelativeImportRegex;
let match = regex.exec(tagText);
while (match) {
// match[1]: match before | and style regex. match[5]: match after | (script regex)
const importPath = match[1] || match[5];
const newImportPath = updateRelativeImport(oldPath, newPath, importPath);
tagText = tagText.replace(importPath, newImportPath);
match = regex.exec(tagText);
}
return tagText;
}

0 comments on commit 6f30321

Please sign in to comment.