Skip to content

Commit

Permalink
feat: apply correct type information to $derived argument expression (
Browse files Browse the repository at this point in the history
  • Loading branch information
ota-meshi authored Nov 20, 2023
1 parent 9324e89 commit af1bae5
Show file tree
Hide file tree
Showing 26 changed files with 522 additions and 45 deletions.
5 changes: 5 additions & 0 deletions .changeset/blue-ghosts-tell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"svelte-eslint-parser": minor
---

feat: apply correct type information to `$derived` argument expression
164 changes: 152 additions & 12 deletions src/parser/typescript/analyze/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,17 @@ import type ESTree from "estree";
import type { SvelteAttribute, SvelteHTMLElement } from "../../../ast";
import { globals, globalsForRunes } from "../../../parser/globals";
import type { NormalizedParserOptions } from "../../parser-options";
import { setParent } from "../set-parent";

export type AnalyzeTypeScriptContext = {
slots: Set<SvelteHTMLElement>;
};

type TransformInfo = {
node: TSESTree.Node;
transform: (ctx: VirtualTypeScriptContext) => void;
};

/**
* Analyze TypeScript source code in <script>.
* Generate virtual code to provide correct type information for Svelte store reference names, scopes, and runes.
Expand Down Expand Up @@ -55,7 +61,10 @@ export function analyzeTypeScriptInSvelte(

analyzeRuneVariables(result, ctx);

analyzeReactiveScopes(result, ctx);
applyTransforms(
[...analyzeReactiveScopes(result), ...analyzeDollarDerivedScopes(result)],
ctx,
);

analyzeRenderScopes(code, ctx);

Expand Down Expand Up @@ -84,6 +93,8 @@ export function analyzeTypeScript(

analyzeRuneVariables(result, ctx);

applyTransforms([...analyzeDollarDerivedScopes(result)], ctx);

ctx.appendOriginalToEnd();

return ctx;
Expand Down Expand Up @@ -390,10 +401,9 @@ function analyzeRuneVariables(
* Analyze the reactive scopes.
* Transform source code to provide the correct type information in the `$:` statements.
*/
function analyzeReactiveScopes(
function* analyzeReactiveScopes(
result: TSESParseForESLintResult,
ctx: VirtualTypeScriptContext,
) {
): Iterable<TransformInfo> {
const scopeManager = result.scopeManager;
const throughIds = scopeManager.globalScope!.through.map(
(reference) => reference.identifier,
Expand All @@ -417,17 +427,57 @@ function analyzeReactiveScopes(
left.range[0] <= id.range[0] && id.range[1] <= left.range[1],
)
) {
transformForDeclareReactiveVar(
statement,
statement.body.expression.left,
statement.body.expression,
result.ast.tokens!,
ctx,
);
const node = statement;
const expression = statement.body.expression;
yield {
node,
transform: (ctx) =>
transformForDeclareReactiveVar(
node,
left,
expression,
result.ast.tokens!,
ctx,
),
};
continue;
}
}
transformForReactiveStatement(statement, ctx);
yield {
node: statement,
transform: (ctx) => transformForReactiveStatement(statement, ctx),
};
}
}
}

/**
* Analyze the $derived scopes.
* Transform source code to provide the correct type information in the `$derived(...)` expression.
*/
function* analyzeDollarDerivedScopes(
result: TSESParseForESLintResult,
): Iterable<TransformInfo> {
const scopeManager = result.scopeManager;
const derivedReferences = scopeManager.globalScope!.through.filter(
(reference) => reference.identifier.name === "$derived",
);
if (!derivedReferences.length) {
return;
}
setParent(result);
for (const ref of derivedReferences) {
const derived = ref.identifier;
if (
derived.parent.type === "CallExpression" &&
derived.parent.callee === derived &&
derived.parent.arguments[0]?.type !== "SpreadElement"
) {
const node = derived.parent;
yield {
node,
transform: (ctx) => transformForDollarDerived(node, ctx),
};
}
}
}
Expand Down Expand Up @@ -464,6 +514,26 @@ function analyzeRenderScopes(
});
}

