Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -37,12 +37,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;
// `@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;
}
Loading