Skip to content

Commit c1e4b55

Browse files
committed
[skip ci] WIP building out getReferences for approximate feature parity
1 parent 3492bb4 commit c1e4b55

File tree

8 files changed

+157
-71
lines changed

8 files changed

+157
-71
lines changed

package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@
3434
"jsx-ast-utils": "^3.3.2",
3535
"kebab-case": "^1.0.1",
3636
"known-css-properties": "^0.24.0",
37-
"style-to-object": "^0.3.0"
37+
"style-to-object": "^0.3.0",
38+
"tiny-invariant": "^1.3.1"
3839
},
3940
"devDependencies": {
4041
"@babel/core": "^7.18.9",

pnpm-lock.yaml

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

src/rules/reactivity.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -601,7 +601,7 @@ const rule: TSESLint.RuleModule<MessageIds, []> = {
601601
};
602602

603603
/*
604-
* Sync array functions (forEach, map, reduce, reduceRight, flatMap),
604+
* Sync array functions (forEach, map, reduce, reduceRight, flatMap), IIFEs,
605605
* store update fn params (ex. setState("todos", (t) => [...t.slice(0, i()),
606606
* ...t.slice(i() + 1)])), batch, onCleanup, and onError fn params, and
607607
* maybe a few others don't actually create a new scope. That is, any

src/rules/reactivity/analyze.ts

+22-2
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,28 @@ interface TrackedScope {
1818
}
1919

2020
export interface VirtualReference {
21-
reference: TSESLint.Scope.Reference | T.Node; // store references directly instead of pushing variables?
22-
declarationScope: ReactivityScope;
21+
/**
22+
* The node, commonly an identifier, where reactivity is referenced.
23+
*/
24+
node: T.Node;
25+
/**
26+
* The scope where the variable `node` is referencing is declared, or `null` if no variable.
27+
* Since reactivity is, by definition, set up once and accessed multiple times, the final
28+
* call or property access in a reactive expression's `path` is the access that's reactive.
29+
* Previous `path` segments just describe the API. It's safe to assume that the second-to-last
30+
* `path` segment defines the declaration scope unless the reactive expression is a derived signal.
31+
*
32+
* @example
33+
* function Component() {
34+
* const [signal1] = createSignal(42);
35+
* const signal2 = createSignal(42)[0];
36+
* const signal3 = createSignal(42)[0];
37+
* const d = () => {
38+
* console.log(signal()); // declarationScope === Component
39+
* }
40+
* console.log(createSignal(42)[0]()); // declarationScope ===
41+
*/
42+
declarationScope: ReactivityScope | null;
2343
}
2444

