Skip to content

Commit 3492bb4

Browse files
committed
Initial WIP work for reactivity plugin api.
1 parent 6d28495 commit 3492bb4

File tree

8 files changed

+446
-11
lines changed

8 files changed

+446
-11
lines changed

.eslintignore

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
dist
22
node_modules
33
jest.config.js
4+
bin

bin/reactivity.cjs

+79
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
#!/usr/bin/env node
2+
3+
const fs = require("fs/promises");
4+
const path = require("path");
5+
const glob = require("fast-glob");
6+
7+
const KEY = "solid/reactivity";
8+
const WRAPPED_KEY = `"${KEY}"`;
9+
10+
async function findPlugins() {
11+
const pluginsMap = new Map();
12+
13+
const files = await glob("**/package.json", { onlyFiles: true, unique: true, deep: 10 });
14+
await Promise.all(
15+
files.map(async (file) => {
16+
const contents = await fs.readFile(file, "utf-8");
17+
18+
if (contents.includes(WRAPPED_KEY)) {
19+
const parsed = JSON.parse(contents);
20+
if (parsed && Object.prototype.hasOwnProperty.call(parsed, KEY)) {
21+
const pluginPath = parsed[KEY];
22+
if (file === "package.json") {
23+
pluginsMap.set(".", pluginPath); // associate current CWD with the plugin path
24+
} else {
25+
pluginsMap.set(parsed.name, pluginPath); // associate the package name with the plugin path
26+
}
27+
}
28+
}
29+
})
30+
);
31+
32+
return pluginsMap;
33+
}
34+
35+
async function reactivityCLI() {
36+
if (process.argv.some((token) => token.match(/\-h|\-v/i))) {
37+
console.log(
38+
`eslint-plugin-solid v${require("../package.json").version}
39+
40+
This CLI command searches for any plugins for the "solid/reactivity" ESLint rule that your dependencies make available.
41+
42+
If any are found, an ESLint rule config will be printed out with the necessary options to load and use those "solid/reactivity" plugins.
43+
If you are authoring a framework or template repository for SolidJS, this can help your users get the best possible linter feedback.
44+
45+
For instructions on authoring a "solid/reactivity" plugin, see TODO PROVIDE URL.`
46+
);
47+
} else {
48+
console.log(
49+
`Searching for ${WRAPPED_KEY} keys in all package.json files... (this could take a minute)`
50+
);
51+
const pluginsMap = await findPlugins();
52+
if (pluginsMap.size > 0) {
53+
// create a resolve function relative from the root of the current working directory
54+
const { resolve } = require("module").createRequire(path.join(process.cwd(), "index.js"));
55+
56+
const plugins = Array.from(pluginsMap)
57+
.map(([packageName, pluginPath]) => {
58+
const absPluginPath = resolve(
59+
(packageName + "/" + pluginPath).replace(/(\.\/){2,}/g, "./")
60+
);
61+
return path.relative(process.cwd(), absPluginPath);
62+
})
63+
.filter((v, i, a) => a.indexOf(v) === i); // remove duplicates
64+
65+
const config = [1, { plugins }];
66+
67+
console.log(
68+
`Found ${plugins.length} "solid/reactivity" plugin${plugins.length !== 1 ? "s" : ""}. Add ${
69+
plugins.length !== 1 ? "them" : "it"
70+
} to your ESLint config by adding the following to the "rules" section:\n`
71+
);
72+
console.log(`"solid/reactivity": ${JSON.stringify(config, null, 2)}`);
73+
} else {
74+
console.log(`No "solid/reactivity" plugins found.`);
75+
}
76+
}
77+
}
78+
79+
reactivityCLI();

pnpm-lock.yaml

+3-3
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

scripts/docs.ts

