Skip to content

Commit 10ffeec

Browse files
authored
feat: add AST node for function bindings (#647)
1 parent 9ea27a5 commit 10ffeec

34 files changed

+5405
-31
lines changed

.changeset/red-pots-shake.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"svelte-eslint-parser": minor
3+
---
4+
5+
feat: add AST node for function bindings

.github/ISSUE_TEMPLATE/bug_report.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ body:
2929
- type: textarea
3030
id: eslint-plugin-svelte-version
3131
attributes:
32-
label: What version of `eslint-plugin-svelte` and ` svelte-eslint-parser` are you using?
32+
label: What version of `eslint-plugin-svelte` and `svelte-eslint-parser` are you using?
3333
value: |
3434
3535

docs/AST.md

+18-1
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ interface SvelteBindingDirective extends Node {
278278
kind: "Binding";
279279
key: SvelteDirectiveKey;
280280
shorthand: boolean;
281-
expression: null | Expression;
281+
expression: null | Expression | SvelteFunctionBindingsExpression;
282282
}
283283
interface SvelteClassDirective extends Node {
284284
type: "SvelteDirective";
@@ -601,3 +601,20 @@ interface SvelteReactiveStatement extends Node {
601601
body: Statement;
602602
}
603603
```
604+
605+
### SvelteFunctionBindingsExpression
606+
607+
This node is a function bindings expression in `bind:name={get, set}`.\
608+
`SvelteFunctionBindingsExpression` is a special node to avoid confusing ESLint check rules with `SequenceExpression`.
609+
610+
```ts
611+
interface SvelteFunctionBindingsExpression extends Node {
612+
type: "SvelteFunctionBindingsExpression";
613+
expressions: [
614+
/** Getter */
615+
Expression,
616+
/** Setter */
617+
Expression,
618+
];
619+
}
620+
```

src/ast/html.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type ESTree from "estree";
22
import type { TSESTree } from "@typescript-eslint/types";
33
import type { BaseNode } from "./base.js";
44
import type { Token, Comment } from "./common.js";
5+
import type { SvelteFunctionBindingsExpression } from "./script.js";
56

67
export type SvelteHTMLNode =
78
| SvelteProgram
@@ -595,7 +596,7 @@ export interface SvelteBindingDirective extends BaseSvelteDirective {
595596
kind: "Binding";
596597
key: SvelteDirectiveKeyTextName;
597598
shorthand: boolean;
598-
expression: null | ESTree.Expression;
599+
expression: null | ESTree.Expression | SvelteFunctionBindingsExpression;
599600
}
600601
export interface SvelteClassDirective extends BaseSvelteDirective {
601602
kind: "Class";

src/ast/script.ts

+14-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import type ESTree from "estree";
22
import type { BaseNode } from "./base.js";
33

4-
export type SvelteScriptNode = SvelteReactiveStatement;
4+
export type SvelteScriptNode =
5+
| SvelteReactiveStatement
6+
| SvelteFunctionBindingsExpression;
57

68
/** Node of `$` statement. */
79
export interface SvelteReactiveStatement extends BaseNode {
@@ -10,3 +12,14 @@ export interface SvelteReactiveStatement extends BaseNode {
1012
body: ESTree.Statement;
1113
parent: ESTree.Node;
1214
}
15+
16+
/** Node of `bind:name={get, set}` expression. */
17+
export interface SvelteFunctionBindingsExpression extends BaseNode {
18+
type: "SvelteFunctionBindingsExpression";
19+
expressions: [
20+
/** Getter */
21+
ESTree.Expression,
22+
/** Setter */
23+
ESTree.Expression,
24+
];
25+
}

src/context/script-let.ts

+69-21
Original file line numberDiff line numberDiff line change
@@ -79,27 +79,72 @@ function getNodeRange(
7979
leadingComments?: Comment[];
8080
trailingComments?: Comment[];
8181
},
82+
code: string,
8283
): [number, number] {
83-
let start = null;
84-
let end = null;
84+
const loc =
85+
"range" in node
86+
? { start: node.range![0], end: node.range![1] }
87+
: getWithLoc(node);
88+
89+
let start = loc.start;
90+
let end = loc.end;
91+
92+
let openingParenCount = 0;
93+
let closingParenCount = 0;
8594
if (node.leadingComments) {
86-
start = getWithLoc(node.leadingComments[0]).start;
95+
const commentStart = getWithLoc(node.leadingComments[0]).start;
96+
if (commentStart < start) {
97+
start = commentStart;
98+
99+
// Extract the number of parentheses before the node.
100+
let leadingEnd = loc.start;
101+
for (let index = node.leadingComments.length - 1; index >= 0; index--) {
102+
const comment = node.leadingComments[index];
103+
const loc = getWithLoc(comment);
104+
for (const c of code.slice(loc.end, leadingEnd).trim()) {
105+
if (c === "(") openingParenCount++;
106+
}
107+
leadingEnd = loc.start;
108+
}
109+
}
87110
}
88111
if (node.trailingComments) {
89-
end = getWithLoc(
112+
const commentEnd = getWithLoc(
90113
node.trailingComments[node.trailingComments.length - 1],
91114
).end;
115+
if (end < commentEnd) {
116+
end = commentEnd;
117+
118+
// Extract the number of parentheses after the node.
119+
let trailingStart = loc.end;
120+
for (const comment of node.trailingComments) {
121+
const loc = getWithLoc(comment);
122+
for (const c of code.slice(trailingStart, loc.start).trim()) {
123+
if (c === ")") closingParenCount++;
124+
}
125+
trailingStart = loc.end;
126+
}
127+
}
92128
}
93129

94-
const loc =
95-
"range" in node
96-
? { start: node.range![0], end: node.range![1] }
97-
: getWithLoc(node);
130+
// Adjust the range so that the parentheses match up.
131+
if (openingParenCount < closingParenCount) {
132+
for (; openingParenCount < closingParenCount && start >= 0; start--) {
133+
const c = code[start].trim();
134+
if (c) continue;
135+
if (c !== "(") break;
136+
openingParenCount++;
137+
}
138+
} else if (openingParenCount > closingParenCount) {
139+
for (; openingParenCount > closingParenCount && end < code.length; end++) {
140+
const c = code[end].trim();
141+
if (c) continue;
142+
if (c !== ")") break;
143+
closingParenCount++;
144+
}
145+
}
98146

99-
return [
100-
start ? Math.min(start, loc.start) : loc.start,
101-
end ? Math.max(end, loc.end) : loc.end,
102-
];
147+
return [start, end];
103148
}
104149

105150
type StatementNodeType = `${TSESTree.Statement["type"]}`;
@@ -154,7 +199,7 @@ export class ScriptLetContext {
154199
typing?: string | null,
155200
...callbacks: ScriptLetCallback<E>[]
156201
): ScriptLetCallback<E>[] {
157-
const range = getNodeRange(expression);
202+
const range = getNodeRange(expression, this.ctx.code);
158203
return this.addExpressionFromRange(range, parent, typing, ...callbacks);
159204
}
160205

@@ -221,7 +266,7 @@ export class ScriptLetContext {
221266
parent: SvelteNode,
222267
...callbacks: ScriptLetCallback<ObjectShorthandProperty>[]
223268
): void {
224-
const range = getNodeRange(identifier);
269+
const range = getNodeRange(identifier, this.ctx.code);
225270
const part = this.ctx.code.slice(...range);
226271
this.appendScript(
227272
`({${part}});`,
@@ -260,8 +305,11 @@ export class ScriptLetContext {
260305
const range =
261306
declarator.type === "VariableDeclarator"
262307
? // As of Svelte v5-next.65, VariableDeclarator nodes do not have location information.
263-
[getNodeRange(declarator.id)[0], getNodeRange(declarator.init!)[1]]
264-
: getNodeRange(declarator);
308+
[
309+
getNodeRange(declarator.id, this.ctx.code)[0],
310+
getNodeRange(declarator.init!, this.ctx.code)[1],
311+
]
312+
: getNodeRange(declarator, this.ctx.code);
265313
const part = this.ctx.code.slice(...range);
266314
this.appendScript(
267315
`const ${part};`,
@@ -398,7 +446,7 @@ export class ScriptLetContext {
398446
ifBlock: SvelteIfBlock,
399447
callback: ScriptLetCallback<ESTree.Expression>,
400448
): void {
401-
const range = getNodeRange(expression);
449+
const range = getNodeRange(expression, this.ctx.code);
402450
const part = this.ctx.code.slice(...range);
403451
const restore = this.appendScript(
404452
`if(${part}){`,
@@ -442,8 +490,8 @@ export class ScriptLetContext {
442490
index: ESTree.Identifier | null,
443491
) => void,
444492
): void {
445-
const exprRange = getNodeRange(expression);
446-
const ctxRange = context && getNodeRange(context);
493+
const exprRange = getNodeRange(expression, this.ctx.code);
494+
const ctxRange = context && getNodeRange(context, this.ctx.code);
447495
let source = "Array.from(";
448496
const exprOffset = source.length;
449497
source += `${this.ctx.code.slice(...exprRange)}).forEach((`;
@@ -563,7 +611,7 @@ export class ScriptLetContext {
563611
callback: (id: ESTree.Identifier, params: ESTree.Pattern[]) => void,
564612
): void {
565613
const scopeKind = kind || this.currentScriptScopeKind;
566-
const idRange = getNodeRange(id);
614+
const idRange = getNodeRange(id, this.ctx.code);
567615
const part = this.ctx.code.slice(idRange[0], closeParentIndex + 1);
568616
const restore = this.appendScript(
569617
`function ${part}{`,
@@ -660,7 +708,7 @@ export class ScriptLetContext {
660708
.map((d) => {
661709
return {
662710
...d,
663-
range: getNodeRange(d.node),
711+
range: getNodeRange(d.node, this.ctx.code),
664712
};
665713
})
666714
.sort((a, b) => {

src/parser/converts/attr.ts

+50-4
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type {
2020
SvelteStyleElement,
2121
SvelteElseBlock,
2222
SvelteAwaitBlock,
23+
SvelteFunctionBindingsExpression,
2324
} from "../../ast/index.js";
2425
import type ESTree from "estree";
2526
import type { Context } from "../../context/index.js";
@@ -367,6 +368,12 @@ function convertBindingDirective(
367368
null,
368369
(es, { getScope }) => {
369370
directive.expression = es;
371+
if (isFunctionBindings(ctx, es)) {
372+
(
373+
directive.expression as any as SvelteFunctionBindingsExpression
374+
).type = "SvelteFunctionBindingsExpression";
375+
return;
376+
}
370377
const scope = getScope(es);
371378
const reference = scope.references.find(
372379
(ref) => ref.identifier === es,
@@ -386,6 +393,34 @@ function convertBindingDirective(
386393
return directive;
387394
}
388395

396+
/**
397+
* Checks whether the given expression is Function bindings (added in Svelte 5.9.0) or not.
398+
* See https://svelte.dev/docs/svelte/bind#Function-bindings
399+
*/
400+
function isFunctionBindings(
401+
ctx: Context,
402+
expression: ESTree.Expression,
403+
): expression is ESTree.SequenceExpression {
404+
// Svelte 3/4 does not support Function bindings.
405+
if (!svelteVersion.gte(5)) {
406+
return false;
407+
}
408+
if (
409+
expression.type !== "SequenceExpression" ||
410+
expression.expressions.length !== 2
411+
) {
412+
return false;
413+
}
414+
const bindValueOpenIndex = ctx.code.lastIndexOf("{", expression.range![0]);
415+
if (bindValueOpenIndex < 0) return false;
416+
const betweenText = ctx.code
417+
.slice(bindValueOpenIndex + 1, expression.range![0])
418+
// Strip comments
419+
.replace(/\/\/[^\n]*\n|\/\*[\s\S]*?\*\//g, "")
420+
.trim();
421+
return !betweenText;
422+
}
423+
389424
/** Convert for EventHandler Directive */
390425
function convertEventHandlerDirective(
391426
node: SvAST.DirectiveForExpression | Compiler.OnDirective,
@@ -774,7 +809,10 @@ function buildLetDirectiveType(
774809
type DirectiveProcessors<
775810
D extends SvAST.Directive | StandardDirective,
776811
S extends SvelteDirective,
777-
E extends D["expression"] & S["expression"],
812+
E extends Exclude<
813+
D["expression"] & S["expression"],
814+
SvelteFunctionBindingsExpression
815+
>,
778816
> =
779817
| {
780818
processExpression: (
@@ -801,7 +839,10 @@ type DirectiveProcessors<
801839
function processDirective<
802840
D extends SvAST.Directive | StandardDirective,
803841
S extends SvelteDirective,
804-
E extends D["expression"] & S["expression"],
842+
E extends Exclude<
843+
D["expression"] & S["expression"],
844+
SvelteFunctionBindingsExpression
845+
>,
805846
>(
806847
node: D & { expression: null | E },
807848
directive: S,
@@ -878,7 +919,7 @@ function processDirectiveKey<
878919
function processDirectiveExpression<
879920
D extends SvAST.Directive | StandardDirective,
880921
S extends SvelteDirective,
881-
E extends D["expression"],
922+
E extends Exclude<D["expression"], SvelteFunctionBindingsExpression>,
882923
>(
883924
node: D & { expression: null | E },
884925
directive: S,
@@ -901,7 +942,12 @@ function processDirectiveExpression<
901942
}
902943
if (processors.processExpression) {
903944
processors.processExpression(node.expression, shorthand).push((es) => {
904-
if (node.expression && es.type !== node.expression.type) {
945+
if (
946+
node.expression &&
947+
((es.type as string) === "SvelteFunctionBindingsExpression"
948+
? "SequenceExpression"
949+
: es.type) !== node.expression.type
950+
) {
905951
throw new ParseError(
906952
`Expected ${node.expression.type}, but ${es.type} found.`,
907953
es.range![0],

src/visitor-keys.ts

+1
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ const svelteKeys: SvelteKeysType = {
5151
SvelteText: [],
5252
SvelteHTMLComment: [],
5353
SvelteReactiveStatement: ["label", "body"],
54+
SvelteFunctionBindingsExpression: ["expressions"],
5455
};
5556

5657
export const KEYS: SourceCode.VisitorKeys = unionWith(
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<input bind:value={
2+
() => value,
3+
(v) => value = v.toLowerCase()}
4+
/>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
[
2+
{
3+
"ruleId": "no-undef",
4+
"code": "value",
5+
"line": 2,
6+
"column": 8
7+
},
8+
{
9+
"ruleId": "no-undef",
10+
"code": "value",
11+
"line": 3,
12+
"column": 9
13+
}
14+
]

0 commit comments

Comments
 (0)