Skip to content

Commit

Permalink
feat: add AST node for function bindings
Browse files Browse the repository at this point in the history
  • Loading branch information
ota-meshi committed Jan 15, 2025
1 parent bf7a037 commit 4387930
Show file tree
Hide file tree
Showing 32 changed files with 5,399 additions and 31 deletions.
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ body:
- type: textarea
id: eslint-plugin-svelte-version
attributes:
label: What version of `eslint-plugin-svelte` and ` svelte-eslint-parser` are you using?
label: What version of `eslint-plugin-svelte` and `svelte-eslint-parser` are you using?
value: |
- [email protected]
- [email protected]
Expand Down
19 changes: 18 additions & 1 deletion docs/AST.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ interface SvelteBindingDirective extends Node {
kind: "Binding";
key: SvelteDirectiveKey;
shorthand: boolean;
expression: null | Expression;
expression: null | Expression | SvelteFunctionBindingsExpression;
}
interface SvelteClassDirective extends Node {
type: "SvelteDirective";
Expand Down Expand Up @@ -601,3 +601,20 @@ interface SvelteReactiveStatement extends Node {
body: Statement;
}
```

### SvelteFunctionBindingsExpression

This node is a function bindings expression in `bind:name={get, set}`.\
`SvelteFunctionBindingsExpression` is a special node to avoid confusing ESLint check rules with `SequenceExpression`.

```ts
interface SvelteFunctionBindingsExpression extends Node {
type: "SvelteFunctionBindingsExpression";
expressions: [
/** Getter */
Expression,
/** Setter */
Expression,
];
}
```
3 changes: 2 additions & 1 deletion src/ast/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type ESTree from "estree";
import type { TSESTree } from "@typescript-eslint/types";
import type { BaseNode } from "./base.js";
import type { Token, Comment } from "./common.js";
import type { SvelteFunctionBindingsExpression } from "./script.js";

export type SvelteHTMLNode =
| SvelteProgram
Expand Down Expand Up @@ -595,7 +596,7 @@ export interface SvelteBindingDirective extends BaseSvelteDirective {
kind: "Binding";
key: SvelteDirectiveKeyTextName;
shorthand: boolean;
expression: null | ESTree.Expression;
expression: null | ESTree.Expression | SvelteFunctionBindingsExpression;
}
export interface SvelteClassDirective extends BaseSvelteDirective {
kind: "Class";
Expand Down
15 changes: 14 additions & 1 deletion src/ast/script.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type ESTree from "estree";
import type { BaseNode } from "./base.js";

export type SvelteScriptNode = SvelteReactiveStatement;
export type SvelteScriptNode =
| SvelteReactiveStatement
| SvelteFunctionBindingsExpression;

/** Node of `$` statement. */
export interface SvelteReactiveStatement extends BaseNode {
Expand All @@ -10,3 +12,14 @@ export interface SvelteReactiveStatement extends BaseNode {
body: ESTree.Statement;
parent: ESTree.Node;
}

/** Node of `bind:name={get, set}` expression. */
export interface SvelteFunctionBindingsExpression extends BaseNode {
type: "SvelteFunctionBindingsExpression";
expressions: [
/** Getter */
ESTree.Expression,
/** Setter */
ESTree.Expression,
];
}
90 changes: 69 additions & 21 deletions src/context/script-let.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,27 +79,72 @@ function getNodeRange(
leadingComments?: Comment[];
trailingComments?: Comment[];
},
code: string,
): [number, number] {
let start = null;
let end = null;
const loc =
"range" in node
? { start: node.range![0], end: node.range![1] }
: getWithLoc(node);

let start = loc.start;
let end = loc.end;

let openingParenCount = 0;
let closingParenCount = 0;
if (node.leadingComments) {
start = getWithLoc(node.leadingComments[0]).start;
const commentStart = getWithLoc(node.leadingComments[0]).start;
if (commentStart < start) {
start = commentStart;

// Extract the number of parentheses before the node.
let leadingEnd = loc.start;
for (let index = node.leadingComments.length - 1; index >= 0; index--) {
const comment = node.leadingComments[index];
const loc = getWithLoc(comment);
for (const c of code.slice(loc.end, leadingEnd).trim()) {
if (c === "(") openingParenCount++;
}
leadingEnd = loc.start;
}
}
}
if (node.trailingComments) {
end = getWithLoc(
const commentEnd = getWithLoc(
node.trailingComments[node.trailingComments.length - 1],
).end;
if (end < commentEnd) {
end = commentEnd;

// Extract the number of parentheses after the node.
let trailingStart = loc.end;
for (const comment of node.trailingComments) {
const loc = getWithLoc(comment);
for (const c of code.slice(trailingStart, loc.start).trim()) {
if (c === ")") closingParenCount++;
}
trailingStart = loc.end;
}
}
}

const loc =
"range" in node
? { start: node.range![0], end: node.range![1] }
: getWithLoc(node);
// Adjust the range so that the parentheses match up.
if (openingParenCount < closingParenCount) {
for (; openingParenCount < closingParenCount && start >= 0; start--) {
const c = code[start].trim();
if (c) continue;
if (c !== "(") break;
openingParenCount++;
}
} else if (openingParenCount > closingParenCount) {
for (; openingParenCount > closingParenCount && end < code.length; end++) {
const c = code[end].trim();
if (c) continue;
if (c !== ")") break;
closingParenCount++;
}
}

return [
start ? Math.min(start, loc.start) : loc.start,
end ? Math.max(end, loc.end) : loc.end,
];
return [start, end];
}

type StatementNodeType = `${TSESTree.Statement["type"]}`;
Expand Down Expand Up @@ -154,7 +199,7 @@ export class ScriptLetContext {
typing?: string | null,
...callbacks: ScriptLetCallback<E>[]
): ScriptLetCallback<E>[] {
const range = getNodeRange(expression);
const range = getNodeRange(expression, this.ctx.code);
return this.addExpressionFromRange(range, parent, typing, ...callbacks);
}

Expand Down Expand Up @@ -221,7 +266,7 @@ export class ScriptLetContext {
parent: SvelteNode,
...callbacks: ScriptLetCallback<ObjectShorthandProperty>[]
): void {
const range = getNodeRange(identifier);
const range = getNodeRange(identifier, this.ctx.code);
const part = this.ctx.code.slice(...range);
this.appendScript(
`({${part}});`,
Expand Down Expand Up @@ -260,8 +305,11 @@ export class ScriptLetContext {
const range =
declarator.type === "VariableDeclarator"
? // As of Svelte v5-next.65, VariableDeclarator nodes do not have location information.
[getNodeRange(declarator.id)[0], getNodeRange(declarator.init!)[1]]
: getNodeRange(declarator);
[
getNodeRange(declarator.id, this.ctx.code)[0],
getNodeRange(declarator.init!, this.ctx.code)[1],
]
: getNodeRange(declarator, this.ctx.code);
const part = this.ctx.code.slice(...range);
this.appendScript(
`const ${part};`,
Expand Down Expand Up @@ -398,7 +446,7 @@ export class ScriptLetContext {
ifBlock: SvelteIfBlock,
callback: ScriptLetCallback<ESTree.Expression>,
): void {
const range = getNodeRange(expression);
const range = getNodeRange(expression, this.ctx.code);
const part = this.ctx.code.slice(...range);
const restore = this.appendScript(
`if(${part}){`,
Expand Down Expand Up @@ -442,8 +490,8 @@ export class ScriptLetContext {
index: ESTree.Identifier | null,
) => void,
): void {
const exprRange = getNodeRange(expression);
const ctxRange = context && getNodeRange(context);
const exprRange = getNodeRange(expression, this.ctx.code);
const ctxRange = context && getNodeRange(context, this.ctx.code);
let source = "Array.from(";
const exprOffset = source.length;
source += `${this.ctx.code.slice(...exprRange)}).forEach((`;
Expand Down Expand Up @@ -563,7 +611,7 @@ export class ScriptLetContext {
callback: (id: ESTree.Identifier, params: ESTree.Pattern[]) => void,
): void {
const scopeKind = kind || this.currentScriptScopeKind;
const idRange = getNodeRange(id);
const idRange = getNodeRange(id, this.ctx.code);
const part = this.ctx.code.slice(idRange[0], closeParentIndex + 1);
const restore = this.appendScript(
`function ${part}{`,
Expand Down Expand Up @@ -660,7 +708,7 @@ export class ScriptLetContext {
.map((d) => {
return {
...d,
range: getNodeRange(d.node),
range: getNodeRange(d.node, this.ctx.code),
};
})
.sort((a, b) => {
Expand Down
54 changes: 50 additions & 4 deletions src/parser/converts/attr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {
SvelteStyleElement,
SvelteElseBlock,
SvelteAwaitBlock,
SvelteFunctionBindingsExpression,
} from "../../ast/index.js";
import type ESTree from "estree";
import type { Context } from "../../context/index.js";
Expand Down Expand Up @@ -367,6 +368,12 @@ function convertBindingDirective(
null,
(es, { getScope }) => {
directive.expression = es;
if (isFunctionBindings(ctx, es)) {
(
directive.expression as any as SvelteFunctionBindingsExpression
).type = "SvelteFunctionBindingsExpression";
return;
}
const scope = getScope(es);
const reference = scope.references.find(
(ref) => ref.identifier === es,
Expand All @@ -386,6 +393,34 @@ function convertBindingDirective(
return directive;
}

/**
* Checks whether the given expression is Function bindings (added in Svelte 5.9.0) or not.
* See https://svelte.dev/docs/svelte/bind#Function-bindings
*/
function isFunctionBindings(
ctx: Context,
expression: ESTree.Expression,
): expression is ESTree.SequenceExpression {
// Svelte 3/4 does not support Function bindings.
if (!svelteVersion.gte(5)) {
return false;
}
if (
expression.type !== "SequenceExpression" ||
expression.expressions.length !== 2
) {
return false;
}
const bindValueOpenIndex = ctx.code.lastIndexOf("{", expression.range![0]);
if (bindValueOpenIndex < 0) return false;
const betweenText = ctx.code
.slice(bindValueOpenIndex + 1, expression.range![0])
// Strip comments
.replace(/\/\/[^\n]*\n|\/\*[\s\S]*?\*\//g, "")
.trim();
return !betweenText;
}

/** Convert for EventHandler Directive */
function convertEventHandlerDirective(
node: SvAST.DirectiveForExpression | Compiler.OnDirective,
Expand Down Expand Up @@ -774,7 +809,10 @@ function buildLetDirectiveType(
type DirectiveProcessors<
D extends SvAST.Directive | StandardDirective,
S extends SvelteDirective,
E extends D["expression"] & S["expression"],
E extends Exclude<
D["expression"] & S["expression"],
SvelteFunctionBindingsExpression
>,
> =
| {
processExpression: (
Expand All @@ -801,7 +839,10 @@ type DirectiveProcessors<
function processDirective<
D extends SvAST.Directive | StandardDirective,
S extends SvelteDirective,
E extends D["expression"] & S["expression"],
E extends Exclude<
D["expression"] & S["expression"],
SvelteFunctionBindingsExpression
>,
>(
node: D & { expression: null | E },
directive: S,
Expand Down Expand Up @@ -878,7 +919,7 @@ function processDirectiveKey<
function processDirectiveExpression<
D extends SvAST.Directive | StandardDirective,
S extends SvelteDirective,
E extends D["expression"],
E extends Exclude<D["expression"], SvelteFunctionBindingsExpression>,
>(
node: D & { expression: null | E },
directive: S,
Expand All @@ -901,7 +942,12 @@ function processDirectiveExpression<
}
if (processors.processExpression) {
processors.processExpression(node.expression, shorthand).push((es) => {
if (node.expression && es.type !== node.expression.type) {
if (
node.expression &&
((es.type as string) === "SvelteFunctionBindingsExpression"
? "SequenceExpression"
: es.type) !== node.expression.type
) {
throw new ParseError(
`Expected ${node.expression.type}, but ${es.type} found.`,
es.range![0],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<input bind:value={
() => value,
(v) => value = v.toLowerCase()}
/>
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[
{
"ruleId": "no-undef",
"code": "value",
"line": 2,
"column": 8
},
{
"ruleId": "no-undef",
"code": "value",
"line": 3,
"column": 9
}
]
Loading

0 comments on commit 4387930

Please sign in to comment.