Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add AST node for function bindings #647

Merged
merged 3 commits into from
Jan 15, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/red-pots-shake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"svelte-eslint-parser": minor
---

feat: add AST node for function bindings
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 { 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 @@ -265,7 +266,7 @@
/**
* @deprecated Use `declarations` instead.
*/
declaration: ESTree.VariableDeclarator; // TODO Remove in v2 and later.

Check warning on line 269 in src/ast/html.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected 'todo' comment: 'TODO Remove in v2 and later.'
declarations: [ESTree.VariableDeclarator];
parent:
| SvelteProgram
Expand Down Expand Up @@ -595,7 +596,7 @@
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 @@
SvelteStyleElement,
SvelteElseBlock,
SvelteAwaitBlock,
SvelteFunctionBindingsExpression,
} from "../../ast/index.js";
import type ESTree from "estree";
import type { Context } from "../../context/index.js";
Expand Down Expand Up @@ -170,7 +171,7 @@
(key as any).parent = sAttr;
ctx.scriptLet.addObjectShorthandProperty(attribute.key, sAttr, (es) => {
if (
// FIXME: Older parsers may use the same node. In that case, do not replace.

Check warning on line 174 in src/parser/converts/attr.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected 'fixme' comment: 'FIXME: Older parsers may use the same...'
// We will drop support for ESLint v7 in the next major version and remove this branch.
es.key !== es.value
) {
Expand Down Expand Up @@ -367,6 +368,12 @@
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 @@
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 @@
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 @@
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 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 @@
}
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
1 change: 1 addition & 0 deletions src/visitor-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const svelteKeys: SvelteKeysType = {
SvelteText: [],
SvelteHTMLComment: [],
SvelteReactiveStatement: ["label", "body"],
SvelteFunctionBindingsExpression: ["expressions"],
};

export const KEYS: SourceCode.VisitorKeys = unionWith(
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
Loading