/**
* Applies the given transforms.
* Note that intersecting transformations are not applied.
*/
function applyTransforms(
transforms: TransformInfo[],
ctx: VirtualTypeScriptContext,
) {
transforms.sort((a, b) => a.node.range[0] - b.node.range[0]);

let offset = 0;
for (const transform of transforms) {
const range = transform.node.range;
if (offset <= range[0]) {
transform.transform(ctx);
}
offset = range[1];
}
}

/**
* Transform for `$: id = ...` to `$: let id = ...`
*/
Expand Down Expand Up @@ -720,6 +790,76 @@ function transformForReactiveStatement(
});
}

/**
* Transform for `$derived(expr)` to `$derived((()=>{ return fn(); function fn () { return expr } })())`
*/
function transformForDollarDerived(
derivedCall: TSESTree.CallExpression,
ctx: VirtualTypeScriptContext,
) {
const functionId = ctx.generateUniqueId("$derivedArgument");
const expression = derivedCall.arguments[0];
ctx.appendOriginal(expression.range[0]);
ctx.appendVirtualScript(
`(()=>{return ${functionId}();function ${functionId}(){return `,
);
ctx.appendOriginal(expression.range[1]);
ctx.appendVirtualScript(`}})()`);

ctx.restoreContext.addRestoreExpressionProcess<TSESTree.CallExpression>({
target: "CallExpression" as TSESTree.AST_NODE_TYPES.CallExpression,
restore:
// eslint-disable-next-line complexity -- ignore
(node, result) => {
if (
node.callee.type !== "Identifier" ||
node.callee.name !== "$derived"
) {
return false;
}
const arg = node.arguments[0];
if (
!arg ||
arg.type !== "CallExpression" ||
arg.arguments.length !== 0 ||
arg.callee.type !== "ArrowFunctionExpression" ||
arg.callee.body.type !== "BlockStatement" ||
arg.callee.body.body.length !== 2 ||
arg.callee.body.body[0].type !== "ReturnStatement" ||
arg.callee.body.body[0].argument?.type !== "CallExpression" ||
arg.callee.body.body[0].argument.callee.type !== "Identifier" ||
arg.callee.body.body[0].argument.callee.name !== functionId ||
arg.callee.body.body[1].type !== "FunctionDeclaration" ||
arg.callee.body.body[1].id.name !== functionId
) {
return false;
}
const fnNode = arg.callee.body.body[1];
if (
fnNode.body.body.length !== 1 ||
fnNode.body.body[0].type !== "ReturnStatement" ||
!fnNode.body.body[0].argument
) {
return false;
}

const expr = fnNode.body.body[0].argument;

node.arguments[0] = expr;
expr.parent = node;

const scopeManager = result.scopeManager as ScopeManager;
removeFunctionScope(arg.callee.body.body[1], scopeManager);
removeIdentifierReference(
arg.callee.body.body[0].argument.callee,
scopeManager.acquire(arg.callee)!,
);
removeFunctionScope(arg.callee, scopeManager);
return true;
},
});
}

