Skip to content

Commit 9d7907e

Browse files
authored
fix: rework bindable types strategy (#2361)
Instead of using types that declare whether or not a type is bindable directly as part of the property, we're introducing a new for-types-only field to `SvelteComponent`: `$$bindings`, which is typed as the keys of the properties that are bindable (string by default, i.e. everything's bindable; for backwards compat). language-tools can then produce code that assigns to this property which results in an error we can display if the binding is invalid. This means we can revert a lot of the changes we made to make the previous version of bindable types work
1 parent 6cfb0d2 commit 9d7907e

File tree

40 files changed

+457
-751
lines changed

40 files changed

+457
-751
lines changed

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

Lines changed: 64 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -145,28 +145,77 @@ export class DiagnosticsProviderImpl implements DiagnosticsProvider {
145145

146146
diagnostics = resolveNoopsInReactiveStatements(lang, diagnostics);
147147

148-
return diagnostics
149-
.map<Diagnostic>((diagnostic) => ({
150-
range: convertRange(tsDoc, diagnostic),
151-
severity: mapSeverity(diagnostic.category),
148+
const mapRange = rangeMapper(tsDoc, document, lang);
149+
const noFalsePositive = isNoFalsePositive(document, tsDoc);
150+
const converted: Diagnostic[] = [];
151+
152+
for (const tsDiag of diagnostics) {
153+
let diagnostic: Diagnostic = {
154+
range: convertRange(tsDoc, tsDiag),
155+
severity: mapSeverity(tsDiag.category),
152156
source: isTypescript ? 'ts' : 'js',
153-
message: ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'),
154-
code: diagnostic.code,
155-
tags: getDiagnosticTag(diagnostic)
156-
}))
157-
.map(mapRange(tsDoc, document, lang))
158-
.filter(hasNoNegativeLines)
159-
.filter(isNoFalsePositive(document, tsDoc))
160-
.map(adjustIfNecessary)
161-
.map(swapDiagRangeStartEndIfNecessary);
157+
message: ts.flattenDiagnosticMessageText(tsDiag.messageText, '\n'),
158+
code: tsDiag.code,
159+
tags: getDiagnosticTag(tsDiag)
160+
};
161+
diagnostic = mapRange(diagnostic);
162+
163+
moveBindingErrorMessage(tsDiag, tsDoc, diagnostic, document);
164+
165+
if (!hasNoNegativeLines(diagnostic) || !noFalsePositive(diagnostic)) {
166+
continue;
167+
}
168+
169+
diagnostic = adjustIfNecessary(diagnostic);
170+
diagnostic = swapDiagRangeStartEndIfNecessary(diagnostic);
171+
converted.push(diagnostic);
172+
}
173+
174+
return converted;
162175
}
163176

164177
private async getLSAndTSDoc(document: Document) {
165178
return this.lsAndTsDocResolver.getLSAndTSDoc(document);
166179
}
167180
}
168181

