-
Notifications
You must be signed in to change notification settings - Fork 31
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Initial WIP work for reactivity plugin api.
- Loading branch information
1 parent
6d28495
commit 3492bb4
Showing
8 changed files
with
446 additions
and
11 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,4 @@ | ||
dist | ||
node_modules | ||
jest.config.js | ||
bin |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
// // } | ||
|
||
// } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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."); | ||
} | ||
} |
Oops, something went wrong.