/** Remove function scope and marge child scopes to upper scope */
function removeFunctionScope(
node:
Expand Down
12 changes: 2 additions & 10 deletions src/parser/typescript/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { ESLintExtendedProgram } from "..";
import { traverseNodes } from "../..";
import type { NormalizedParserOptions } from "../parser-options";
import { parseScript, parseScriptInSvelte } from "../script";
import type { AnalyzeTypeScriptContext } from "./analyze";
import { analyzeTypeScript, analyzeTypeScriptInSvelte } from "./analyze";
import { setParent } from "./set-parent";
import type { TSESParseForESLintResult } from "./types";

/**
Expand Down Expand Up @@ -34,15 +34,7 @@ export function parseTypeScript(
const tsCtx = analyzeTypeScript(code, attrs, parserOptions);

const result = parseScript(tsCtx.script, attrs, parserOptions);
traverseNodes(result.ast, {
visitorKeys: result.visitorKeys,
enterNode(node, parent) {
(node as any).parent = parent;
},
leaveNode() {
//
},
});
setParent(result);

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

Expand Down
54 changes: 52 additions & 2 deletions src/parser/typescript/restore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,20 @@ type RestoreStatementProcess = (
node: TSESTree.Statement,
result: TSESParseForESLintResult,
) => boolean;
/**
* A function that restores the expression.
* @param node The node to restore.
* @param result The result of parsing.
* @returns
* If `false`, it indicates that the specified node was not processed.
*
* If `true`, it indicates that the specified node was processed for processing.
* This process will no longer be called.
*/
type RestoreExpressionProcess<T extends TSESTree.Expression> = {
target: T["type"];
restore: (node: T, result: TSESParseForESLintResult) => boolean;
};

export class RestoreContext {
private readonly originalLocs: LinesAndColumns;
Expand All @@ -27,6 +41,9 @@ export class RestoreContext {

private readonly restoreStatementProcesses: RestoreStatementProcess[] = [];

private readonly restoreExpressionProcesses: RestoreExpressionProcess<TSESTree.Expression>[] =
[];

public constructor(code: string) {
this.originalLocs = new LinesAndColumns(code);
}
Expand All @@ -35,6 +52,12 @@ export class RestoreContext {
this.restoreStatementProcesses.push(process);
}

public addRestoreExpressionProcess<T extends TSESTree.Expression>(
process: RestoreExpressionProcess<T>,
): void {
this.restoreExpressionProcesses.push(process as never);
}

public addOffset(offset: { original: number; dist: number }): void {
this.offsets.push(offset);
}
Expand All @@ -61,6 +84,7 @@ export class RestoreContext {
});

restoreStatements(result, this.restoreStatementProcesses);
restoreExpressions(result, this.restoreExpressionProcesses);

// Adjust program node location
const firstOffset = Math.min(
Expand Down Expand Up @@ -151,9 +175,10 @@ function remapLocations(
// remap locations
traverseNodes(result.ast, {
visitorKeys: result.visitorKeys,
enterNode: (node, p) => {
enterNode: (node, parent) => {
(node as any).parent = parent;
if (!traversed.has(node)) {
traversed.set(node, p);
traversed.set(node, parent);

remapLocation(node);
}
Expand Down Expand Up @@ -194,3 +219,28 @@ function restoreStatements(
}
}
}

/** Restore expression nodes */
function restoreExpressions(
result: TSESParseForESLintResult,
restoreExpressionProcesses: RestoreExpressionProcess<TSESTree.Expression>[],
) {
if (restoreExpressionProcesses.length === 0) return;
const restoreExpressionProcessesSet = new Set(restoreExpressionProcesses);
traverseNodes(result.ast, {
visitorKeys: result.visitorKeys,
enterNode(node) {
for (const proc of restoreExpressionProcessesSet) {
if (proc.target === node.type) {
if (proc.restore(node as any, result)) {
restoreExpressionProcessesSet.delete(proc);
}
break;
}
}
},
leaveNode() {
/* noop */
},
});
}
20 changes: 20 additions & 0 deletions src/parser/typescript/set-parent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { ESLintExtendedProgram } from "..";
import { traverseNodes } from "../..";
import type { TSESParseForESLintResult } from "./types";

export function setParent(
result: ESLintExtendedProgram | TSESParseForESLintResult,
): void {
if (result.ast.body.some((node) => (node as any).parent)) {
return;
}
traverseNodes(result.ast, {
visitorKeys: result.visitorKeys,
enterNode(node, parent) {
(node as any).parent = parent;
},
leaveNode() {
// noop
},
});
}
17 changes: 17 additions & 0 deletions tests/fixtures/integrations/type-info-tests/$derived-input.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<script lang="ts">
type Info = { foo: number };
let x: Info | null = { foo: 42 };
const get = () => "hello";
x = null;
const y = $derived(x);
const z = $derived(fn(y.foo));
const foo = $derived(get);
function fn(a: number): number {
return a;
}
</script>

<input title={z} bind:value={x}>
{foo()}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[]
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"parse": {
"svelte": ">=5.0.0-0"
}
}
Loading

0 comments on commit af1bae5

Please sign in to comment.