Skip to content

Commit af1bae5

Browse files
authored
feat: apply correct type information to $derived argument expression (#430)
1 parent 9324e89 commit af1bae5

26 files changed

+522
-45
lines changed

.changeset/blue-ghosts-tell.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"svelte-eslint-parser": minor
3+
---
4+
5+
feat: apply correct type information to `$derived` argument expression

src/parser/typescript/analyze/index.ts

+152-12
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,17 @@ import type ESTree from "estree";
1818
import type { SvelteAttribute, SvelteHTMLElement } from "../../../ast";
1919
import { globals, globalsForRunes } from "../../../parser/globals";
2020
import type { NormalizedParserOptions } from "../../parser-options";
21+
import { setParent } from "../set-parent";
2122

2223
export type AnalyzeTypeScriptContext = {
2324
slots: Set<SvelteHTMLElement>;
2425
};
2526

27+
type TransformInfo = {
28+
node: TSESTree.Node;
29+
transform: (ctx: VirtualTypeScriptContext) => void;
30+
};
31+
2632
/**
2733
* Analyze TypeScript source code in <script>.
2834
* Generate virtual code to provide correct type information for Svelte store reference names, scopes, and runes.
@@ -55,7 +61,10 @@ export function analyzeTypeScriptInSvelte(
5561

5662
analyzeRuneVariables(result, ctx);
5763

58-
analyzeReactiveScopes(result, ctx);
64+
applyTransforms(
65+
[...analyzeReactiveScopes(result), ...analyzeDollarDerivedScopes(result)],
66+
ctx,
67+
);
5968

6069
analyzeRenderScopes(code, ctx);
6170

@@ -84,6 +93,8 @@ export function analyzeTypeScript(
8493

8594
analyzeRuneVariables(result, ctx);
8695

96+
applyTransforms([...analyzeDollarDerivedScopes(result)], ctx);
97+
8798
ctx.appendOriginalToEnd();
8899

89100
return ctx;
@@ -390,10 +401,9 @@ function analyzeRuneVariables(
390401
* Analyze the reactive scopes.
391402
* Transform source code to provide the correct type information in the `$:` statements.
392403
*/
393-
function analyzeReactiveScopes(
404+
function* analyzeReactiveScopes(
394405
result: TSESParseForESLintResult,
395-
ctx: VirtualTypeScriptContext,
396-
) {
406+
): Iterable<TransformInfo> {
397407
const scopeManager = result.scopeManager;
398408
const throughIds = scopeManager.globalScope!.through.map(
399409
(reference) => reference.identifier,
@@ -417,17 +427,57 @@ function analyzeReactiveScopes(
417427
left.range[0] <= id.range[0] && id.range[1] <= left.range[1],
418428
)
419429
) {
420-
transformForDeclareReactiveVar(
421-
statement,
422-
statement.body.expression.left,
423-
statement.body.expression,
424-
result.ast.tokens!,
425-
ctx,
426-
);
430+
const node = statement;
431+
const expression = statement.body.expression;
432+
yield {
433+
node,
434+
transform: (ctx) =>
435+
transformForDeclareReactiveVar(
436+
node,
437+
left,
438+
expression,
439+
result.ast.tokens!,
440+
ctx,
441+
),
442+
};
427443
continue;
428444
}
429445
}
430-
transformForReactiveStatement(statement, ctx);
446+
yield {
447+
node: statement,
448+
transform: (ctx) => transformForReactiveStatement(statement, ctx),
449+
};
450+
}
451+
}
452+
}
453+
454+
/**
455+
* Analyze the $derived scopes.
456+
* Transform source code to provide the correct type information in the `$derived(...)` expression.
457+
*/
458+
function* analyzeDollarDerivedScopes(
459+
result: TSESParseForESLintResult,
460+
): Iterable<TransformInfo> {
461+
const scopeManager = result.scopeManager;
462+
const derivedReferences = scopeManager.globalScope!.through.filter(
463+
(reference) => reference.identifier.name === "$derived",
464+
);
465+
if (!derivedReferences.length) {
466+
return;
467+
}
468+
setParent(result);
469+
for (const ref of derivedReferences) {
470+
const derived = ref.identifier;
471+
if (
472+
derived.parent.type === "CallExpression" &&
473+
derived.parent.callee === derived &&
474+
derived.parent.arguments[0]?.type !== "SpreadElement"
475+
) {
476+
const node = derived.parent;
477+
yield {
478+
node,
479+
transform: (ctx) => transformForDollarDerived(node, ctx),
480+
};
431481
}
432482
}
433483
}
@@ -464,6 +514,26 @@ function analyzeRenderScopes(
464514
});
465515
}
466516