169-
function mapRange(
182+
function moveBindingErrorMessage(
183+
tsDiag: ts.Diagnostic,
184+
tsDoc: SvelteDocumentSnapshot,
185+
diagnostic: Diagnostic,
186+
document: Document
187+
) {
188+
if (
189+
tsDiag.code === DiagnosticCode.TYPE_X_NOT_ASSIGNABLE_TO_TYPE_Y &&
190+
tsDiag.start &&
191+
tsDoc.getText(tsDiag.start, tsDiag.start + tsDiag.length!).endsWith('.$$bindings')
192+
) {
193+
let node = tsDoc.svelteNodeAt(diagnostic.range.start);
194+
while (node && node.type !== 'InlineComponent') {
195+
node = node.parent!;
196+
}
197+
if (node) {
198+
let name = tsDoc.getText(
199+
tsDiag.start + tsDiag.length!,
200+
tsDiag.start + tsDiag.length! + 100
201+
);
202+
const quoteIdx = name.indexOf("'");
203+
name = name.substring(quoteIdx + 1, name.indexOf("'", quoteIdx + 1));
204+
const binding: any = node.attributes.find(
205+
(attr: any) => attr.type === 'Binding' && attr.name === name
206+
);
207+
if (binding) {
208+
diagnostic.message = 'Cannot bind: to this property\n\n' + diagnostic.message;
209+
diagnostic.range = {
210+
start: document.positionAt(binding.start),
211+
end: document.positionAt(binding.end)
212+
};
213+
}
214+
}
215+
}
216+
}
217+
218+
function rangeMapper(
170219
snapshot: SvelteDocumentSnapshot,
171220
document: Document,
172221
lang: ts.LanguageService
@@ -194,7 +243,7 @@ function mapRange(
194243
diagnostic.code as number
195244
) ||
196245
(DiagnosticCode.TYPE_X_NOT_ASSIGNABLE_TO_TYPE_Y &&
197-
diagnostic.message.includes("'PropsWithChildren<"))) &&
246+
diagnostic.message.includes("'Properties<"))) &&
198247
!hasNonZeroRange({ range })
199248
) {
200249
const node = getNodeIfIsInStartTag(document.html, document.offsetAt(range.start));
@@ -327,37 +376,6 @@ function adjustIfNecessary(diagnostic: Diagnostic): Diagnostic {
327376
};
328377
}
329378

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-
361379
return diagnostic;
362380
}
363381

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

Lines changed: 60 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,14 @@ import {
3131
} from './utils';
3232
import { LSConfigManager } from '../../../ls-config';
3333
import { isAttributeName, isEventHandler } from '../svelte-ast-utils';
34+
import { Identifier } from 'estree';
3435

3536
interface TsRenameLocation extends ts.RenameLocation {
3637
range: Range;
3738
newName?: string;
3839
}
3940

4041
const bind = 'bind:';
41-
const bindShortHandGeneratedLength = ':__sveltets_2_binding('.length;
4242

