Skip to content

Commit

Permalink
Initial WIP work for reactivity plugin api.
Browse files Browse the repository at this point in the history
  • Loading branch information
joshwilsonvu committed Mar 12, 2023
1 parent 6d28495 commit 3492bb4
Show file tree
Hide file tree
Showing 8 changed files with 446 additions and 11 deletions.
1 change: 1 addition & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
dist
node_modules
jest.config.js
bin
79 changes: 79 additions & 0 deletions bin/reactivity.cjs
Original file line number Diff line number Diff line change
@@ -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();
6 changes: 3 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 9 additions & 7 deletions scripts/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
},
});
}
}
}

Expand Down
81 changes: 81 additions & 0 deletions src/rules/reactivity/analyze.ts
Original file line number Diff line number Diff line change
@@ -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<ReactivityScope> = [];
trackedScopes: Array<TrackedScope> = [];
errorContexts: Array<T.Node> = [];
references: Array<VirtualReference> = [];
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<ReactivityScope> {
yield this;
for (const scope of this.childScopes) {
yield* scope.walk();
}
}

private *iterateUpwards<T>(prop: (scope: ReactivityScope) => Iterable<T>): Iterable<T> {
yield* prop(this);
if (this.parentScope) {
yield* this.parentScope.iterateUpwards(prop);
}
}
}

// class ReactivityScopeTree {
// constructor(public root: ReactivityScope) {}
// syncCallbacks = new Set<FunctionNode>();

// /** 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);
// // }

// }
93 changes: 93 additions & 0 deletions src/rules/reactivity/pluginApi.ts
Original file line number Diff line number Diff line change
@@ -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<string>): 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.");
}
}
Loading

0 comments on commit 3492bb4

Please sign in to comment.