Skip to content

Commit 64d7b77

Browse files
authored
feat: experimental support for Svelte 5 (#2198)
* 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
1 parent 663e602 commit 64d7b77

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

52 files changed

+539
-157
lines changed

packages/language-server/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
"prettier": "~2.8.8",
5656
"prettier-plugin-svelte": "~2.10.1",
5757
"svelte": "^3.57.0",
58-
"svelte-preprocess": "~5.0.4",
58+
"svelte-preprocess": "~5.1.0",
5959
"svelte2tsx": "workspace:~",
6060
"typescript": "^5.2.2",
6161
"vscode-css-languageservice": "~6.2.0",

packages/language-server/src/importPackage.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export function importSvelte(fromPath: string): typeof svelte {
5858
const pkg = getPackageInfo('svelte', fromPath);
5959
const main = resolve(pkg.path, 'compiler');
6060
Logger.debug('Using Svelte v' + pkg.version.full, 'from', main);
61-
return dynamicRequire(main + (pkg.version.major === 4 ? '.cjs' : ''));
61+
return dynamicRequire(main + (pkg.version.major >= 4 ? '.cjs' : ''));
6262
}
6363

6464
export function importSveltePreprocess(fromPath: string): typeof sveltePreprocess {

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

+2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { Logger } from '../../logger';
2+
// @ts-ignore
23
import { CompileOptions } from 'svelte/types/compiler/interfaces';
4+
// @ts-ignore
35
import { PreprocessorGroup } from 'svelte/types/compiler/preprocess';
46
import { importSveltePreprocess } from '../../importPackage';
57
import _glob from 'fast-glob';

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

+4-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { TraceMap } from '@jridgewell/trace-mapping';
22
import type { compile } from 'svelte/compiler';
3+
// @ts-ignore
34
import { CompileOptions } from 'svelte/types/compiler/interfaces';
5+
// @ts-ignore
46
import { PreprocessorGroup, Processed } from 'svelte/types/compiler/preprocess';
57
import { Position } from 'vscode-languageserver';
68
import { getPackageInfo, importSvelte } from '../../importPackage';
@@ -367,7 +369,7 @@ export class SvelteFragmentMapper implements PositionMapper {
367369
*/
368370
function wrapPreprocessors(preprocessors: PreprocessorGroup | PreprocessorGroup[] = []) {
369371
preprocessors = Array.isArray(preprocessors) ? preprocessors : [preprocessors];
370-
return preprocessors.map((preprocessor) => {
372+
return preprocessors.map((preprocessor: any) => {
371373
const wrappedPreprocessor: PreprocessorGroup = { markup: preprocessor.markup };
372374

373375
if (preprocessor.script) {
@@ -404,7 +406,7 @@ async function transpile(
404406
const processedScripts: Processed[] = [];
405407
const processedStyles: Processed[] = [];
406408

407-
const wrappedPreprocessors = preprocessors.map((preprocessor) => {
409+
const wrappedPreprocessors = preprocessors.map((preprocessor: any) => {
408410
const wrappedPreprocessor: PreprocessorGroup = { markup: preprocessor.markup };
409411

410412
if (preprocessor.script) {

packages/language-server/src/plugins/svelte/features/SvelteTags.ts

+14-4
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@ import { SvelteDocument } from '../SvelteDocument';
33
/**
44
* Special svelte syntax tags that do template logic.
55
*/
6-
export type SvelteLogicTag = 'each' | 'if' | 'await' | 'key';
6+
export type SvelteLogicTag = 'each' | 'if' | 'await' | 'key' | 'snippet';
77

88
/**
99
* Special svelte syntax tags.
1010
*/
11-
export type SvelteTag = SvelteLogicTag | 'html' | 'debug' | 'const';
11+
export type SvelteTag = SvelteLogicTag | 'html' | 'debug' | 'const' | 'render';
1212

1313
/**
1414
* For each tag, a documentation in markdown format.
@@ -52,6 +52,13 @@ When used around components, this will cause them to be reinstantiated and reini
5252
\`{#key expression}...{/key}\`\\
5353
\\
5454
https://svelte.dev/docs#template-syntax-key
55+
`,
56+
snippet: `\`{#snippet identifier(parameter)}...{/snippet}\`\\
57+
Snippets allow you to create reusable UI blocks you can render with the {@render ...} tag.
58+
They also function as slot props for components.
59+
`,
60+
render: `\`{@render ...}\`\\
61+
Renders a snippet with the given parameters.
5562
`,
5663
html:
5764
`\`{@html ...}\`\\
@@ -80,9 +87,11 @@ It accepts a comma-separated list of variable names (not arbitrary expressions).
8087
https://svelte.dev/docs#template-syntax-debug
8188
`,
8289
const: `\`{@const ...}\`\\
83-
TODO
90+
Defines a local constant}\\
8491
#### Usage:
8592
\`{@const a = b + c}\`\\
93+
\\
94+
https://svelte.dev/docs/special-tags#const
8695
`
8796
};
8897

@@ -102,7 +111,8 @@ export function getLatestOpeningTag(
102111
idxOfLastOpeningTag(content, 'each'),
103112
idxOfLastOpeningTag(content, 'if'),
104113
idxOfLastOpeningTag(content, 'await'),
105-
idxOfLastOpeningTag(content, 'key')
114+
idxOfLastOpeningTag(content, 'key'),
115+
idxOfLastOpeningTag(content, 'snippet')
106116
];
107117
const lastIdx = lastIdxs.sort((i1, i2) => i2.lastIdx - i1.lastIdx);
108118
return lastIdx[0].lastIdx === -1 ? null : lastIdx[0].tag;

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

+1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { walk } from 'estree-walker';
22
import { EOL } from 'os';
3+
// @ts-ignore
34
import { TemplateNode } from 'svelte/types/compiler/interfaces';
45
import {
56
CodeAction,

packages/language-server/src/plugins/svelte/features/getCompletions.ts

+7-2
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,8 @@ function getCompletionsWithRegardToTriggerCharacter(
125125
return createCompletionItems([
126126
{ tag: 'html', label: 'html' },
127127
{ tag: 'debug', label: 'debug' },
128-
{ tag: 'const', label: 'const' }
128+
{ tag: 'const', label: 'const' },
129+
{ tag: 'render', label: 'render' }
129130
]);
130131
}
131132

@@ -143,7 +144,8 @@ function getCompletionsWithRegardToTriggerCharacter(
143144
label: 'await then',
144145
insertText: 'await $1 then $2}\n\t$3\n{/await'
145146
},
146-
{ tag: 'key', label: 'key', insertText: 'key $1}\n\t$2\n{/key' }
147+
{ tag: 'key', label: 'key', insertText: 'key $1}\n\t$2\n{/key' },
148+
{ tag: 'snippet', label: 'snippet', insertText: 'snippet $1($2)}\n\t$3\n{/snippet' }
147149
]);
148150
}
149151

@@ -207,6 +209,7 @@ function showCompletionWithRegardsToOpenedTags(
207209
ifOpen: CompletionList;
208210
awaitOpen: CompletionList;
209211
keyOpen?: CompletionList;
212+
snippetOpen?: CompletionList;
210213
},
211214
svelteDoc: SvelteDocument,
212215
offset: number
@@ -220,6 +223,8 @@ function showCompletionWithRegardsToOpenedTags(
220223
return on.awaitOpen;
221224
case 'key':
222225
return on?.keyOpen ?? null;
226+
case 'snippet':
227+
return on.snippetOpen ?? null;
223228
default:
224229
return null;
225230
}

packages/language-server/src/plugins/svelte/features/getDiagnostics.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// @ts-ignore
12
import { Warning } from 'svelte/types/compiler/interfaces';
23
import { Diagnostic, DiagnosticSeverity, Position, Range } from 'vscode-languageserver';
34
import {

packages/language-server/src/plugins/svelte/features/getHoverInfo.ts

+3
Original file line numberDiff line numberDiff line change
@@ -109,10 +109,13 @@ const tagPossibilities: Array<{ tag: SvelteTag | ':else'; values: string[] }> =
109109
{ tag: 'await' as const, values: ['#await', '/await', ':then', ':catch'] },
110110
// key
111111
{ tag: 'key' as const, values: ['#key', '/key'] },
112+
// snippet
113+
{ tag: 'snippet' as const, values: ['#snippet', '/snippet'] },
112114
// @
113115
{ tag: 'html' as const, values: ['@html'] },
114116
{ tag: 'debug' as const, values: ['@debug'] },
115117
{ tag: 'const' as const, values: ['@const'] },
118+
{ tag: 'render' as const, values: ['@render'] },
116119
// this tag has multiple possibilities
117120
{ tag: ':else' as const, values: [':else'] }
118121
];

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

+3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { EncodedSourceMap, TraceMap, originalPositionFor } from '@jridgewell/trace-mapping';
2+
// @ts-ignore
23
import { TemplateNode } from 'svelte/types/compiler/interfaces';
34
import { svelte2tsx, IExportedNames, internalHelpers } from 'svelte2tsx';
45
import ts from 'typescript';
@@ -66,6 +67,7 @@ export interface DocumentSnapshot extends ts.IScriptSnapshot, DocumentMapper {
6667
* Options that apply to svelte files.
6768
*/
6869
export interface SvelteSnapshotOptions {
70+
parse: typeof import('svelte/compiler').parse | undefined;
6971
transformOnTemplateError: boolean;
7072
typingsNamespace: string;
7173
}
@@ -196,6 +198,7 @@ function preprocessSvelteFile(document: Document, options: SvelteSnapshotOptions
196198

197199
try {
198200
const tsx = svelte2tsx(text, {
201+
parse: options.parse,
199202
filename: document.getFilePath() ?? undefined,
200203
isTsFile: scriptKind === ts.ScriptKind.TS,
201204
mode: 'ts',

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

+69-44
Original file line numberDiff line numberDiff line change
@@ -268,48 +268,63 @@ export class CompletionsProviderImpl implements CompletionsProvider<CompletionEn
268268
const fileUrl = pathToUrl(tsDoc.filePath);
269269
const isCompletionInTag = svelteIsInTag(svelteNode, originalOffset);
270270

271+
const completionItems: CompletionItem[] = eventAndSlotLetCompletions;
272+
const isValidCompletion = createIsValidCompletion(document, position, !!tsDoc.parserError);
273+
const addCompletion = (entry: ts.CompletionEntry, asStore: boolean) => {
274+
if (isValidCompletion(entry)) {
275+
let completion = this.toCompletionItem(
276+
tsDoc,
277+
entry,
278+
fileUrl,
279+
position,
280+
isCompletionInTag,
281+
addCommitCharacters,
282+
asStore,
283+
existingImports
284+
);
285+
if (completion) {
286+
completionItems.push(
287+
this.fixTextEditRange(
288+
wordRangeStartPosition,
289+
mapCompletionItemToOriginal(tsDoc, completion)
290+
)
291+
);
292+
}
293+
}
294+
};
295+
271296
// If completion is about a store which is not imported yet, do another
272297
// completion request at the beginning of the file to get all global
273298
// import completions and then filter them down to likely matches.
274299
if (word.charAt(0) === '$') {
275300
const storeName = word.substring(1);
276301
const text = '__sveltets_2_store_get(' + storeName;
277302
if (!tsDoc.getFullText().includes(text)) {
278-
const storeImportCompletions =
279-
lang
280-
.getCompletionsAtPosition(
281-
filePath,
282-
0,
283-
{
284-
...userPreferences,
285-
triggerCharacter: validTriggerCharacter
286-
},
287-
formatSettings
288-
)
289-
?.entries.filter(
290-
(entry) => entry.source && entry.name.startsWith(storeName)
291-
) || [];
292-
completions.push(...storeImportCompletions);
303+
const pos = (tsDoc.scriptInfo || tsDoc.moduleScriptInfo)?.endPos ?? {
304+
line: 0,
305+
character: 0
306+
};
307+
const virtualOffset = tsDoc.offsetAt(tsDoc.getGeneratedPosition(pos));
308+
const storeCompletions = lang.getCompletionsAtPosition(
309+
filePath,
310+
virtualOffset,
311+
{
312+
...userPreferences,
313+
triggerCharacter: validTriggerCharacter
314+
},
315+
formatSettings
316+
);
317+
for (const entry of storeCompletions?.entries || []) {
318+
if (entry.name.startsWith(storeName)) {
319+
addCompletion(entry, true);
320+
}
321+
}
293322
}
294323
}
295324

296-
const completionItems = completions
297-
.filter(isValidCompletion(document, position, !!tsDoc.parserError))
298-
.map((comp) =>
299-
this.toCompletionItem(
300-
tsDoc,
301-
comp,
302-
fileUrl,
303-
position,
304-
isCompletionInTag,
305-
addCommitCharacters,
306-
existingImports
307-
)
308-
)
309-
.filter(isNotNullOrUndefined)
310-
.map((comp) => mapCompletionItemToOriginal(tsDoc, comp))
311-
.map((comp) => this.fixTextEditRange(wordRangeStartPosition, comp))
312-
.concat(eventAndSlotLetCompletions);
325+
for (const entry of completions) {
326+
addCompletion(entry, false);
327+
}
313328

314329
// Add ./$types imports for SvelteKit since TypeScript is bad at it
315330
if (basename(filePath).startsWith('+')) {
@@ -455,14 +470,16 @@ export class CompletionsProviderImpl implements CompletionsProvider<CompletionEn
455470
position: Position,
456471
isCompletionInTag: boolean,
457472
addCommitCharacters: boolean,
473+
asStore: boolean,
458474
existingImports: Set<string>
459475
): AppCompletionItem<CompletionEntryWithIdentifier> | null {
460476
const completionLabelAndInsert = this.getCompletionLabelAndInsert(snapshot, comp);
461477
if (!completionLabelAndInsert) {
462478
return null;
463479
}
464480

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

482502
const textEdit = replacementSpan
@@ -496,9 +516,9 @@ export class CompletionsProviderImpl implements CompletionsProvider<CompletionEn
496516
insertText,
497517
kind: scriptElementKindToCompletionItemKind(comp.kind),
498518
commitCharacters: addCommitCharacters ? this.commitCharacters : undefined,
499-
// Make sure svelte component takes precedence
500-
sortText: isSvelteComp ? '-1' : comp.sortText,
501-
preselect: isSvelteComp ? true : comp.isRecommended,
519+
// Make sure svelte component and runes take precedence
520+
sortText: isRunesCompletion || isSvelteComp ? '-1' : comp.sortText,
521+
preselect: isRunesCompletion || isSvelteComp ? true : comp.isRecommended,
502522
insertTextFormat: comp.isSnippet ? InsertTextFormat.Snippet : undefined,
503523
labelDetails,
504524
textEdit,
@@ -518,7 +538,9 @@ export class CompletionsProviderImpl implements CompletionsProvider<CompletionEn
518538
let { name, insertText, kindModifiers } = comp;
519539
const isScriptElement = comp.kind === ts.ScriptElementKind.scriptElement;
520540
const hasModifier = Boolean(comp.kindModifiers);
521-
const isSvelteComp = isGeneratedSvelteComponentName(name);
541+
const isRunesCompletion =
542+
name === '$props' || name === '$state' || name === '$derived' || name === '$effect';
543+
const isSvelteComp = !isRunesCompletion && isGeneratedSvelteComponentName(name);
522544
if (isSvelteComp) {
523545
name = changeSvelteComponentName(name);
524546

@@ -533,14 +555,16 @@ export class CompletionsProviderImpl implements CompletionsProvider<CompletionEn
533555
return {
534556
insertText: name,
535557
label,
536-
isSvelteComp
558+
isSvelteComp,
559+
isRunesCompletion
537560
};
538561
}
539562

540563
if (comp.replacementSpan) {
541564
return {
542565
label: name,
543566
isSvelteComp,
567+
isRunesCompletion,
544568
insertText: insertText ? changeSvelteComponentName(insertText) : undefined,
545569
replacementSpan: comp.replacementSpan
546570
};
@@ -549,7 +573,8 @@ export class CompletionsProviderImpl implements CompletionsProvider<CompletionEn
549573
return {
550574
label: name,
551575
insertText,
552-
isSvelteComp
576+
isSvelteComp,
577+
isRunesCompletion
553578
};
554579
}
555580

@@ -653,12 +678,12 @@ export class CompletionsProviderImpl implements CompletionsProvider<CompletionEn
653678

654679
const detail = lang.getCompletionEntryDetails(
655680
filePath,
656-
tsDoc.offsetAt(tsDoc.getGeneratedPosition(comp!.position)),
657-
comp!.name,
681+
tsDoc.offsetAt(tsDoc.getGeneratedPosition(comp.position)),
682+
comp.name,
658683
formatCodeOptions,
659-
comp!.source,
684+
comp.source,
660685
errorPreventingUserPreferences,
661-
comp!.data
686+
comp.data
662687
);
663688

664689
if (detail) {
@@ -913,7 +938,7 @@ const svelte2tsxTypes = new Set([
913938

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

916-
function isValidCompletion(
941+
function createIsValidCompletion(
917942
document: Document,
918943
position: Position,
919944
hasParserError: boolean

0 commit comments

Comments
 (0)