Skip to content

Commit

Permalink
feat: experimental support for Svelte 5 (#2198)
Browse files Browse the repository at this point in the history
* handle $props()

* handle .svelte.ts files

* remove $ from wordpattern to get proper runes autocompletion; adjust store autocompletion as a consequence

* snippets

* use walk from estree-walker

* more ts-ignore for types that don't exist anymore

* handle the cjs case for 5, too

* fix

* mark test as skipped for now

* lint

* load Svelte 5 compiler if applicable

* bump svelte-preprocess
  • Loading branch information
dummdidumm authored Nov 10, 2023
1 parent 663e602 commit 64d7b77
Show file tree
Hide file tree
Showing 52 changed files with 539 additions and 157 deletions.
2 changes: 1 addition & 1 deletion packages/language-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
"prettier": "~2.8.8",
"prettier-plugin-svelte": "~2.10.1",
"svelte": "^3.57.0",
"svelte-preprocess": "~5.0.4",
"svelte-preprocess": "~5.1.0",
"svelte2tsx": "workspace:~",
"typescript": "^5.2.2",
"vscode-css-languageservice": "~6.2.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/language-server/src/importPackage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export function importSvelte(fromPath: string): typeof svelte {
const pkg = getPackageInfo('svelte', fromPath);
const main = resolve(pkg.path, 'compiler');
Logger.debug('Using Svelte v' + pkg.version.full, 'from', main);
return dynamicRequire(main + (pkg.version.major === 4 ? '.cjs' : ''));
return dynamicRequire(main + (pkg.version.major >= 4 ? '.cjs' : ''));
}

