diff --git a/package.json b/package.json index 360b4b6..92dd2c8 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "jsx-ast-utils": "^3.3.2", "kebab-case": "^1.0.1", "known-css-properties": "^0.24.0", - "style-to-object": "^0.3.0" + "style-to-object": "^0.3.0", + "tiny-invariant": "^1.3.1" }, "devDependencies": { "@babel/core": "^7.18.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 625161c..fcbb10d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,6 +37,7 @@ specifiers: prettier: ^2.7.1 rollup: ^2.79.0 style-to-object: ^0.3.0 + tiny-invariant: ^1.3.1 ts-jest: ^28.0.7 ts-node: ^10.9.1 typescript: ^4.7.4 @@ -48,6 +49,7 @@ dependencies: kebab-case: 1.0.1 known-css-properties: 0.24.0 style-to-object: 0.3.0 + tiny-invariant: 1.3.1 devDependencies: '@babel/core': 7.18.9 @@ -1714,7 +1716,7 @@ packages: dev: true /concat-map/0.0.1: - resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=} + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} /concat-stream/1.6.2: resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} @@ -4775,6 +4777,10 @@ packages: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} dev: true + /tiny-invariant/1.3.1: + resolution: {integrity: sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==} + dev: false + /tmp/0.0.33: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} engines: {node: '>=0.6.0'} diff --git a/src/rules/reactivity.ts b/src/rules/reactivity.ts index 55d296d..7ab3bee 100644 --- a/src/rules/reactivity.ts +++ b/src/rules/reactivity.ts @@ -601,7 +601,7 @@ const rule: TSESLint.RuleModule = { }; /* - * Sync array functions (forEach, map, reduce, reduceRight, flatMap), + * Sync array functions (forEach, map, reduce, reduceRight, flatMap), IIFEs, * store update fn params (ex. setState("todos", (t) => [...t.slice(0, i()), * ...t.slice(i() + 1)])), batch, onCleanup, and onError fn params, and * maybe a few others don't actually create a new scope. That is, any diff --git a/src/rules/reactivity/analyze.ts b/src/rules/reactivity/analyze.ts index 4346cb3..bcf1e31 100644 --- a/src/rules/reactivity/analyze.ts +++ b/src/rules/reactivity/analyze.ts @@ -18,8 +18,28 @@ interface TrackedScope { } export interface VirtualReference { - reference: TSESLint.Scope.Reference | T.Node; // store references directly instead of pushing variables? - declarationScope: ReactivityScope; + /** + * The node, commonly an identifier, where reactivity is referenced. + */ + node: T.Node; + /** + * The scope where the variable `node` is referencing is declared, or `null` if no variable. + * Since reactivity is, by definition, set up once and accessed multiple times, the final + * call or property access in a reactive expression's `path` is the access that's reactive. + * Previous `path` segments just describe the API. It's safe to assume that the second-to-last + * `path` segment defines the declaration scope unless the reactive expression is a derived signal. + * + * @example + * function Component() { + * const [signal1] = createSignal(42); + * const signal2 = createSignal(42)[0]; + * const signal3 = createSignal(42)[0]; + * const d = () => { + * console.log(signal()); // declarationScope === Component + * } + * console.log(createSignal(42)[0]()); // declarationScope === + */ + declarationScope: ReactivityScope | null; } function isRangeWithin(inner: T.Range, outer: T.Range): boolean { diff --git a/src/rules/reactivity/pluginApi.ts b/src/rules/reactivity/pluginApi.ts index fc8fbfb..14a645e 100644 --- a/src/rules/reactivity/pluginApi.ts +++ b/src/rules/reactivity/pluginApi.ts @@ -1,8 +1,7 @@ import type { TSESTree as T, TSESLint, TSESTree } from "@typescript-eslint/utils"; import { FunctionNode, ProgramOrFunctionNode } from "../../utils"; -type PathSegment = `[${number}]`; -export type ExprPath = PathSegment; // PathSegment | `${PathSegment}${Path}`; + export interface ReactivityPluginApi { /** @@ -26,25 +25,50 @@ export interface ReactivityPluginApi { provideErrorContext(node: T.Node, errorMessage: string): void; /** - * Mark that a node evaluates to a signal (or memo, etc.). + * Mark that a node evaluates to a signal (or memo, etc.). Short for `.reactive(node, (path ?? '') + '()')` */ - signal(node: T.Node, path?: ExprPath): void; + signal(node: T.Node, path?: string): void; /** - * Mark that a node evaluates to a store or props. + * Mark that a node evaluates to a store or props. Short for `.reactive(node, (path ?? '') + '.**')` */ - store(node: T.Node, path?: ExprPath, options?: { mutable?: boolean }): void; + store(node: T.Node, path?: string, options?: { mutable?: boolean }): void; /** - * Mark that a node is reactive in some way--either a signal-like or a store-like. + * Mark that a node is reactive in some way--either a signal-like or a store-like, or something + * more specialized. + * @param path a string with a special pattern, akin to a "type" for reactivity. Composed of the + * following segments: + * - `()`: when called + * - `[0]`: when the first element is accessed + * - `[]`: when any element is accessed + * - `.foo`: when the 'foo' property is accessed + * - `.*`: when any direct property is accessed + * - `.**`: when any direct or nested property is accessed + * @example + * if (isCall(node, "createSignal")) { + * reactive(node, '[0]()'); + * } else if (isCall(node, "createMemo")) { + * reactive(node, '()'); + * } else if (isCall(node, "splitProps")) { + * reactive(node, '[].**'); + * } else if (isCall(node, "createResource")) { + * reactive(node, '()'); + * reactive(node, '.loading'); + * reactive(node, '.error'); + * } */ - reactive(node: T.Node, path?: ExprPath): void; + reactive(node: T.Node, path: string): void; /** * Convenience method for checking if a node is a call expression. If `primitive` is provided, * checks that the callee is a Solid import with that name (or one of that name), handling aliases. */ - isCall(node: T.Node, primitive?: string | Array): node is T.CallExpression; + isCall( + node: T.Node, + primitive?: string | Array | RegExp, + module?: string | RegExp + ): node is T.CallExpression; } export interface ReactivityPlugin { @@ -58,36 +82,3 @@ export function plugin(p: ReactivityPlugin): ReactivityPlugin { } // =========================== - -class Reactivity implements ReactivityPluginApi { - trackedScope(node: T.Node): void { - throw new Error("Method not implemented."); - } - calledFunction(node: T.Node): void { - throw new Error("Method not implemented."); - } - trackedFunction(node: T.Node): void { - throw new Error("Method not implemented."); - } - trackedExpression(node: T.Node): void { - throw new Error("Method not implemented."); - } - syncCallback(node: T.Node): void { - throw new Error("Method not implemented."); - } - provideErrorContext(node: T.Node, errorMessage: string): void { - throw new Error("Method not implemented."); - } - signal(node: T.Node, path?: `[${number}]` | undefined): void { - throw new Error("Method not implemented."); - } - store(node: T.Node, path?: `[${number}]` | undefined): void { - throw new Error("Method not implemented."); - } - reactive(node: T.Node, path?: `[${number}]` | undefined): void { - throw new Error("Method not implemented."); - } - isCall(node: T.Node, primitive?: string | string[] | undefined): node is T.CallExpression { - throw new Error("Method not implemented."); - } -} diff --git a/src/rules/reactivity/rule.ts b/src/rules/reactivity/rule.ts index 0421481..1fda916 100644 --- a/src/rules/reactivity/rule.ts +++ b/src/rules/reactivity/rule.ts @@ -1,7 +1,28 @@ -import { TSESLint, TSESTree as T } from "@typescript-eslint/utils"; -import { ProgramOrFunctionNode, FunctionNode, trackImports, isFunctionNode } from "../../utils"; +import { TSESLint, TSESTree as T, ASTUtils } from "@typescript-eslint/utils"; +import invariant from 'tiny-invariant' +import { + ProgramOrFunctionNode, + FunctionNode, + trackImports, + isFunctionNode, + ignoreTransparentWrappers, +} from "../../utils"; import { ReactivityScope, VirtualReference } from "./analyze"; -import type { ExprPath, ReactivityPlugin, ReactivityPluginApi } from "./pluginApi"; +import type { ReactivityPlugin, ReactivityPluginApi } from "./pluginApi"; + +const { findVariable } = ASTUtils; + +function parsePath(path: string): Array | null { + if (path) { + const regex = /\(\)|\[\d*\]|\.(?:\w+|**?)/g; + const matches = path.match(regex); + // ensure the whole string is matched + if (matches && matches.reduce((acc, match) => acc + match.length, 0) === path.length) { + return matches; + } + } + return null; +} type MessageIds = | "noWrite" @@ -11,7 +32,8 @@ type MessageIds = | "badUnnamedDerivedSignal" | "shouldDestructure" | "shouldAssign" - | "noAsyncTrackedScope"; + | "noAsyncTrackedScope" + | "jsxReactiveVariable"; const rule: TSESLint.RuleModule = { meta: { @@ -39,12 +61,13 @@ const rule: TSESLint.RuleModule = { "For proper analysis, a variable should be used to capture the result of this function call.", noAsyncTrackedScope: "This tracked scope should not be async. Solid's reactivity only tracks synchronously.", + jsxReactiveVariable: "This variable should not be used as a JSX element.", }, }, create(context) { const sourceCode = context.getSourceCode(); - const { handleImportDeclaration, matchImport, matchLocalToModule } = trackImports(/^/); + const { handleImportDeclaration, matchImport, matchLocalToModule } = trackImports(); const root = new ReactivityScope(sourceCode.ast, null); const syncCallbacks = new Set(); @@ -92,22 +115,64 @@ const rule: TSESLint.RuleModule = { } } - function getReferences(node: T.Expression, path: ExprPath) { - if (node.parent?.type === "VariableDeclarator" && node.parent.init === node) { - + function VirtualReference(node: T.Node): VirtualReference { + return { node, declarationScope: } + } + + /** + * Given what's usually a CallExpression and a description of how the expression must be used + * in order to be accessed reactively, return a list of virtual references for each place where + * a reactive expression is accessed. + * `path` is a string formatted according to `pluginApi`. + */ + function* getReferences(node: T.Expression, path: string, allowMutable = false): Generator { + node = ignoreTransparentWrappers(node, "up"); + if (!path) { + yield VirtualReference(node); + } else if (node.parent?.type === "VariableDeclarator" && node.parent.init === node) { + const { id } = node.parent; + if (id.type === "Identifier") { + const variable = findVariable(context.getScope(), id); + if (variable) { + for (const reference of variable.references) { + if (reference.init) { + // ignore + } else if (reference.identifier.type === "JSXIdentifier") { + context.report({ node: reference.identifier, messageId: "jsxReactiveVariable" }); + } else if (reference.isWrite()) { + if (!allowMutable) { + context.report({ node: reference.identifier, messageId: "noWrite" }); + } + } else { + yield* getReferences(reference.identifier, path); + } + } + } + } else if (id.type === "ArrayPattern") { + const parsedPath = parsePath(path) + if (parsedPath) { + const newPath = path.substring(match[0].length); + const index = match[1] + if (index === '*') { + + } else { + + } + } + + } } } function distributeReferences(root: ReactivityScope, references: Array) { references.forEach((ref) => { - const range = - "range" in ref.reference ? ref.reference.range : ref.reference.identifier.range; - const scope = root.deepestScopeContaining(range)!; + const range = ref.node.range; + const scope = root.deepestScopeContaining(range); + invariant(scope != null) scope.references.push(ref); }); } - const pluginApi: ReactivityPluginApi = { calledFunction(node) { currentScope.trackedScopes.push({ node, expect: "called-function" }); @@ -121,19 +186,21 @@ const rule: TSESLint.RuleModule = { provideErrorContext(node) { currentScope.errorContexts.push(node); }, - signal(node, path) { - - const references = []; // TODO generate virtual signal references - undistributedReferences.push(...references); - }, - store(node, path, options) { - const references = []; // TODO generate virtual store references - undistributedReferences.push(...references); - }, + // signal(node, path) { + // const references = []; // TODO generate virtual signal references + // undistributedReferences.push(...references); + // }, + // store(node, path, options) { + // const references = []; // TODO generate virtual store references + // undistributedReferences.push(...references); + // }, reactive(node, path) { const references = []; // TODO generate virtual reactive references undistributedReferences.push(...references); }, + getReferences(node, path) { + return Array.from(getReferences(node, path)); + }, isCall(node, primitive): node is T.CallExpression { return ( node.type === "CallExpression" && diff --git a/src/utils.ts b/src/utils.ts index c6c8f96..86664e9 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -70,11 +70,11 @@ export function trace(node: T.Node, initialScope: TSESLint.Scope.Scope): T.Node } /** Get the relevant node when wrapped by a node that doesn't change the behavior */ -export function ignoreTransparentWrappers(node: T.Node, up = false): T.Node { +export function ignoreTransparentWrappers(node: T.Expression, dir = 'down'): T.Expression { if (node.type === "TSAsExpression" || node.type === "TSNonNullExpression") { - const next = up ? node.parent : node.expression; + const next = dir === 'up' ? node.parent as T.Expression : node.expression; if (next) { - return ignoreTransparentWrappers(next, up); + return ignoreTransparentWrappers(next, dir); } } return node; diff --git a/test/fixture/valid/async/suspense/greeting.tsx b/test/fixture/valid/async/suspense/greeting.tsx index e69de29..8cec2e9 100644 --- a/test/fixture/valid/async/suspense/greeting.tsx +++ b/test/fixture/valid/async/suspense/greeting.tsx @@ -0,0 +1 @@ +export {}; \ No newline at end of file