517+
/**
518+
* Applies the given transforms.
519+
* Note that intersecting transformations are not applied.
520+
*/
521+
function applyTransforms(
522+
transforms: TransformInfo[],
523+
ctx: VirtualTypeScriptContext,
524+
) {
525+
transforms.sort((a, b) => a.node.range[0] - b.node.range[0]);
526+
527+
let offset = 0;
528+
for (const transform of transforms) {
529+
const range = transform.node.range;
530+
if (offset <= range[0]) {
531+
transform.transform(ctx);
532+
}
533+
offset = range[1];
534+
}
535+
}
536+
467537
/**
468538
* Transform for `$: id = ...` to `$: let id = ...`
469539
*/
@@ -720,6 +790,76 @@ function transformForReactiveStatement(
720790
});
721791
}
722792

793+
/**
794+
* Transform for `$derived(expr)` to `$derived((()=>{ return fn(); function fn () { return expr } })())`
795+
*/
796+
function transformForDollarDerived(
797+
derivedCall: TSESTree.CallExpression,
798+
ctx: VirtualTypeScriptContext,
799+
) {
800+
const functionId = ctx.generateUniqueId("$derivedArgument");
801+
const expression = derivedCall.arguments[0];
802+
ctx.appendOriginal(expression.range[0]);
803+
ctx.appendVirtualScript(
804+
`(()=>{return ${functionId}();function ${functionId}(){return `,
805+
);
806+
ctx.appendOriginal(expression.range[1]);
807+
ctx.appendVirtualScript(`}})()`);
808+
809+
ctx.restoreContext.addRestoreExpressionProcess<TSESTree.CallExpression>({
810+
target: "CallExpression" as TSESTree.AST_NODE_TYPES.CallExpression,
811+
restore:
812+
// eslint-disable-next-line complexity -- ignore
813+
(node, result) => {
814+
if (
815+
node.callee.type !== "Identifier" ||
816+
node.callee.name !== "$derived"
817+
) {
818+
return false;
819+
}
820+
const arg = node.arguments[0];
821+
if (
822+
!arg ||
823+
arg.type !== "CallExpression" ||
824+
arg.arguments.length !== 0 ||
825+
arg.callee.type !== "ArrowFunctionExpression" ||
826+
arg.callee.body.type !== "BlockStatement" ||
827+
arg.callee.body.body.length !== 2 ||
828+
arg.callee.body.body[0].type !== "ReturnStatement" ||
829+
arg.callee.body.body[0].argument?.type !== "CallExpression" ||
830+
arg.callee.body.body[0].argument.callee.type !== "Identifier" ||
831+
arg.callee.body.body[0].argument.callee.name !== functionId ||
832+
arg.callee.body.body[1].type !== "FunctionDeclaration" ||
833+
arg.callee.body.body[1].id.name !== functionId
834+
) {
835+
return false;
836+
}
837+
const fnNode = arg.callee.body.body[1];
838+
if (
839+
fnNode.body.body.length !== 1 ||
840+
fnNode.body.body[0].type !== "ReturnStatement" ||
841+
!fnNode.body.body[0].argument
842+
) {
843+
return false;
844+
}
845+
846+
const expr = fnNode.body.body[0].argument;
847+
848+
node.arguments[0] = expr;
849+
expr.parent = node;
850+
851+
const scopeManager = result.scopeManager as ScopeManager;
852+
removeFunctionScope(arg.callee.body.body[1], scopeManager);
853+
removeIdentifierReference(
854+
arg.callee.body.body[0].argument.callee,
855+
scopeManager.acquire(arg.callee)!,
856+
);
857+
removeFunctionScope(arg.callee, scopeManager);
858+
return true;
859+
},
860+
});
861+
}
862+
723863
/** Remove function scope and marge child scopes to upper scope */
724864
function removeFunctionScope(
725865
node:

src/parser/typescript/index.ts

+2-10
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import type { ESLintExtendedProgram } from "..";
2-
import { traverseNodes } from "../..";
32
import type { NormalizedParserOptions } from "../parser-options";
43
import { parseScript, parseScriptInSvelte } from "../script";
54
import type { AnalyzeTypeScriptContext } from "./analyze";
65
import { analyzeTypeScript, analyzeTypeScriptInSvelte } from "./analyze";
6+
import { setParent } from "./set-parent";
77
import type { TSESParseForESLintResult } from "./types";
88

99
/**
@@ -34,15 +34,7 @@ export function parseTypeScript(
3434
const tsCtx = analyzeTypeScript(code, attrs, parserOptions);
3535

3636
const result = parseScript(tsCtx.script, attrs, parserOptions);
37-
traverseNodes(result.ast, {
38-
visitorKeys: result.visitorKeys,
39-
enterNode(node, parent) {
40-
(node as any).parent = parent;
41-
},
42-
leaveNode() {
43-
//
44-
},
45-
});
37+
setParent(result);
4638

4739
tsCtx.restoreContext.restore(result as unknown as TSESParseForESLintResult);
4840

src/parser/typescript/restore.ts

+52-2
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,20 @@ type RestoreStatementProcess = (
1717
node: TSESTree.Statement,
1818
result: TSESParseForESLintResult,
1919
) => boolean;
20+
/**
21+
* A function that restores the expression.
22+
* @param node The node to restore.
23+
* @param result The result of parsing.
24+
* @returns
25+
* If `false`, it indicates that the specified node was not processed.
26+
*
27+
* If `true`, it indicates that the specified node was processed for processing.
28+
* This process will no longer be called.
29+
*/
30+
type RestoreExpressionProcess<T extends TSESTree.Expression> = {
31+
target: T["type"];
32+
restore: (node: T, result: TSESParseForESLintResult) => boolean;
33+
};
2034

2135
export class RestoreContext {
2236
private readonly originalLocs: LinesAndColumns;
@@ -27,6 +41,9 @@ export class RestoreContext {
2741

2842
private readonly restoreStatementProcesses: RestoreStatementProcess[] = [];
2943

44+
private readonly restoreExpressionProcesses: RestoreExpressionProcess<TSESTree.Expression>[] =
45+
[];
46+
3047
public constructor(code: string) {
3148
this.originalLocs = new LinesAndColumns(code);
3249
}
@@ -35,6 +52,12 @@ export class RestoreContext {
3552
this.restoreStatementProcesses.push(process);
3653
}
3754

55+
public addRestoreExpressionProcess<T extends TSESTree.Expression>(
56+
process: RestoreExpressionProcess<T>,
57+
): void {
58+
this.restoreExpressionProcesses.push(process as never);
59+
}
60+
3861
public addOffset(offset: { original: number; dist: number }): void {
3962
this.offsets.push(offset);
4063
}
@@ -61,6 +84,7 @@ export class RestoreContext {
6184
});
6285

6386
restoreStatements(result, this.restoreStatementProcesses);
87+
restoreExpressions(result, this.restoreExpressionProcesses);
6488

6589
// Adjust program node location
6690
const firstOffset = Math.min(
@@ -151,9 +175,10 @@ function remapLocations(
151175
// remap locations
152176
traverseNodes(result.ast, {
153177
visitorKeys: result.visitorKeys,
154-
enterNode: (node, p) => {
178+
enterNode: (node, parent) => {
179+
(node as any).parent = parent;
155180
if (!traversed.has(node)) {
156-
traversed.set(node, p);
181+
traversed.set(node, parent);
157182

158183
remapLocation(node);
159184
}
@@ -194,3 +219,28 @@ function restoreStatements(
194219
}
195220
}
196221
}
222+
223+
/** Restore expression nodes */
224+
function restoreExpressions(
225+
result: TSESParseForESLintResult,
226+
restoreExpressionProcesses: RestoreExpressionProcess<TSESTree.Expression>[],
227+
) {
228+
if (restoreExpressionProcesses.length === 0) return;
229+
const restoreExpressionProcessesSet = new Set(restoreExpressionProcesses);
230+
traverseNodes(result.ast, {
231+
visitorKeys: result.visitorKeys,
232+
enterNode(node) {
233+
for (const proc of restoreExpressionProcessesSet) {
234+
if (proc.target === node.type) {
235+
if (proc.restore(node as any, result)) {
236+
restoreExpressionProcessesSet.delete(proc);
237+
}
238+
break;
239+
}
240+
}
241+
},
242+
leaveNode() {
243+
/* noop */
244+
},
245+
});
246+
}

src/parser/typescript/set-parent.ts

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { ESLintExtendedProgram } from "..";
2+
import { traverseNodes } from "../..";
3+
import type { TSESParseForESLintResult } from "./types";
4+
5+
export function setParent(
6+
result: ESLintExtendedProgram | TSESParseForESLintResult,
7+
): void {
8+
if (result.ast.body.some((node) => (node as any).parent)) {
9+
return;
10+
}
11+
traverseNodes(result.ast, {
12+
visitorKeys: result.visitorKeys,
13+
enterNode(node, parent) {
14+
(node as any).parent = parent;
15+
},
16+
leaveNode() {
17+
// noop
18+
},
19+
});
20+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<script lang="ts">
2+
type Info = { foo: number };
3+
let x: Info | null = { foo: 42 };
4+
const get = () => "hello";
5+
6+
x = null;
7+
const y = $derived(x);
8+
const z = $derived(fn(y.foo));
9+
const foo = $derived(get);
10+
11+
function fn(a: number): number {
12+
return a;
13+
}
14+
</script>
15+
16+
<input title={z} bind:value={x}>
17+
{foo()}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"parse": {
3+
"svelte": ">=5.0.0-0"
4+
}
5+
}

0 commit comments

Comments
 (0)