2545
function isRangeWithin(inner: T.Range, outer: T.Range): boolean {

src/rules/reactivity/pluginApi.ts

+33-42
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import type { TSESTree as T, TSESLint, TSESTree } from "@typescript-eslint/utils";
22
import { FunctionNode, ProgramOrFunctionNode } from "../../utils";
33

4-
type PathSegment = `[${number}]`;
5-
export type ExprPath = PathSegment; // PathSegment | `${PathSegment}${Path}`;
4+
65

76
export interface ReactivityPluginApi {
87
/**
@@ -26,25 +25,50 @@ export interface ReactivityPluginApi {
2625
provideErrorContext(node: T.Node, errorMessage: string): void;
2726

2827
/**
29-
* Mark that a node evaluates to a signal (or memo, etc.).
28+
* Mark that a node evaluates to a signal (or memo, etc.). Short for `.reactive(node, (path ?? '') + '()')`
3029
*/
31-
signal(node: T.Node, path?: ExprPath): void;
30+
signal(node: T.Node, path?: string): void;
3231

3332
/**
34-
* Mark that a node evaluates to a store or props.
33+
* Mark that a node evaluates to a store or props. Short for `.reactive(node, (path ?? '') + '.**')`
3534
*/
36-
store(node: T.Node, path?: ExprPath, options?: { mutable?: boolean }): void;
35+
store(node: T.Node, path?: string, options?: { mutable?: boolean }): void;
3736

3837
/**
39-
* Mark that a node is reactive in some way--either a signal-like or a store-like.
38+
* Mark that a node is reactive in some way--either a signal-like or a store-like, or something
39+
* more specialized.
40+
* @param path a string with a special pattern, akin to a "type" for reactivity. Composed of the
41+
* following segments:
42+
* - `()`: when called
43+
* - `[0]`: when the first element is accessed
44+
* - `[]`: when any element is accessed
45+
* - `.foo`: when the 'foo' property is accessed
46+
* - `.*`: when any direct property is accessed
47+
* - `.**`: when any direct or nested property is accessed
48+
* @example
49+
* if (isCall(node, "createSignal")) {
50+
* reactive(node, '[0]()');
51+
* } else if (isCall(node, "createMemo")) {
52+
* reactive(node, '()');
53+
* } else if (isCall(node, "splitProps")) {
54+
* reactive(node, '[].**');
55+
* } else if (isCall(node, "createResource")) {
56+
* reactive(node, '()');
57+
* reactive(node, '.loading');
58+
* reactive(node, '.error');
59+
* }
4060
*/
41-
reactive(node: T.Node, path?: ExprPath): void;
61+
reactive(node: T.Node, path: string): void;
4262

4363
/**
4464
* Convenience method for checking if a node is a call expression. If `primitive` is provided,
4565
* checks that the callee is a Solid import with that name (or one of that name), handling aliases.
4666
*/
47-
isCall(node: T.Node, primitive?: string | Array<string>): node is T.CallExpression;
67+
isCall(
68+
node: T.Node,
69+
primitive?: string | Array<string> | RegExp,
70+
module?: string | RegExp
71+
): node is T.CallExpression;
4872
}
4973

5074
export interface ReactivityPlugin {
@@ -58,36 +82,3 @@ export function plugin(p: ReactivityPlugin): ReactivityPlugin {
5882
}
5983

6084
// ===========================
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-
}

src/rules/reactivity/rule.ts

+88-21
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,28 @@
1-
import { TSESLint, TSESTree as T } from "@typescript-eslint/utils";
2-
import { ProgramOrFunctionNode, FunctionNode, trackImports, isFunctionNode } from "../../utils";
1+
import { TSESLint, TSESTree as T, ASTUtils } from "@typescript-eslint/utils";
2+
import invariant from 'tiny-invariant'
3+
import {
4+
ProgramOrFunctionNode,
5+
FunctionNode,
6+
trackImports,
7+
isFunctionNode,
8+
ignoreTransparentWrappers,
9+
} from "../../utils";
310
import { ReactivityScope, VirtualReference } from "./analyze";
4-
import type { ExprPath, ReactivityPlugin, ReactivityPluginApi } from "./pluginApi";
11+
import type { ReactivityPlugin, ReactivityPluginApi } from "./pluginApi";
12+
13+
const { findVariable } = ASTUtils;
14+
15+
function parsePath(path: string): Array<string> | null {
16+
if (path) {
17+
const regex = /\(\)|\[\d*\]|\.(?:\w+|**?)/g;
18+
const matches = path.match(regex);
19+
// ensure the whole string is matched
20+
if (matches && matches.reduce((acc, match) => acc + match.length, 0) === path.length) {
21+
return matches;
22+
}
23+
}
24+
return null;
25+
}
526

627
type MessageIds =
728
| "noWrite"
@@ -11,7 +32,8 @@ type MessageIds =
1132
| "badUnnamedDerivedSignal"
1233
| "shouldDestructure"
1334
| "shouldAssign"
14-
| "noAsyncTrackedScope";
35+
| "noAsyncTrackedScope"
36+
| "jsxReactiveVariable";
1537

1638
const rule: TSESLint.RuleModule<MessageIds, []> = {
1739
meta: {
@@ -39,12 +61,13 @@ const rule: TSESLint.RuleModule<MessageIds, []> = {
3961
"For proper analysis, a variable should be used to capture the result of this function call.",
4062
noAsyncTrackedScope:
4163
"This tracked scope should not be async. Solid's reactivity only tracks synchronously.",
64+
jsxReactiveVariable: "This variable should not be used as a JSX element.",
4265
},
4366
},
4467
create(context) {
4568
const sourceCode = context.getSourceCode();
4669

47-
const { handleImportDeclaration, matchImport, matchLocalToModule } = trackImports(/^/);
70+
const { handleImportDeclaration, matchImport, matchLocalToModule } = trackImports();
4871

4972
const root = new ReactivityScope(sourceCode.ast, null);
5073
const syncCallbacks = new Set<FunctionNode>();
@@ -92,22 +115,64 @@ const rule: TSESLint.RuleModule<MessageIds, []> = {
92115
}
93116
}
94117

95-
function getReferences(node: T.Expression, path: ExprPath) {
96-
if (node.parent?.type === "VariableDeclarator" && node.parent.init === node) {
97-
118+
function VirtualReference(node: T.Node): VirtualReference {
119+
return { node, declarationScope: }
120+
}
121+
122+
/**
123+
* Given what's usually a CallExpression and a description of how the expression must be used
124+
* in order to be accessed reactively, return a list of virtual references for each place where
125+
* a reactive expression is accessed.
126+
* `path` is a string formatted according to `pluginApi`.
127+
*/
128+
function* getReferences(node: T.Expression, path: string, allowMutable = false): Generator<VirtualReference> {
129+
node = ignoreTransparentWrappers(node, "up");
130+
if (!path) {
131+
yield VirtualReference(node);
132+
} else if (node.parent?.type === "VariableDeclarator" && node.parent.init === node) {
133+
const { id } = node.parent;
134+
if (id.type === "Identifier") {
135+
const variable = findVariable(context.getScope(), id);
136+
if (variable) {
137+
for (const reference of variable.references) {
138+
if (reference.init) {
139+
// ignore
140+
} else if (reference.identifier.type === "JSXIdentifier") {
141+
context.report({ node: reference.identifier, messageId: "jsxReactiveVariable" });
142+
} else if (reference.isWrite()) {
143+
if (!allowMutable) {
144+
context.report({ node: reference.identifier, messageId: "noWrite" });
145+
}
146+
} else {
147+
yield* getReferences(reference.identifier, path);
148+
}
149+
}
150+
}
151+
} else if (id.type === "ArrayPattern") {
152+
const parsedPath = parsePath(path)
153+
if (parsedPath) {
154+
const newPath = path.substring(match[0].length);
155+
const index = match[1]
156+
if (index === '*') {
157+
158+
} else {
159+
160+
}
161+
}
162+
163+
}
98164
}
99165
}
100166

101167
function distributeReferences(root: ReactivityScope, references: Array<VirtualReference>) {
102168
references.forEach((ref) => {
103-
const range =
104-
"range" in ref.reference ? ref.reference.range : ref.reference.identifier.range;
105-
const scope = root.deepestScopeContaining(range)!;
169+
const range = ref.node.range;
170+
const scope = root.deepestScopeContaining(range);
171+
invariant(scope != null)
106172
scope.references.push(ref);
107173
});
108174
}
109175

110-
111176
const pluginApi: ReactivityPluginApi = {
112177
calledFunction(node) {
113178
currentScope.trackedScopes.push({ node, expect: "called-function" });
@@ -121,19 +186,21 @@ const rule: TSESLint.RuleModule<MessageIds, []> = {
121186
provideErrorContext(node) {
122187
currentScope.errorContexts.push(node);
123188
},
124-
signal(node, path) {
125-
126-
const references = []; // TODO generate virtual signal references
127-
undistributedReferences.push(...references);
128-
},
129-
store(node, path, options) {
130-
const references = []; // TODO generate virtual store references
131-
undistributedReferences.push(...references);
132-
},
189+
// signal(node, path) {
190+
// const references = []; // TODO generate virtual signal references
191+
// undistributedReferences.push(...references);
192+
// },
193+
// store(node, path, options) {
194+
// const references = []; // TODO generate virtual store references
195+
// undistributedReferences.push(...references);
196+
// },
133197
reactive(node, path) {
134198
const references = []; // TODO generate virtual reactive references
135199
undistributedReferences.push(...references);
136200
},
201+
getReferences(node, path) {
202+
return Array.from(getReferences(node, path));
203+
},
137204
isCall(node, primitive): node is T.CallExpression {
138205
return (
139206
node.type === "CallExpression" &&

src/utils.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -70,11 +70,11 @@ export function trace(node: T.Node, initialScope: TSESLint.Scope.Scope): T.Node
7070
}
7171

7272
/** Get the relevant node when wrapped by a node that doesn't change the behavior */
73-
export function ignoreTransparentWrappers(node: T.Node, up = false): T.Node {
73+
export function ignoreTransparentWrappers(node: T.Expression, dir = 'down'): T.Expression {
7474
if (node.type === "TSAsExpression" || node.type === "TSNonNullExpression") {
75-
const next = up ? node.parent : node.expression;
75+
const next = dir === 'up' ? node.parent as T.Expression : node.expression;
7676
if (next) {
77-
return ignoreTransparentWrappers(next, up);
77+
return ignoreTransparentWrappers(next, dir);
7878
}
7979
}
8080
return node;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export {};

0 commit comments

Comments
 (0)