Skip to content

Commit 784328e

Browse files
committed
refactor(testkit): simplify testkit internals with ASTUtils and shared inline rule helper
1 parent 42c6a5b commit 784328e

6 files changed

Lines changed: 71 additions & 94 deletions

File tree

.pkgs/testkit/docs/functions/collectNodes.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
```ts
66
function collectNodes<T>(
77
code: string,
8-
type: AST_NODE_TYPES,
8+
type: T["type"],
99
options?: ParseCodeOptions,
1010
): T[];
1111
```
@@ -21,7 +21,7 @@ function collectNodes<T>(
2121
| Parameter | Type |
2222
| --------- | ------------------------------------------------------- |
2323
| `code` | `string` |
24-
| `type` | `AST_NODE_TYPES` |
24+
| `type` | `T`\[`"type"`\] |
2525
| `options` | [`ParseCodeOptions`](../interfaces/ParseCodeOptions.md) |
2626

2727
## Returns

.pkgs/testkit/docs/functions/getFirstNodeOfType.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
```ts
66
function getFirstNodeOfType<T>(
77
code: string,
8-
type: AST_NODE_TYPES,
8+
type: T["type"],
99
options?: ParseCodeOptions,
1010
): T;
1111
```
@@ -21,7 +21,7 @@ function getFirstNodeOfType<T>(
2121
| Parameter | Type |
2222
| --------- | ------------------------------------------------------- |
2323
| `code` | `string` |
24-
| `type` | `AST_NODE_TYPES` |
24+
| `type` | `T`\[`"type"`\] |
2525
| `options` | [`ParseCodeOptions`](../interfaces/ParseCodeOptions.md) |
2626

2727
## Returns

.pkgs/testkit/docs/functions/runCollector.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ function runCollector<A, R>(
77
code: string,
88
getCollector: (context: TestRuleContext) => {
99
api: A;
10-
visitor: object;
10+
visitor: RuleListener;
1111
},
1212
harvest: (api: A, program: Program) => R,
1313
): R;
@@ -26,11 +26,11 @@ into the rule, and harvests the result via the collector's `api` on
2626

2727
## Parameters
2828

29-
| Parameter | Type |
30-
| -------------- | -------------------------------------------------------------------------------------------------------------- |
31-
| `code` | `string` |
32-
| `getCollector` | (`context`: [`TestRuleContext`](../type-aliases/TestRuleContext.md)) => \{ `api`: `A`; `visitor`: `object`; \} |
33-
| `harvest` | (`api`: `A`, `program`: `Program`) => `R` |
29+
| Parameter | Type |
30+
| -------------- | -------------------------------------------------------------------------------------------------------------------- |
31+
| `code` | `string` |
32+
| `getCollector` | (`context`: [`TestRuleContext`](../type-aliases/TestRuleContext.md)) => \{ `api`: `A`; `visitor`: `RuleListener`; \} |
33+
| `harvest` | (`api`: `A`, `program`: `Program`) => `R` |
3434

3535
## Returns
3636

.pkgs/testkit/src/linter.ts

Lines changed: 50 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import * as tsParser from "@typescript-eslint/parser";
22
import type { TSESTree } from "@typescript-eslint/types";
3-
import type { RuleContext } from "@typescript-eslint/utils/ts-eslint";
4-
import { Linter } from "eslint";
3+
import { Linter, type RuleContext, type RuleListener } from "@typescript-eslint/utils/ts-eslint";
54

65
/**
76
* The rule context surface handed to unit-test harness callbacks.
@@ -15,35 +14,47 @@ const testRuleLanguageOptions = {
1514
} as const;
1615

1716
/**
18-
* Runs `code` through a real `Linter` with an inline test rule and calls `fn`
19-
* from its `Program` listener, giving the callback a real rule context
20-
* (scope manager, static evaluation, ...).
17+
* Runs `code` through a real `Linter` with an inline test rule.
18+
* The supplied `createVisitor` callback receives the real rule context
19+
* and returns the visitor that the rule should use.
2120
*/
22-
export function runInRule<T>(code: string, fn: (context: TestRuleContext, program: TSESTree.Program) => T): T {
23-
const state = { called: false };
24-
let fact: T | undefined;
21+
function runInlineRule(code: string, createVisitor: (context: TestRuleContext) => RuleListener): TestRuleContext {
22+
let context: TestRuleContext | null = null;
2523
new Linter().verify(code, {
2624
languageOptions: testRuleLanguageOptions,
2725
plugins: {
2826
test: {
2927
rules: {
3028
"test-rule": {
3129
meta: { type: "problem", messages: {}, schema: [] },
32-
create(context: unknown) {
33-
return {
34-
Program(program: TSESTree.Program) {
35-
state.called = true;
36-
// tsl-ignore dx/no-unsafe-as
37-
fact = fn(context as TestRuleContext, program);
38-
},
39-
};
30+
create(ctx: unknown) {
31+
// tsl-ignore dx/no-unsafe-as
32+
context = ctx as TestRuleContext;
33+
return createVisitor(context);
4034
},
4135
},
4236
},
4337
},
4438
},
4539
rules: { "test/test-rule": "error" },
4640
});
41+
return context!;
42+
}
43+
44+
/**
45+
* Runs `code` through a real `Linter` with an inline test rule and calls `fn`
46+
* from its `Program` listener, giving the callback a real rule context
47+
* (scope manager, static evaluation, ...).
48+
*/
49+
export function runInRule<T>(code: string, fn: (context: TestRuleContext, program: TSESTree.Program) => T): T {
50+
const state = { called: false };
51+
let fact: T | undefined;
52+
runInlineRule(code, (context) => ({
53+
Program(program: TSESTree.Program) {
54+
state.called = true;
55+
fact = fn(context, program);
56+
},
57+
}));
4758
if (!state.called) {
4859
throw new Error("runInRule: the rule's Program listener was not invoked");
4960
}
@@ -55,37 +66,17 @@ export function runInRule<T>(code: string, fn: (context: TestRuleContext, progra
5566
* Runs `code` through a real `Linter` and captures the first node visited by
5667
* `visitorKey` (e.g. `"JSXElement"`) together with the rule context.
5768
*/
58-
export function getNodeInRule<T extends TSESTree.Node>(
59-
code: string,
60-
visitorKey: string,
61-
): { context: TestRuleContext; node: T } {
62-
const found: { context: TestRuleContext | null; node: T | null } = { context: null, node: null };
63-
new Linter().verify(code, {
64-
languageOptions: testRuleLanguageOptions,
65-
plugins: {
66-
test: {
67-
rules: {
68-
"test-rule": {
69-
meta: { type: "problem", messages: {}, schema: [] },
70-
create(context: unknown) {
71-
// tsl-ignore dx/no-unsafe-as
72-
found.context = context as TestRuleContext;
73-
return {
74-
[visitorKey](node: T) {
75-
found.node ??= node;
76-
},
77-
};
78-
},
79-
},
80-
},
81-
},
69+
export function getNodeInRule<T extends TSESTree.Node>(code: string, visitorKey: string): { context: TestRuleContext; node: T } {
70+
const found: { node: T | null } = { node: null };
71+
const context = runInlineRule(code, () => ({
72+
[visitorKey](node: T) {
73+
found.node ??= node;
8274
},
83-
rules: { "test/test-rule": "error" },
84-
});
85-
if (found.context == null || found.node == null) {
75+
}));
76+
if (found.node == null) {
8677
throw new Error(`expected a node matching "${visitorKey}" in the code`);
8778
}
88-
return { context: found.context, node: found.node };
79+
return { context, node: found.node };
8980
}
9081

9182
/**
@@ -95,39 +86,27 @@ export function getNodeInRule<T extends TSESTree.Node>(
9586
*/
9687
export function runCollector<A, R>(
9788
code: string,
98-
getCollector: (context: TestRuleContext) => { api: A; visitor: object },
89+
getCollector: (context: TestRuleContext) => { api: A; visitor: RuleListener },
9990
harvest: (api: A, program: TSESTree.Program) => R,
10091
): R {
10192
const state = { harvested: false };
10293
let program: TSESTree.Program | null = null;
10394
let fact: R | undefined;
104-
new Linter().verify(code, {
105-
languageOptions: testRuleLanguageOptions,
106-
plugins: {
107-
test: {
108-
rules: {
109-
"test-rule": {
110-
meta: { type: "problem", messages: {}, schema: [] },
111-
create(context: unknown) {
112-
// tsl-ignore dx/no-unsafe-as
113-
const { api, visitor } = getCollector(context as TestRuleContext);
114-
return {
115-
...visitor,
116-
Program(node: TSESTree.Program) {
117-
program = node;
118-
},
119-
"Program:exit"() {
120-
if (program == null) return;
121-
state.harvested = true;
122-
fact = harvest(api, program);
123-
},
124-
};
125-
},
126-
},
127-
},
95+
let api: A | undefined;
96+
runInlineRule(code, (context) => {
97+
const collector = getCollector(context);
98+
api = collector.api;
99+
return {
100+
...collector.visitor,
101+
Program(node: TSESTree.Program) {
102+
program = node;
128103
},
129-
},
130-
rules: { "test/test-rule": "error" },
104+
"Program:exit"() {
105+
if (program == null || api == null) return;
106+
state.harvested = true;
107+
fact = harvest(api, program);
108+
},
109+
};
131110
});
132111
if (!state.harvested) {
133112
throw new Error("runCollector: the harvest callback was not invoked");

.pkgs/testkit/src/mock-context.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { AST_NODE_TYPES as AST, type TSESTree } from "@typescript-eslint/types";
2+
import { ASTUtils } from "@typescript-eslint/utils";
23

34
import type { TestRuleContext } from "./linter";
45
import type { parseCode } from "./parse";
@@ -9,20 +10,15 @@ import type { parseCode } from "./parse";
910
*/
1011
export function createScopeContext(parsed: ReturnType<typeof parseCode>): TestRuleContext {
1112
const { scopeManager } = parsed;
13+
const globalScope = scopeManager.scopes[0]!;
1214
// tsl-ignore dx/no-unsafe-as
1315
return {
1416
sourceCode: {
1517
getScope(node: TSESTree.Node) {
16-
const inner = node.type !== AST.Program;
17-
for (let current: TSESTree.Node | undefined = node; current != null; current = current.parent) {
18-
const scope = scopeManager.acquire(current, inner);
19-
if (scope != null) {
20-
return scope.type === "function-expression-name"
21-
? scope.childScopes[0]
22-
: scope;
23-
}
18+
if (node.type === AST.Program) {
19+
return globalScope;
2420
}
25-
return scopeManager.scopes[0];
21+
return ASTUtils.getInnermostScope(globalScope, node);
2622
},
2723
},
2824
} as unknown as TestRuleContext;

.pkgs/testkit/src/parse.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
/// <reference types="node" />
22

33
import { parseForESLint } from "@typescript-eslint/parser";
4-
import type { AST_NODE_TYPES, TSESTree } from "@typescript-eslint/types";
4+
import type { TSESTree } from "@typescript-eslint/types";
55
import { simpleTraverse } from "@typescript-eslint/typescript-estree";
6+
import { ASTUtils } from "@typescript-eslint/utils";
67
import path from "node:path";
78

89
import { getFixturesRootDir } from "./fixtures";
@@ -30,13 +31,14 @@ export function parseCode(code: string, options: ParseCodeOptions = {}): ReturnT
3031
});
3132
}
3233

33-
export function collectNodes<T extends TSESTree.Node>(code: string, type: AST_NODE_TYPES, options: ParseCodeOptions = {}): T[] {
34+
export function collectNodes<T extends TSESTree.Node>(code: string, type: T["type"], options: ParseCodeOptions = {}): T[] {
3435
const nodes: T[] = [];
36+
const isTarget = ASTUtils.isNodeOfType(type);
3537
simpleTraverse(
3638
parseCode(code, options).ast,
3739
{
3840
enter(node) {
39-
if (node.type === type) {
41+
if (isTarget(node)) {
4042
// @ts-expect-error - we know the node is of type `T`, but the type checker doesn't know
4143
nodes.push(node);
4244
}
@@ -47,7 +49,7 @@ export function collectNodes<T extends TSESTree.Node>(code: string, type: AST_NO
4749
return nodes;
4850
}
4951

50-
export function getFirstNodeOfType<T extends TSESTree.Node>(code: string, type: AST_NODE_TYPES, options: ParseCodeOptions = {}): T {
52+
export function getFirstNodeOfType<T extends TSESTree.Node>(code: string, type: T["type"], options: ParseCodeOptions = {}): T {
5153
const [node] = collectNodes<T>(code, type, options);
5254
if (node == null) {
5355
throw new Error(`No ${type} found in: ${code}`);

0 commit comments

Comments
 (0)