export function importSveltePreprocess(fromPath: string): typeof sveltePreprocess {
Expand Down
2 changes: 2 additions & 0 deletions packages/language-server/src/lib/documents/configLoader.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Logger } from '../../logger';
// @ts-ignore
import { CompileOptions } from 'svelte/types/compiler/interfaces';
// @ts-ignore
import { PreprocessorGroup } from 'svelte/types/compiler/preprocess';
import { importSveltePreprocess } from '../../importPackage';
import _glob from 'fast-glob';
Expand Down
6 changes: 4 additions & 2 deletions packages/language-server/src/plugins/svelte/SvelteDocument.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { TraceMap } from '@jridgewell/trace-mapping';
import type { compile } from 'svelte/compiler';
// @ts-ignore
import { CompileOptions } from 'svelte/types/compiler/interfaces';
// @ts-ignore
import { PreprocessorGroup, Processed } from 'svelte/types/compiler/preprocess';
import { Position } from 'vscode-languageserver';
import { getPackageInfo, importSvelte } from '../../importPackage';
Expand Down Expand Up @@ -367,7 +369,7 @@ export class SvelteFragmentMapper implements PositionMapper {
*/
function wrapPreprocessors(preprocessors: PreprocessorGroup | PreprocessorGroup[] = []) {
preprocessors = Array.isArray(preprocessors) ? preprocessors : [preprocessors];
return preprocessors.map((preprocessor) => {
return preprocessors.map((preprocessor: any) => {
const wrappedPreprocessor: PreprocessorGroup = { markup: preprocessor.markup };

if (preprocessor.script) {
Expand Down Expand Up @@ -404,7 +406,7 @@ async function transpile(
const processedScripts: Processed[] = [];
const processedStyles: Processed[] = [];

const wrappedPreprocessors = preprocessors.map((preprocessor) => {
const wrappedPreprocessors = preprocessors.map((preprocessor: any) => {
const wrappedPreprocessor: PreprocessorGroup = { markup: preprocessor.markup };

if (preprocessor.script) {
Expand Down
18 changes: 14 additions & 4 deletions packages/language-server/src/plugins/svelte/features/SvelteTags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ import { SvelteDocument } from '../SvelteDocument';
/**
* Special svelte syntax tags that do template logic.
*/
export type SvelteLogicTag = 'each' | 'if' | 'await' | 'key';
export type SvelteLogicTag = 'each' | 'if' | 'await' | 'key' | 'snippet';

/**
* Special svelte syntax tags.
*/
export type SvelteTag = SvelteLogicTag | 'html' | 'debug' | 'const';
export type SvelteTag = SvelteLogicTag | 'html' | 'debug' | 'const' | 'render';

/**
* For each tag, a documentation in markdown format.
Expand Down Expand Up @@ -52,6 +52,13 @@ When used around components, this will cause them to be reinstantiated and reini
\`{#key expression}...{/key}\`\\
\\
https://svelte.dev/docs#template-syntax-key
`,
snippet: `\`{#snippet identifier(parameter)}...{/snippet}\`\\
Snippets allow you to create reusable UI blocks you can render with the {@render ...} tag.
They also function as slot props for components.
`,
render: `\`{@render ...}\`\\
Renders a snippet with the given parameters.
`,
html:
`\`{@html ...}\`\\
Expand Down Expand Up @@ -80,9 +87,11 @@ It accepts a comma-separated list of variable names (not arbitrary expressions).
https://svelte.dev/docs#template-syntax-debug
`,
const: `\`{@const ...}\`\\
TODO
Defines a local constant}\\
#### Usage:
\`{@const a = b + c}\`\\
\\
https://svelte.dev/docs/special-tags#const
`
};

Expand All @@ -102,7 +111,8 @@ export function getLatestOpeningTag(
idxOfLastOpeningTag(content, 'each'),
idxOfLastOpeningTag(content, 'if'),
idxOfLastOpeningTag(content, 'await'),
idxOfLastOpeningTag(content, 'key')
idxOfLastOpeningTag(content, 'key'),
idxOfLastOpeningTag(content, 'snippet')
];
const lastIdx = lastIdxs.sort((i1, i2) => i2.lastIdx - i1.lastIdx);
return lastIdx[0].lastIdx === -1 ? null : lastIdx[0].tag;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { walk } from 'estree-walker';
import { EOL } from 'os';
// @ts-ignore
import { TemplateNode } from 'svelte/types/compiler/interfaces';
import {
CodeAction,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,8 @@ function getCompletionsWithRegardToTriggerCharacter(
return createCompletionItems([
{ tag: 'html', label: 'html' },
{ tag: 'debug', label: 'debug' },
{ tag: 'const', label: 'const' }
{ tag: 'const', label: 'const' },
{ tag: 'render', label: 'render' }
]);
}

Expand All @@ -143,7 +144,8 @@ function getCompletionsWithRegardToTriggerCharacter(
label: 'await then',
insertText: 'await $1 then $2}\n\t$3\n{/await'
},
{ tag: 'key', label: 'key', insertText: 'key $1}\n\t$2\n{/key' }
{ tag: 'key', label: 'key', insertText: 'key $1}\n\t$2\n{/key' },
{ tag: 'snippet', label: 'snippet', insertText: 'snippet $1($2)}\n\t$3\n{/snippet' }
]);
}

Expand Down Expand Up @@ -207,6 +209,7 @@ function showCompletionWithRegardsToOpenedTags(
ifOpen: CompletionList;
awaitOpen: CompletionList;
keyOpen?: CompletionList;
snippetOpen?: CompletionList;
},
svelteDoc: SvelteDocument,
offset: number
Expand All @@ -220,6 +223,8 @@ function showCompletionWithRegardsToOpenedTags(
return on.awaitOpen;
case 'key':
return on?.keyOpen ?? null;
case 'snippet':
return on.snippetOpen ?? null;
default:
return null;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// @ts-ignore
import { Warning } from 'svelte/types/compiler/interfaces';
import { Diagnostic, DiagnosticSeverity, Position, Range } from 'vscode-languageserver';
import {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,13 @@ const tagPossibilities: Array<{ tag: SvelteTag | ':else'; values: string[] }> =
{ tag: 'await' as const, values: ['#await', '/await', ':then', ':catch'] },
// key
{ tag: 'key' as const, values: ['#key', '/key'] },
// snippet
{ tag: 'snippet' as const, values: ['#snippet', '/snippet'] },
// @
{ tag: 'html' as const, values: ['@html'] },
{ tag: 'debug' as const, values: ['@debug'] },
{ tag: 'const' as const, values: ['@const'] },
{ tag: 'render' as const, values: ['@render'] },
// this tag has multiple possibilities
{ tag: ':else' as const, values: [':else'] }
];
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { EncodedSourceMap, TraceMap, originalPositionFor } from '@jridgewell/trace-mapping';
// @ts-ignore
import { TemplateNode } from 'svelte/types/compiler/interfaces';
import { svelte2tsx, IExportedNames, internalHelpers } from 'svelte2tsx';
import ts from 'typescript';
Expand Down Expand Up @@ -66,6 +67,7 @@ export interface DocumentSnapshot extends ts.IScriptSnapshot, DocumentMapper {
* Options that apply to svelte files.
*/
export interface SvelteSnapshotOptions {
parse: typeof import('svelte/compiler').parse | undefined;
transformOnTemplateError: boolean;
typingsNamespace: string;
}
Expand Down Expand Up @@ -196,6 +198,7 @@ function preprocessSvelteFile(document: Document, options: SvelteSnapshotOptions

try {
const tsx = svelte2tsx(text, {
parse: options.parse,
filename: document.getFilePath() ?? undefined,
isTsFile: scriptKind === ts.ScriptKind.TS,
mode: 'ts',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -268,48 +268,63 @@ export class CompletionsProviderImpl implements CompletionsProvider<CompletionEn
const fileUrl = pathToUrl(tsDoc.filePath);
const isCompletionInTag = svelteIsInTag(svelteNode, originalOffset);

const completionItems: CompletionItem[] = eventAndSlotLetCompletions;
const isValidCompletion = createIsValidCompletion(document, position, !!tsDoc.parserError);
const addCompletion = (entry: ts.CompletionEntry, asStore: boolean) => {
if (isValidCompletion(entry)) {
let completion = this.toCompletionItem(
tsDoc,
entry,
fileUrl,
position,
isCompletionInTag,
addCommitCharacters,
asStore,
existingImports
);
if (completion) {
completionItems.push(
this.fixTextEditRange(
wordRangeStartPosition,
mapCompletionItemToOriginal(tsDoc, completion)
)
);
}
}
};

// If completion is about a store which is not imported yet, do another
// completion request at the beginning of the file to get all global
// import completions and then filter them down to likely matches.
if (word.charAt(0) === '$') {
const storeName = word.substring(1);
const text = '__sveltets_2_store_get(' + storeName;
if (!tsDoc.getFullText().includes(text)) {
const storeImportCompletions =
lang
.getCompletionsAtPosition(
filePath,
0,
{
...userPreferences,
triggerCharacter: validTriggerCharacter
},
formatSettings
)
?.entries.filter(
(entry) => entry.source && entry.name.startsWith(storeName)
) || [];
completions.push(...storeImportCompletions);
const pos = (tsDoc.scriptInfo || tsDoc.moduleScriptInfo)?.endPos ?? {
line: 0,
character: 0
};
const virtualOffset = tsDoc.offsetAt(tsDoc.getGeneratedPosition(pos));
const storeCompletions = lang.getCompletionsAtPosition(
filePath,
virtualOffset,
{
...userPreferences,
triggerCharacter: validTriggerCharacter
},
formatSettings
);
for (const entry of storeCompletions?.entries || []) {
if (entry.name.startsWith(storeName)) {
addCompletion(entry, true);
}
}
}
}

const completionItems = completions
.filter(isValidCompletion(document, position, !!tsDoc.parserError))
.map((comp) =>
this.toCompletionItem(
tsDoc,
comp,
fileUrl,
position,
isCompletionInTag,
addCommitCharacters,
existingImports
)
)
.filter(isNotNullOrUndefined)
.map((comp) => mapCompletionItemToOriginal(tsDoc, comp))
.map((comp) => this.fixTextEditRange(wordRangeStartPosition, comp))
.concat(eventAndSlotLetCompletions);
for (const entry of completions) {
addCompletion(entry, false);
}

// Add ./$types imports for SvelteKit since TypeScript is bad at it
if (basename(filePath).startsWith('+')) {
Expand Down Expand Up @@ -455,14 +470,16 @@ export class CompletionsProviderImpl implements CompletionsProvider<CompletionEn
position: Position,
isCompletionInTag: boolean,
addCommitCharacters: boolean,
asStore: boolean,
existingImports: Set<string>
): AppCompletionItem<CompletionEntryWithIdentifier> | null {
const completionLabelAndInsert = this.getCompletionLabelAndInsert(snapshot, comp);
if (!completionLabelAndInsert) {
return null;
}

let { label, insertText, isSvelteComp, replacementSpan } = completionLabelAndInsert;
let { label, insertText, isSvelteComp, isRunesCompletion, replacementSpan } =
completionLabelAndInsert;
// TS may suggest another Svelte component even if there already exists an import
// with the same name, because under the hood every Svelte component is postfixed
// with `__SvelteComponent`. In this case, filter out this completion by returning null.
Expand All @@ -477,6 +494,9 @@ export class CompletionsProviderImpl implements CompletionsProvider<CompletionEn
label[label.length - 1] === '"'
) {
label = label.slice(1, -1);
} else if (asStore) {
// only modify label, so that the data property is untouched, which is important so the resolving still works
label = `$${label}`;
}

const textEdit = replacementSpan
Expand All @@ -496,9 +516,9 @@ export class CompletionsProviderImpl implements CompletionsProvider<CompletionEn
insertText,
kind: scriptElementKindToCompletionItemKind(comp.kind),
commitCharacters: addCommitCharacters ? this.commitCharacters : undefined,
// Make sure svelte component takes precedence
sortText: isSvelteComp ? '-1' : comp.sortText,
preselect: isSvelteComp ? true : comp.isRecommended,
// Make sure svelte component and runes take precedence
sortText: isRunesCompletion || isSvelteComp ? '-1' : comp.sortText,
preselect: isRunesCompletion || isSvelteComp ? true : comp.isRecommended,
insertTextFormat: comp.isSnippet ? InsertTextFormat.Snippet : undefined,
labelDetails,
textEdit,
Expand All @@ -518,7 +538,9 @@ export class CompletionsProviderImpl implements CompletionsProvider<CompletionEn
let { name, insertText, kindModifiers } = comp;
const isScriptElement = comp.kind === ts.ScriptElementKind.scriptElement;
const hasModifier = Boolean(comp.kindModifiers);
const isSvelteComp = isGeneratedSvelteComponentName(name);
const isRunesCompletion =
name === '$props' || name === '$state' || name === '$derived' || name === '$effect';
const isSvelteComp = !isRunesCompletion && isGeneratedSvelteComponentName(name);
if (isSvelteComp) {
name = changeSvelteComponentName(name);

Expand All @@ -533,14 +555,16 @@ export class CompletionsProviderImpl implements CompletionsProvider<CompletionEn
return {
insertText: name,
label,
isSvelteComp
isSvelteComp,
isRunesCompletion
};
}

if (comp.replacementSpan) {
return {
label: name,
isSvelteComp,
isRunesCompletion,
insertText: insertText ? changeSvelteComponentName(insertText) : undefined,
replacementSpan: comp.replacementSpan
};
Expand All @@ -549,7 +573,8 @@ export class CompletionsProviderImpl implements CompletionsProvider<CompletionEn
return {
label: name,
insertText,
isSvelteComp
isSvelteComp,
isRunesCompletion
};
}

Expand Down Expand Up @@ -653,12 +678,12 @@ export class CompletionsProviderImpl implements CompletionsProvider<CompletionEn

const detail = lang.getCompletionEntryDetails(
filePath,
tsDoc.offsetAt(tsDoc.getGeneratedPosition(comp!.position)),
comp!.name,
tsDoc.offsetAt(tsDoc.getGeneratedPosition(comp.position)),
comp.name,
formatCodeOptions,
comp!.source,
comp.source,
errorPreventingUserPreferences,
comp!.data
comp.data
);

if (detail) {
Expand Down Expand Up @@ -913,7 +938,7 @@ const svelte2tsxTypes = new Set([

const startsWithUppercase = /^[A-Z]/;

function isValidCompletion(
function createIsValidCompletion(
document: Document,
position: Position,
hasParserError: boolean
Expand Down
Loading

0 comments on commit 64d7b77

Please sign in to comment.