4343
export class RenameProviderImpl implements RenameProvider {
4444
constructor(
@@ -76,7 +76,7 @@ export class RenameProviderImpl implements RenameProvider {
7676

7777
const renameLocations = lang.findRenameLocations(
7878
tsDoc.filePath,
79-
offset + (renameInfo.bindShorthand || 0),
79+
offset,
8080
false,
8181
false,
8282
true
@@ -101,7 +101,8 @@ export class RenameProviderImpl implements RenameProvider {
101101
convertedRenameLocations = this.checkShortHandBindingOrSlotLetLocation(
102102
lang,
103103
convertedRenameLocations,
104-
docs
104+
docs,
105+
newName
105106
);
106107

107108
const additionalRenameForPropRenameInsideComponentWithProp =
@@ -160,7 +161,6 @@ export class RenameProviderImpl implements RenameProvider {
160161
):
161162
| (ts.RenameInfoSuccess & {
162163
isStore?: boolean;
163-
bindShorthand?: number;
164164
})
165165
| null {
166166
// Don't allow renames in error-state, because then there is no generated svelte2tsx-code
@@ -170,14 +170,6 @@ export class RenameProviderImpl implements RenameProvider {
170170
}
171171

172172
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-
181173
const renameInfo = lang.getRenameInfo(tsDoc.filePath, generatedOffset, {
182174
allowRenameOfImportPath: false
183175
});
@@ -211,11 +203,6 @@ export class RenameProviderImpl implements RenameProvider {
211203
}
212204
}
213205

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

@@ -366,48 +353,8 @@ export class RenameProviderImpl implements RenameProvider {
366353
return location;
367354
}
368355

369-
const { parent } = snapshot;
370-
371-
const bindingShorthand = this.getBindingShorthand(snapshot, location.range.start);
372-
if (bindingShorthand) {
373-
// bind:|foo| -> bind:|newName|={foo}
374-
const name = parent
375-
.getText()
376-
.substring(bindingShorthand.start, bindingShorthand.end);
377-
return {
378-
...location,
379-
prefixText: '',
380-
suffixText: `={${name}}`
381-
};
382-
}
383-
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-
394-
if (snapshot.getOriginalText().charAt(rangeStart - 1) === '{') {
395-
// {|foo|} -> |{foo|}
396-
rangeStart--;
397-
return {
398-
...location,
399-
range: {
400-
start: parent.positionAt(rangeStart),
401-
end: location.range.end
402-
},
403-
// |{foo|} -> newName=|{foo|}
404-
newName: parent.getText(location.range),
405-
prefixText: `${newName}={`,
406-
suffixText: ''
407-
};
408-
}
409-
410-
return location;
356+
const shorthandLocation = this.transformShorthand(snapshot, location, newName);
357+
return shorthandLocation || location;
411358
});
412359
}
413360

@@ -595,7 +542,8 @@ export class RenameProviderImpl implements RenameProvider {
595542
private checkShortHandBindingOrSlotLetLocation(
596543
lang: ts.LanguageService,
597544
renameLocations: TsRenameLocation[],
598-
snapshots: SnapshotMap
545+
snapshots: SnapshotMap,
546+
newName?: string
599547
): TsRenameLocation[] {
600548
return renameLocations.map((location) => {
601549
const sourceFile = lang.getProgram()?.getSourceFile(location.fileName);
@@ -616,6 +564,16 @@ export class RenameProviderImpl implements RenameProvider {
616564

617565
const { parent } = snapshot;
618566

567+
if (snapshot.isSvelte5Plus && newName && location.suffixText) {
568+
// Svelte 5 runes mode, thanks to $props(), is much easier to handle rename-wise.
569+
// Notably, it doesn't need the "additional props rename locations" logic, because
570+
// these renames already appear here.
571+
const shorthandLocation = this.transformShorthand(snapshot, location, newName);
572+
if (shorthandLocation) {
573+
return shorthandLocation;
574+
}
575+
}
576+
619577
let rangeStart = parent.offsetAt(location.range.start);
620578
let prefixText = location.prefixText?.trimRight();
621579

@@ -638,38 +596,6 @@ export class RenameProviderImpl implements RenameProvider {
638596
suffixText: '}'
639597
};
640598
}
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-
}
673599
}
674600

675601
if (!prefixText || prefixText.slice(-1) !== ':') {
@@ -714,16 +640,54 @@ export class RenameProviderImpl implements RenameProvider {
714640
});
715641
}
716642

717-
private getBindingShorthand(
643+
private transformShorthand(
644+
snapshot: SvelteDocumentSnapshot,
645+
location: TsRenameLocation,
646+
newName: string
647+
): TsRenameLocation | undefined {
648+
const shorthand = this.getBindingOrAttrShorthand(snapshot, location.range.start);
649+
if (shorthand) {
650+
if (shorthand.isBinding) {
651+
// bind:|foo| -> bind:|newName|={foo}
652+
return {
653+
...location,
654+
prefixText: '',
655+
suffixText: `={${shorthand.id.name}}`
656+
};
657+
} else {
658+
return {
659+
...location,
660+
range: {
661+
// {|foo|} -> |{foo|}
662+
start: {
663+
line: location.range.start.line,
664+
character: location.range.start.character - 1
665+
},
666+
end: location.range.end
667+
},
668+
// |{foo|} -> newName=|{foo|}
669+
newName: shorthand.id.name,
670+
prefixText: `${newName}={`,
671+
suffixText: ''
672+
};
673+
}
674+
}
675+
}
676+
677+
private getBindingOrAttrShorthand(
718678
snapshot: SvelteDocumentSnapshot,
719679
position: Position,
720680
svelteNode = snapshot.svelteNodeAt(position)
721-
) {
681+
): { id: Identifier; isBinding: boolean } | undefined {
722682
if (
723-
svelteNode?.parent?.type === 'Binding' &&
683+
(svelteNode?.parent?.type === 'Binding' ||
684+
svelteNode?.parent?.type === 'AttributeShorthand') &&
724685
svelteNode.parent.expression.end === svelteNode.parent.end
725686
) {
726-
return svelteNode.parent.expression;
687+
return {
688+
id: svelteNode as any as Identifier,
689+
isBinding: svelteNode.parent.type === 'Binding'
690+
};
727691
}
728692
}
729693
}

0 commit comments

Comments
 (0)