+9-7
Original file line numberDiff line numberDiff line change
@@ -181,13 +181,15 @@ async function run() {
181181
const docRoot = path.resolve(__dirname, "..", "docs");
182182
const docFiles = (await fs.readdir(docRoot)).filter((p) => p.endsWith(".md"));
183183
for (const docFile of docFiles) {
184-
markdownMagic(path.join(docRoot, docFile), {
185-
transforms: {
186-
HEADER: () => buildHeader(docFile),
187-
OPTIONS: () => buildOptions(docFile),
188-
CASES: (content: string) => buildCases(content, docFile),
189-
},
190-
});
184+
if (docFile !== "reactivity-customization.md") {
185+
markdownMagic(path.join(docRoot, docFile), {
186+
transforms: {
187+
HEADER: () => buildHeader(docFile),
188+
OPTIONS: () => buildOptions(docFile),
189+
CASES: (content: string) => buildCases(content, docFile),
190+
},
191+
});
192+
}
191193
}
192194
}
193195

src/rules/reactivity/analyze.ts

+81
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { TSESLint, TSESTree as T } from "@typescript-eslint/utils";
2+
import { ProgramOrFunctionNode, FunctionNode } from "../../utils";
3+
import type { ReactivityPluginApi } from "./pluginApi";
4+
5+
interface TrackedScope {
6+
/**
7+
* The root node, usually a function or JSX expression container, to allow
8+
* reactive variables under.
9+
*/
10+
node: T.Node;
11+
/**
12+
* The reactive variable should be one of these types:
13+
* - "tracked-scope": synchronous function or signal variable, or JSX container/spread
14+
* - "called-function": synchronous or asynchronous function like a timer or
15+
* event handler that isn't really a tracked scope but allows reactivity
16+
*/
17+
expect: "tracked-scope" | "called-function";
18+
}
19+
20+
export interface VirtualReference {
21+
reference: TSESLint.Scope.Reference | T.Node; // store references directly instead of pushing variables?
22+
declarationScope: ReactivityScope;
23+
}
24+
25+
function isRangeWithin(inner: T.Range, outer: T.Range): boolean {
26+
return inner[0] >= outer[0] && inner[1] <= outer[1];
27+
}
28+
29+
export class ReactivityScope {
30+
constructor(
31+
public node: ProgramOrFunctionNode,
32+
public parentScope: ReactivityScope | null
33+
) {}
34+
35+
childScopes: Array<ReactivityScope> = [];
36+
trackedScopes: Array<TrackedScope> = [];
37+
errorContexts: Array<T.Node> = [];
38+
references: Array<VirtualReference> = [];
39+
hasJSX = false;
40+
41+
deepestScopeContaining(node: T.Node | T.Range): ReactivityScope | null {
42+
const range = Array.isArray(node) ? node : node.range;
43+
if (isRangeWithin(range, this.node.range)) {
44+
const matchedChildRange = this.childScopes.find((scope) =>
45+
scope.deepestScopeContaining(range)
46+
);
47+
return matchedChildRange ?? this;
48+
}
49+
return null;
50+
}
51+
52+
isDeepestScopeFor(node: T.Node | T.Range) {
53+
return this.deepestScopeContaining(Array.isArray(node) ? node : node.range) === this;
54+
}
55+
56+
*walk(): Iterable<ReactivityScope> {
57+
yield this;
58+
for (const scope of this.childScopes) {
59+
yield* scope.walk();
60+
}
61+
}
62+
63+
private *iterateUpwards<T>(prop: (scope: ReactivityScope) => Iterable<T>): Iterable<T> {
64+
yield* prop(this);
65+
if (this.parentScope) {
66+
yield* this.parentScope.iterateUpwards(prop);
67+
}
68+
}
69+
}
70+
71+
// class ReactivityScopeTree {
72+
// constructor(public root: ReactivityScope) {}
73+
// syncCallbacks = new Set<FunctionNode>();
74+
75+
// /** Finds the deepest ReactivityScope containing a given range, or null if the range extends beyond the code length. */
76+
// deepestScopeContaining = this.root.deepestScopeContaining.bind(this.root);
77+
// // (node: T.Node | T.Range): ReactivityScope | null {
78+
// // return this.root.deepestScopeContaining(range);
79+
// // }
80+
81+
// }

