diff --git a/.eslintignore b/.eslintignore index 78329ed..d33ac58 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,3 +1,4 @@ dist node_modules jest.config.js +bin \ No newline at end of file diff --git a/bin/reactivity.cjs b/bin/reactivity.cjs new file mode 100755 index 0000000..93e211c --- /dev/null +++ b/bin/reactivity.cjs @@ -0,0 +1,79 @@ +#!/usr/bin/env node + +const fs = require("fs/promises"); +const path = require("path"); +const glob = require("fast-glob"); + +const KEY = "solid/reactivity"; +const WRAPPED_KEY = `"${KEY}"`; + +async function findPlugins() { + const pluginsMap = new Map(); + + const files = await glob("**/package.json", { onlyFiles: true, unique: true, deep: 10 }); + await Promise.all( + files.map(async (file) => { + const contents = await fs.readFile(file, "utf-8"); + + if (contents.includes(WRAPPED_KEY)) { + const parsed = JSON.parse(contents); + if (parsed && Object.prototype.hasOwnProperty.call(parsed, KEY)) { + const pluginPath = parsed[KEY]; + if (file === "package.json") { + pluginsMap.set(".", pluginPath); // associate current CWD with the plugin path + } else { + pluginsMap.set(parsed.name, pluginPath); // associate the package name with the plugin path + } + } + } + }) + ); + + return pluginsMap; +} + +async function reactivityCLI() { + if (process.argv.some((token) => token.match(/\-h|\-v/i))) { + console.log( + `eslint-plugin-solid v${require("../package.json").version} + +This CLI command searches for any plugins for the "solid/reactivity" ESLint rule that your dependencies make available. + +If any are found, an ESLint rule config will be printed out with the necessary options to load and use those "solid/reactivity" plugins. +If you are authoring a framework or template repository for SolidJS, this can help your users get the best possible linter feedback. + +For instructions on authoring a "solid/reactivity" plugin, see TODO PROVIDE URL.` + ); + } else { + console.log( + `Searching for ${WRAPPED_KEY} keys in all package.json files... (this could take a minute)` + ); + const pluginsMap = await findPlugins(); + if (pluginsMap.size > 0) { + // create a resolve function relative from the root of the current working directory + const { resolve } = require("module").createRequire(path.join(process.cwd(), "index.js")); + + const plugins = Array.from(pluginsMap) + .map(([packageName, pluginPath]) => { + const absPluginPath = resolve( + (packageName + "/" + pluginPath).replace(/(\.\/){2,}/g, "./") + ); + return path.relative(process.cwd(), absPluginPath); + }) + .filter((v, i, a) => a.indexOf(v) === i); // remove duplicates + + const config = [1, { plugins }]; + + console.log( + `Found ${plugins.length} "solid/reactivity" plugin${plugins.length !== 1 ? "s" : ""}. Add ${ + plugins.length !== 1 ? "them" : "it" + } to your ESLint config by adding the following to the "rules" section:\n` + ); + console.log(`"solid/reactivity": ${JSON.stringify(config, null, 2)}`); + } else { + console.log(`No "solid/reactivity" plugins found.`); + } + } +} + +reactivityCLI(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab27933..625161c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1186,7 +1186,7 @@ packages: debug: 4.3.4 globby: 11.1.0 is-glob: 4.0.3 - semver: 7.3.7 + semver: 7.3.8 tsutils: 3.21.0_typescript@4.7.4 typescript: 4.7.4 transitivePeerDependencies: @@ -1206,7 +1206,7 @@ packages: debug: 4.3.4 globby: 11.1.0 is-glob: 4.0.3 - semver: 7.3.7 + semver: 7.3.8 tsutils: 3.21.0_typescript@4.7.4 typescript: 4.7.4 transitivePeerDependencies: @@ -4463,6 +4463,7 @@ packages: hasBin: true dependencies: lru-cache: 6.0.0 + dev: true /semver/7.3.8: resolution: {integrity: sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==} @@ -4470,7 +4471,6 @@ packages: hasBin: true dependencies: lru-cache: 6.0.0 - dev: true /shebang-command/1.2.0: resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==} diff --git a/scripts/docs.ts b/scripts/docs.ts index 487a5bf..defa646 100644 --- a/scripts/docs.ts +++ b/scripts/docs.ts @@ -181,13 +181,15 @@ async function run() { const docRoot = path.resolve(__dirname, "..", "docs"); const docFiles = (await fs.readdir(docRoot)).filter((p) => p.endsWith(".md")); for (const docFile of docFiles) { - markdownMagic(path.join(docRoot, docFile), { - transforms: { - HEADER: () => buildHeader(docFile), - OPTIONS: () => buildOptions(docFile), - CASES: (content: string) => buildCases(content, docFile), - }, - }); + if (docFile !== "reactivity-customization.md") { + markdownMagic(path.join(docRoot, docFile), { + transforms: { + HEADER: () => buildHeader(docFile), + OPTIONS: () => buildOptions(docFile), + CASES: (content: string) => buildCases(content, docFile), + }, + }); + } } } diff --git a/src/rules/reactivity/analyze.ts b/src/rules/reactivity/analyze.ts new file mode 100644 index 0000000..4346cb3 --- /dev/null +++ b/src/rules/reactivity/analyze.ts @@ -0,0 +1,81 @@ +import { TSESLint, TSESTree as T } from "@typescript-eslint/utils"; +import { ProgramOrFunctionNode, FunctionNode } from "../../utils"; +import type { ReactivityPluginApi } from "./pluginApi"; + +interface TrackedScope { + /** + * The root node, usually a function or JSX expression container, to allow + * reactive variables under. + */ + node: T.Node; + /** + * The reactive variable should be one of these types: + * - "tracked-scope": synchronous function or signal variable, or JSX container/spread + * - "called-function": synchronous or asynchronous function like a timer or + * event handler that isn't really a tracked scope but allows reactivity + */ + expect: "tracked-scope" | "called-function"; +} + +export interface VirtualReference { + reference: TSESLint.Scope.Reference | T.Node; // store references directly instead of pushing variables? + declarationScope: ReactivityScope; +} + +function isRangeWithin(inner: T.Range, outer: T.Range): boolean { + return inner[0] >= outer[0] && inner[1] <= outer[1]; +} + +export class ReactivityScope { + constructor( + public node: ProgramOrFunctionNode, + public parentScope: ReactivityScope | null + ) {} + + childScopes: Array = []; + trackedScopes: Array = []; + errorContexts: Array = []; + references: Array = []; + hasJSX = false; + + deepestScopeContaining(node: T.Node | T.Range): ReactivityScope | null { + const range = Array.isArray(node) ? node : node.range; + if (isRangeWithin(range, this.node.range)) { + const matchedChildRange = this.childScopes.find((scope) => + scope.deepestScopeContaining(range) + ); + return matchedChildRange ?? this; + } + return null; + } + + isDeepestScopeFor(node: T.Node | T.Range) { + return this.deepestScopeContaining(Array.isArray(node) ? node : node.range) === this; + } + + *walk(): Iterable { + yield this; + for (const scope of this.childScopes) { + yield* scope.walk(); + } + } + + private *iterateUpwards(prop: (scope: ReactivityScope) => Iterable): Iterable { + yield* prop(this); + if (this.parentScope) { + yield* this.parentScope.iterateUpwards(prop); + } + } +} + +// class ReactivityScopeTree { +// constructor(public root: ReactivityScope) {} +// syncCallbacks = new Set(); + +// /** Finds the deepest ReactivityScope containing a given range, or null if the range extends beyond the code length. */ +// deepestScopeContaining = this.root.deepestScopeContaining.bind(this.root); +// // (node: T.Node | T.Range): ReactivityScope | null { +// // return this.root.deepestScopeContaining(range); +// // } + +// } diff --git a/src/rules/reactivity/pluginApi.ts b/src/rules/reactivity/pluginApi.ts new file mode 100644 index 0000000..fc8fbfb --- /dev/null +++ b/src/rules/reactivity/pluginApi.ts @@ -0,0 +1,93 @@ +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 { + /** + * Mark a node as a function in which reactive variables may be polled (not tracked). Can be + * async. + */ + calledFunction(node: T.Node): void; + /** + * Mark a node as a tracked scope, like `createEffect`'s callback or a JSXExpressionContainer. + */ + trackedScope(node: T.Node): void; + /** + * Mark a node as a function that will be synchronously called in the current scope, like + * Array#map callbacks. + */ + syncCallback(node: FunctionNode): void; + + /** + * Alter the error message within a scope to provide additional details. + */ + provideErrorContext(node: T.Node, errorMessage: string): void; + + /** + * Mark that a node evaluates to a signal (or memo, etc.). + */ + signal(node: T.Node, path?: ExprPath): void; + + /** + * Mark that a node evaluates to a store or props. + */ + store(node: T.Node, path?: ExprPath, options?: { mutable?: boolean }): void; + + /** + * Mark that a node is reactive in some way--either a signal-like or a store-like. + */ + reactive(node: T.Node, path?: ExprPath): 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; +} + +export interface ReactivityPlugin { + package: string; + create: (api: ReactivityPluginApi) => TSESLint.RuleListener; +} + +// Defeats type widening +export function plugin(p: ReactivityPlugin): ReactivityPlugin { + return p; +} + +// =========================== + +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 new file mode 100644 index 0000000..0421481 --- /dev/null +++ b/src/rules/reactivity/rule.ts @@ -0,0 +1,174 @@ +import { TSESLint, TSESTree as T } from "@typescript-eslint/utils"; +import { ProgramOrFunctionNode, FunctionNode, trackImports, isFunctionNode } from "../../utils"; +import { ReactivityScope, VirtualReference } from "./analyze"; +import type { ExprPath, ReactivityPlugin, ReactivityPluginApi } from "./pluginApi"; + +type MessageIds = + | "noWrite" + | "untrackedReactive" + | "expectedFunctionGotExpression" + | "badSignal" + | "badUnnamedDerivedSignal" + | "shouldDestructure" + | "shouldAssign" + | "noAsyncTrackedScope"; + +const rule: TSESLint.RuleModule = { + meta: { + type: "problem", + docs: { + recommended: "warn", + description: + "Enforce that reactive expressions (props, signals, memos, etc.) are only used in tracked scopes; otherwise, they won't update the view as expected.", + url: "https://github.com/solidjs-community/eslint-plugin-solid/blob/main/docs/reactivity.md", + }, + schema: [], + messages: { + noWrite: "The reactive variable '{{name}}' should not be reassigned or altered directly.", + untrackedReactive: + "The reactive variable '{{name}}' should be used within JSX, a tracked scope (like createEffect), or inside an event handler function.", + expectedFunctionGotExpression: + "The reactive variable '{{name}}' should be wrapped in a function for reactivity. This includes event handler bindings on native elements, which are not reactive like other JSX props.", + badSignal: + "The reactive variable '{{name}}' should be called as a function when used in {{where}}.", + badUnnamedDerivedSignal: + "This function should be passed to a tracked scope (like createEffect) or an event handler because it contains reactivity.", + shouldDestructure: + "For proper analysis, array destructuring should be used to capture the {{nth}}result of this function call.", + shouldAssign: + "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.", + }, + }, + create(context) { + const sourceCode = context.getSourceCode(); + + const { handleImportDeclaration, matchImport, matchLocalToModule } = trackImports(/^/); + + const root = new ReactivityScope(sourceCode.ast, null); + const syncCallbacks = new Set(); + const undistributedReferences: Array = []; + + let currentScope = root; + + function onFunctionEnter(node: FunctionNode) { + // if (syncCallbacks.has(node)) { + // // Ignore sync callbacks like Array#forEach and certain Solid primitives + // return; + // } + const childScope = new ReactivityScope(node, currentScope); + currentScope.childScopes.push(childScope); + currentScope = childScope; + } + + function onJSX() { + currentScope.hasJSX = true; + } + + function onFunctionExit() { + currentScope = currentScope.parentScope!; + } + + function stripSyncCallbacks(root: ReactivityScope) { + for (const scope of root.walk()) { + if (scope.childScopes.length > 0) { + // Though we're about to walk these childNodes, hit them now to avoid iterator + // invalidation. If a child scope is a sync callback, merge its data into this scope and + // remove it from the tree. + const addedChildScopes: Array = []; + scope.childScopes = scope.childScopes.filter((childScope) => { + if (isFunctionNode(childScope.node) && syncCallbacks.has(childScope.node)) { + addedChildScopes.push(...childScope.childScopes); + scope.trackedScopes.push(...childScope.trackedScopes); + scope.references.push(...childScope.references); + scope.hasJSX = scope.hasJSX || childScope.hasJSX; + return false; + } + return true; + }); + scope.childScopes.push(...addedChildScopes); + } + } + } + + function getReferences(node: T.Expression, path: ExprPath) { + if (node.parent?.type === "VariableDeclarator" && node.parent.init === node) { + + } + } + + 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)!; + scope.references.push(ref); + }); + } + + + const pluginApi: ReactivityPluginApi = { + calledFunction(node) { + currentScope.trackedScopes.push({ node, expect: "called-function" }); + }, + trackedScope(node) { + currentScope.trackedScopes.push({ node, expect: "tracked-scope" }); + }, + syncCallback(node) { + syncCallbacks.add(node); + }, + 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); + }, + reactive(node, path) { + const references = []; // TODO generate virtual reactive references + undistributedReferences.push(...references); + }, + isCall(node, primitive): node is T.CallExpression { + return ( + node.type === "CallExpression" && + (!primitive || (node.callee.type === "Identifier" && node.callee.name === primitive)) + ); + }, + }; + const visitors: Array> = []; + + return { + /* Function enter/exit */ + FunctionExpression: onFunctionEnter, + ArrowFunctionExpression: onFunctionEnter, + FunctionDeclaration: onFunctionEnter, + "FunctionExpression:exit": onFunctionExit, + "ArrowFunctionExpression:exit": onFunctionExit, + "FunctionDeclaration:exit": onFunctionExit, + + /* Detect JSX for adding props */ + JSXElement: onJSX, + JSXFragment: onJSX, + + ImportDeclaration: handleImportDeclaration, + + "*"(node: T.Node) { + // eslint-disable-next-line + // @ts-ignore + visitors.forEach((visitor) => visitor[node.type]?.(node)); + }, + + "Program:exit"() { + stripSyncCallbacks(root); + distributeReferences(root, undistributedReferences); + analyze(root); + }, + }; + }, +}; diff --git a/src/utils.ts b/src/utils.ts index 78c4fab..c6c8f96 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -124,11 +124,14 @@ export const getCommentAfter = ( export const trackImports = (fromModule = /^solid-js(?:\/?|\b)/) => { const importMap = new Map(); + const moduleMap = new Map(); + const handleImportDeclaration = (node: T.ImportDeclaration) => { if (fromModule.test(node.source.value)) { for (const specifier of node.specifiers) { if (specifier.type === "ImportSpecifier") { importMap.set(specifier.imported.name, specifier.local.name); + moduleMap.set(specifier.local.name, node.source.value); } } } @@ -137,7 +140,9 @@ export const trackImports = (fromModule = /^solid-js(?:\/?|\b)/) => { const importArr = Array.isArray(imports) ? imports : [imports]; return importArr.find((i) => importMap.get(i) === str); }; - return { matchImport, handleImportDeclaration }; + const matchLocalToModule = (local: string): string | undefined => moduleMap.get(local); + + return { matchImport, handleImportDeclaration, matchLocalToModule }; }; export function appendImports(