Skip to content

Commit 957b8d6

Browse files
authored
chore: Bindable and Svelte 5 interop (#2336)
- $bindable() support, adjust language server to accomodate for weird error message types and binding shorthand rename - run Svelte 5 in CI - remove programmatic implicit children handling, it's handled within Svelte's component types by now - cannot bind to exports in runes mode
1 parent 4c8c0db commit 957b8d6

File tree

94 files changed

+1589
-311
lines changed

Some content is hidden

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

94 files changed

+1589
-311
lines changed

.github/workflows/CI.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,31 @@ jobs:
2323
env:
2424
CI: true
2525

26+
test-svelte5:
27+
runs-on: ubuntu-latest
28+
29+
steps:
30+
- uses: actions/checkout@v3
31+
- uses: pnpm/[email protected]
32+
- uses: actions/setup-node@v3
33+
with:
34+
node-version: "18.x"
35+
cache: pnpm
36+
37+
# Lets us use one-liner JSON manipulations the package.json files
38+
- run: "npm install -g json"
39+
40+
# Get projects set up
41+
- run: json -I -f package.json -e 'this.pnpm={"overrides":{"svelte":"^5.0.0-next.100"}}'
42+
- run: pnpm install --no-frozen-lockfile
43+
- run: pnpm bootstrap
44+
- run: pnpm build
45+
46+
# Run any tests
47+
- run: pnpm test
48+
env:
49+
CI: true
50+
2651
lint:
2752
runs-on: ubuntu-latest
2853

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ async function tryGetDiagnostics(
6363
if (cancellationToken?.isCancellationRequested) {
6464
return [];
6565
}
66-
return (((res.stats as any)?.warnings || res.warnings || []) as Warning[])
66+
return (res.warnings || [])
6767
.filter((warning) => settings[warning.code] !== 'ignore')
6868
.map((warning) => {
6969
const start = warning.start || { line: 1, column: 0 };

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ export namespace DocumentSnapshot {
8787
document,
8888
parserError,
8989
scriptKind,
90+
options.version,
9091
text,
9192
nrPrependedLines,
9293
exportedNames,
@@ -272,13 +273,18 @@ export class SvelteDocumentSnapshot implements DocumentSnapshot {
272273
public readonly parent: Document,
273274
public readonly parserError: ParserError | null,
274275
public readonly scriptKind: ts.ScriptKind,
276+
public readonly svelteVersion: string | undefined,
275277
private readonly text: string,
276278
private readonly nrPrependedLines: number,
277279
private readonly exportedNames: IExportedNames,
278280
private readonly tsxMap?: EncodedSourceMap,
279281
private readonly htmlAst?: TemplateNode
280282
) {}
281283

284+
get isSvelte5Plus() {
285+
return Number(this.svelteVersion?.split('.')[0]) >= 5;
286+
}
287+
282288
get filePath() {
283289
return this.parent.getFilePath() || '';
284290
}

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

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,14 @@ import {
2121
isStoreVariableIn$storeDeclaration,
2222
get$storeOffsetOf$storeDeclaration
2323
} from './utils';
24-
import { not, flatten, passMap, swapRangeStartEndIfNecessary, memoize } from '../../../utils';
24+
import {
25+
not,
26+
flatten,
27+
passMap,
28+
swapRangeStartEndIfNecessary,
29+
memoize,
30+
traverseTypeString
31+
} from '../../../utils';
2532
import { LSConfigManager } from '../../../ls-config';
2633
import { isAttributeName, isEventHandler } from '../svelte-ast-utils';
2734

@@ -37,7 +44,10 @@ export enum DiagnosticCode {
3744
DUPLICATED_JSX_ATTRIBUTES = 17001, // "JSX elements cannot have multiple attributes with the same name."
3845
DUPLICATE_IDENTIFIER = 2300, // "Duplicate identifier 'xxx'"
3946
MULTIPLE_PROPS_SAME_NAME = 1117, // "An object literal cannot have multiple properties with the same name in strict mode."
40-
TYPE_X_NOT_ASSIGNABLE_TO_TYPE_Y = 2345, // "Argument of type '..' is not assignable to parameter of type '..'."
47+
ARG_TYPE_X_NOT_ASSIGNABLE_TO_TYPE_Y = 2345, // "Argument of type '..' is not assignable to parameter of type '..'."
48+
TYPE_X_NOT_ASSIGNABLE_TO_TYPE_Y = 2322, // "Type '..' is not assignable to type '..'."
49+
TYPE_X_NOT_ASSIGNABLE_TO_TYPE_Y_DID_YOU_MEAN = 2820, // "Type '..' is not assignable to type '..'. Did you mean '...'?"
50+
UNKNOWN_PROP = 2353, // "Object literal may only specify known properties, and '...' does not exist in type '...'"
4151
MISSING_PROPS = 2739, // "Type '...' is missing the following properties from type '..': ..."
4252
MISSING_PROP = 2741, // "Property '..' is missing in type '..' but required in type '..'."
4353
NO_OVERLOAD_MATCHES_CALL = 2769, // "No overload matches this call"
@@ -101,7 +111,7 @@ export class DiagnosticsProviderImpl implements DiagnosticsProvider {
101111
for (const diagnostic of diagnostics) {
102112
if (
103113
(diagnostic.code === DiagnosticCode.NO_OVERLOAD_MATCHES_CALL ||
104-
diagnostic.code === DiagnosticCode.TYPE_X_NOT_ASSIGNABLE_TO_TYPE_Y) &&
114+
diagnostic.code === DiagnosticCode.ARG_TYPE_X_NOT_ASSIGNABLE_TO_TYPE_Y) &&
105115
!notGenerated(diagnostic)
106116
) {
107117
if (isStoreVariableIn$storeDeclaration(tsDoc.getFullText(), diagnostic.start!)) {
@@ -147,7 +157,7 @@ export class DiagnosticsProviderImpl implements DiagnosticsProvider {
147157
.map(mapRange(tsDoc, document, lang))
148158
.filter(hasNoNegativeLines)
149159
.filter(isNoFalsePositive(document, tsDoc))
150-
.map(enhanceIfNecessary)
160+
.map(adjustIfNecessary)
151161
.map(swapDiagRangeStartEndIfNecessary);
152162
}
153163

@@ -180,9 +190,11 @@ function mapRange(
180190
}
181191

182192
if (
183-
[DiagnosticCode.MISSING_PROP, DiagnosticCode.MISSING_PROPS].includes(
193+
([DiagnosticCode.MISSING_PROP, DiagnosticCode.MISSING_PROPS].includes(
184194
diagnostic.code as number
185-
) &&
195+
) ||
196+
(DiagnosticCode.TYPE_X_NOT_ASSIGNABLE_TO_TYPE_Y &&
197+
diagnostic.message.includes("'PropsWithChildren<"))) &&
186198
!hasNonZeroRange({ range })
187199
) {
188200
const node = getNodeIfIsInStartTag(document.html, document.offsetAt(range.start));
@@ -286,11 +298,11 @@ function isNoUsedBeforeAssigned(
286298
}
287299

288300
/**
289-
* Some diagnostics have JSX-specific nomenclature. Enhance them for more clarity.
301+
* Some diagnostics have JSX-specific or confusing nomenclature. Enhance/adjust them for more clarity.
290302
*/
291-
function enhanceIfNecessary(diagnostic: Diagnostic): Diagnostic {
303+
function adjustIfNecessary(diagnostic: Diagnostic): Diagnostic {
292304
if (
293-
diagnostic.code === DiagnosticCode.TYPE_X_NOT_ASSIGNABLE_TO_TYPE_Y &&
305+
diagnostic.code === DiagnosticCode.ARG_TYPE_X_NOT_ASSIGNABLE_TO_TYPE_Y &&
294306
diagnostic.message.includes('ConstructorOfATypedSvelteComponent')
295307
) {
296308
return {
@@ -315,6 +327,37 @@ function enhanceIfNecessary(diagnostic: Diagnostic): Diagnostic {
315327
};
316328
}
317329

330+
if (
331+
(diagnostic.code === DiagnosticCode.TYPE_X_NOT_ASSIGNABLE_TO_TYPE_Y ||
332+
diagnostic.code === DiagnosticCode.TYPE_X_NOT_ASSIGNABLE_TO_TYPE_Y_DID_YOU_MEAN) &&
333+
diagnostic.message.includes("'Bindable<")
334+
) {
335+
const countBindable = (diagnostic.message.match(/'Bindable\</g) || []).length;
336+
const countBinding = (diagnostic.message.match(/'Binding\</g) || []).length;
337+
if (countBindable === 1 && countBinding === 0) {
338+
// Remove distracting Bindable<...> from diagnostic message
339+
const start = diagnostic.message.indexOf("'Bindable<");
340+
const startType = start + "'Bindable".length;
341+
const end = traverseTypeString(diagnostic.message, startType, '>');
342+
diagnostic.message =
343+
diagnostic.message.substring(0, start + 1) +
344+
diagnostic.message.substring(startType + 1, end) +
345+
diagnostic.message.substring(end + 1);
346+
} else if (countBinding === 3 && countBindable === 1) {
347+
// Only keep Type '...' is not assignable to type '...' in
348+
// Type Bindings<...> is not assignable to type Bindable<...>, Type Binding<...> is not assignable to type Bindable<...>, Type '...' is not assignable to type '...'
349+
const lines = diagnostic.message.split('\n');
350+
if (lines.length === 3) {
351+
diagnostic.message = lines[2].trimStart();
352+
}
353+
}
354+
355+
return {
356+
...diagnostic,
357+
message: diagnostic.message
358+
};
359+
}
360+
318361
return diagnostic;
319362
}
320363

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

Lines changed: 83 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ interface TsRenameLocation extends ts.RenameLocation {
3737
newName?: string;
3838
}
3939

40+
const bind = 'bind:';
41+
const bindShortHandGeneratedLength = ':__sveltets_2_binding('.length;
42+
4043
export class RenameProviderImpl implements RenameProvider {
4144
constructor(
4245
private readonly lsAndTsDocResolver: LSAndTSDocResolver,
@@ -73,7 +76,7 @@ export class RenameProviderImpl implements RenameProvider {
7376

7477
const renameLocations = lang.findRenameLocations(
7578
tsDoc.filePath,
76-
offset,
79+
offset + (renameInfo.bindShorthand || 0),
7780
false,
7881
false,
7982
true
@@ -157,6 +160,7 @@ export class RenameProviderImpl implements RenameProvider {
157160
):
158161
| (ts.RenameInfoSuccess & {
159162
isStore?: boolean;
163+
bindShorthand?: number;
160164
})
161165
| null {
162166
// Don't allow renames in error-state, because then there is no generated svelte2tsx-code
@@ -165,6 +169,15 @@ export class RenameProviderImpl implements RenameProvider {
165169
return null;
166170
}
167171

172+
const svelteNode = tsDoc.svelteNodeAt(originalPosition);
173+
174+
let bindOffset = 0;
175+
const bindingShorthand = this.getBindingShorthand(tsDoc, originalPosition, svelteNode);
176+
if (bindingShorthand) {
177+
bindOffset = bindingShorthand.end - bindingShorthand.start;
178+
generatedOffset += bindShortHandGeneratedLength + bindOffset;
179+
}
180+
168181
const renameInfo = lang.getRenameInfo(tsDoc.filePath, generatedOffset, {
169182
allowRenameOfImportPath: false
170183
});
@@ -178,7 +191,6 @@ export class RenameProviderImpl implements RenameProvider {
178191
return null;
179192
}
180193

181-
const svelteNode = tsDoc.svelteNodeAt(originalPosition);
182194
if (
183195
isInHTMLTagRange(doc.html, doc.offsetAt(originalPosition)) ||
184196
isAttributeName(svelteNode, 'Element') ||
@@ -188,16 +200,22 @@ export class RenameProviderImpl implements RenameProvider {
188200
}
189201

190202
// If $store is renamed, only allow rename for $|store|
191-
if (tsDoc.getFullText().charAt(renameInfo.triggerSpan.start) === '$') {
203+
const text = tsDoc.getFullText();
204+
if (text.charAt(renameInfo.triggerSpan.start) === '$') {
192205
const definition = lang.getDefinitionAndBoundSpan(tsDoc.filePath, generatedOffset)
193206
?.definitions?.[0];
194-
if (definition && isTextSpanInGeneratedCode(tsDoc.getFullText(), definition.textSpan)) {
207+
if (definition && isTextSpanInGeneratedCode(text, definition.textSpan)) {
195208
renameInfo.triggerSpan.start++;
196209
renameInfo.triggerSpan.length--;
197210
(renameInfo as any).isStore = true;
198211
}
199212
}
200213

214+
if (bindOffset) {
215+
renameInfo.triggerSpan.start -= bindShortHandGeneratedLength + bindOffset;
216+
(renameInfo as any).bindShorthand = bindShortHandGeneratedLength + bindOffset;
217+
}
218+
201219
return renameInfo;
202220
}
203221

@@ -325,7 +343,6 @@ export class RenameProviderImpl implements RenameProvider {
325343
replacementsForProp,
326344
snapshots
327345
);
328-
const bind = 'bind:';
329346

330347
// Adjust shorthands
331348
return renameLocations.map((location) => {
@@ -351,31 +368,29 @@ export class RenameProviderImpl implements RenameProvider {
351368

352369
const { parent } = snapshot;
353370

354-
let rangeStart = parent.offsetAt(location.range.start);
355-
let suffixText = location.suffixText?.trimStart();
356-
357-
// suffix is of the form `: oldVarName` -> hints at a shorthand
358-
if (!suffixText?.startsWith(':') || !getNodeIfIsInStartTag(parent.html, rangeStart)) {
359-
return location;
360-
}
361-
362-
const original = parent.getText({
363-
start: Position.create(
364-
location.range.start.line,
365-
location.range.start.character - bind.length
366-
),
367-
end: location.range.end
368-
});
369-
370-
if (original.startsWith(bind)) {
371+
const bindingShorthand = this.getBindingShorthand(snapshot, location.range.start);
372+
if (bindingShorthand) {
371373
// bind:|foo| -> bind:|newName|={foo}
374+
const name = parent
375+
.getText()
376+
.substring(bindingShorthand.start, bindingShorthand.end);
372377
return {
373378
...location,
374379
prefixText: '',
375-
suffixText: `={${original.slice(bind.length)}}`
380+
suffixText: `={${name}}`
376381
};
377382
}
378383

384+
let rangeStart = parent.offsetAt(location.range.start);
385+
386+
// suffix is of the form `: oldVarName` -> hints at a shorthand
387+
if (
388+
!location.suffixText?.trimStart()?.startsWith(':') ||
389+
!getNodeIfIsInStartTag(parent.html, rangeStart)
390+
) {
391+
return location;
392+
}
393+
379394
if (snapshot.getOriginalText().charAt(rangeStart - 1) === '{') {
380395
// {|foo|} -> |{foo|}
381396
rangeStart--;
@@ -582,8 +597,6 @@ export class RenameProviderImpl implements RenameProvider {
582597
renameLocations: TsRenameLocation[],
583598
snapshots: SnapshotMap
584599
): TsRenameLocation[] {
585-
const bind = 'bind:';
586-
587600
return renameLocations.map((location) => {
588601
const sourceFile = lang.getProgram()?.getSourceFile(location.fileName);
589602

@@ -625,6 +638,38 @@ export class RenameProviderImpl implements RenameProvider {
625638
suffixText: '}'
626639
};
627640
}
641+
642+
if (snapshot.isSvelte5Plus) {
643+
const bindingShorthand = this.getBindingShorthand(
644+
snapshot,
645+
location.range.start
646+
);
647+
if (bindingShorthand) {
648+
const name = parent
649+
.getText()
650+
.substring(bindingShorthand.start, bindingShorthand.end);
651+
const start = {
652+
line: location.range.start.line,
653+
character: location.range.start.character - name.length
654+
};
655+
// If binding is followed by the closing tag, start is one character too soon,
656+
// else binding is ending one character too far
657+
if (parent.getText().charAt(parent.offsetAt(start)) === ':') {
658+
start.character++;
659+
} else {
660+
location.range.end.character--;
661+
}
662+
return {
663+
...location,
664+
range: {
665+
start: start,
666+
end: location.range.end
667+
},
668+
prefixText: name + '={',
669+
suffixText: '}'
670+
};
671+
}
672+
}
628673
}
629674

630675
if (!prefixText || prefixText.slice(-1) !== ':') {
@@ -668,4 +713,17 @@ export class RenameProviderImpl implements RenameProvider {
668713
return location;
669714
});
670715
}
716+
717+
private getBindingShorthand(
718+
snapshot: SvelteDocumentSnapshot,
719+
position: Position,
720+
svelteNode = snapshot.svelteNodeAt(position)
721+
) {
722+
if (
723+
svelteNode?.parent?.type === 'Binding' &&
724+
svelteNode.parent.expression.end === svelteNode.parent.end
725+
) {
726+
return svelteNode.parent.expression;
727+
}
728+
}
671729
}

packages/language-server/src/plugins/typescript/svelte-ast-utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export interface SvelteNode {
88
end: number;
99
type: string;
1010
parent?: SvelteNode;
11+
[key: string]: any;
1112
}
1213

1314
type HTMLLike = 'Element' | 'InlineComponent' | 'Body' | 'Window';

0 commit comments

Comments
 (0)