src/rules/reactivity/pluginApi.ts

+93
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import type { TSESTree as T, TSESLint, TSESTree } from "@typescript-eslint/utils";
2+
import { FunctionNode, ProgramOrFunctionNode } from "../../utils";
3+
4+
type PathSegment = `[${number}]`;
5+
export type ExprPath = PathSegment; // PathSegment | `${PathSegment}${Path}`;
6+
7+
export interface ReactivityPluginApi {
8+
/**
9+
* Mark a node as a function in which reactive variables may be polled (not tracked). Can be
10+
* async.
11+
*/
12+
calledFunction(node: T.Node): void;
13+
/**
14+
* Mark a node as a tracked scope, like `createEffect`'s callback or a JSXExpressionContainer.
15+
*/
16+
trackedScope(node: T.Node): void;
17+
/**
18+
* Mark a node as a function that will be synchronously called in the current scope, like
19+
* Array#map callbacks.
20+
*/
21+
syncCallback(node: FunctionNode): void;
22+
23+
/**
24+
* Alter the error message within a scope to provide additional details.
25+
*/
26+
provideErrorContext(node: T.Node, errorMessage: string): void;
27+
28+
/**
29+
* Mark that a node evaluates to a signal (or memo, etc.).
30+
*/
31+
signal(node: T.Node, path?: ExprPath): void;
32+
33+
/**
34+
* Mark that a node evaluates to a store or props.
35+
*/
36+
store(node: T.Node, path?: ExprPath, options?: { mutable?: boolean }): void;
37+
38+
/**
39+
* Mark that a node is reactive in some way--either a signal-like or a store-like.
40+
*/
41+
reactive(node: T.Node, path?: ExprPath): void;
42+
43+
/**
44+
* Convenience method for checking if a node is a call expression. If `primitive` is provided,
45+
* checks that the callee is a Solid import with that name (or one of that name), handling aliases.
46+
*/
47+
isCall(node: T.Node, primitive?: string | Array<string>): node is T.CallExpression;
48+
}
49+
50+
export interface ReactivityPlugin {
51+
package: string;
52+
create: (api: ReactivityPluginApi) => TSESLint.RuleListener;
53+
}
54+
55+
// Defeats type widening
56+
export function plugin(p: ReactivityPlugin): ReactivityPlugin {
57+
return p;
58+
}
59+
60+
// ===========================
61+
62+
class Reactivity implements ReactivityPluginApi {
63+
trackedScope(node: T.Node): void {
64+
throw new Error("Method not implemented.");
65+
}
66+
calledFunction(node: T.Node): void {
67+
throw new Error("Method not implemented.");
68+
}
69+
trackedFunction(node: T.Node): void {
70+
throw new Error("Method not implemented.");
71+
}
72+
trackedExpression(node: T.Node): void {
73+
throw new Error("Method not implemented.");
74+
}
75+
syncCallback(node: T.Node): void {
76+
throw new Error("Method not implemented.");
77+
}
78+
provideErrorContext(node: T.Node, errorMessage: string): void {
79+
throw new Error("Method not implemented.");
80+
}
81+
signal(node: T.Node, path?: `[${number}]` | undefined): void {
82+
throw new Error("Method not implemented.");
83+
}
84+
store(node: T.Node, path?: `[${number}]` | undefined): void {
85+
throw new Error("Method not implemented.");
86+
}
87+
reactive(node: T.Node, path?: `[${number}]` | undefined): void {
88+
throw new Error("Method not implemented.");
89+
}
90+
isCall(node: T.Node, primitive?: string | string[] | undefined): node is T.CallExpression {
91+
throw new Error("Method not implemented.");
92+
}
93+
}

0 commit comments

Comments
 (0)