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: apply correct type information to $derived argument expression #430

Merged
merged 4 commits into from
Nov 20, 2023
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/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
Loading