Skip to content

Commit f7bef73

Browse files
lilnasyautofix-ci[bot]camc314
authored
feat(linter/plugins): scope manager API (#14890)
- "For now" implementation of #14827 - Uses `@typescript-eslint/scope-manager` which works thanks to recent efforts to align JS side AST with ESTree. - `@typescript-eslint/scope-manager` is well-built: It is factored out from `ts-eslint` neatly, and is pure scope analysis with minimal dependencies. Tsconfig resolution and syntax parsing is done by its dependents. ### Tasks - [x] `ScopeManager` class instance at `sourceCode.scopeManager` - [x] Direct usage APIs: `sourceCode.isGlobalReference`, `sourceCode.getDeclaredVariables`, `sourceCode.getScope`, ~~`sourceCode.markVariableAsUsed`~~ - [x] Tests - [x] Limit API surface. We want maneuverability for our eventual implementation, so we should limit the exposed methods to the only two methods documented as non-deprecated: [ScopeManager | ESLint](https://eslint.org/docs/latest/extend/scope-manager-interface#acquirenode-inner--false:~:text=ScopeManager%20interface-,Fields,getDeclaredVariables(node),-Deprecated%20members). - Limiting other interfaces will probably need monkey patching; only public types are limited instead. ### ScopeManager API real world use examples - [`eslint-plugin-import`](https://github.com/import-js/eslint-plugin-import/blob/01c9eb04331d2efa8d63f2d7f4bfec3bc44c94f3/src/rules/no-import-module-exports.js#L20) - [`eslint-plugin-react`](https://github.com/facebook/react/blob/71b3a03cc936c8eb30a6e6108abf5550f5037f71/packages/eslint-plugin-react-hooks/src/rules/ExhaustiveDeps.ts#L149) - [`eslint-plugin-vue`](https://github.com/vuejs/eslint-plugin-vue/blob/917787c46a268b1910b9c77e091656a428d70a89/lib/utils/scope.js#L14) - [`eslint-plugin-svelte`](https://github.com/search?q=repo%3Asveltejs%2Feslint-plugin-svelte%20scopeManager&type=code) --------- Signed-off-by: Arsh <[email protected]> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Cameron <[email protected]>
1 parent 5eaaa8e commit f7bef73

File tree

14 files changed

+668
-28
lines changed

14 files changed

+668
-28
lines changed

apps/oxlint/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,5 +59,9 @@
5959
"darwin-x64",
6060
"darwin-arm64"
6161
]
62+
},
63+
"dependencies": {
64+
"@typescript-eslint/scope-manager": "^8.46.2",
65+
"@typescript-eslint/types": "^8.46.2"
6266
}
6367
}

apps/oxlint/src-js/plugins/scope.ts

Lines changed: 129 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@
44

55
import type * as ESTree from '../generated/types.d.ts';
66

7-
import type { Node } from './types.ts';
7+
import {
8+
analyze,
9+
type AnalyzeOptions,
10+
type ScopeManager as TSESLintScopeManager,
11+
} from '@typescript-eslint/scope-manager';
12+
import { SOURCE_CODE } from './source_code.js';
813

914
type Identifier =
1015
| ESTree.IdentifierName
@@ -14,8 +19,65 @@ type Identifier =
1419
| ESTree.TSThisParameter
1520
| ESTree.TSIndexSignatureName;
1621

22+
/**
23+
* @see https://eslint.org/docs/latest/developer-guide/scope-manager-interface#scopemanager-interface
24+
*/
25+
// This is a wrapper class around the @typescript-eslint/scope-manager package.
26+
// We want to control what APIs are exposed to the user to limit breaking changes when we switch our implementation.
1727
export class ScopeManager {
18-
// TODO
28+
#scopeManager: TSESLintScopeManager;
29+
30+
constructor(ast: ESTree.Program) {
31+
const defaultOptions: AnalyzeOptions = {
32+
globalReturn: false,
33+
jsxFragmentName: null,
34+
jsxPragma: 'React',
35+
lib: ['esnext'],
36+
sourceType: ast.sourceType,
37+
};
38+
// The effectiveness of this assertion depends on our alignment with ESTree.
39+
// It could eventually be removed as we align the remaining corner cases and the typegen.
40+
// @ts-expect-error // TODO: our types don't quite align yet
41+
this.#scopeManager = analyze(ast, defaultOptions);
42+
}
43+
44+
/**
45+
* All scopes
46+
*/
47+
get scopes(): Scope[] {
48+
// @ts-expect-error // TODO: our types don't quite align yet
49+
return this.#scopeManager.scopes;
50+
}
51+
52+
/**
53+
* The root scope
54+
*/
55+
get globalScope(): Scope | null {
56+
return this.#scopeManager.globalScope as any;
57+
}
58+
59+
/**
60+
* Get the variables that a given AST node defines. The gotten variables' `def[].node`/`def[].parent` property is the node.
61+
* If the node does not define any variable, this returns an empty array.
62+
* @param node An AST node to get their variables.
63+
*/
64+
getDeclaredVariables(node: ESTree.Node): Variable[] {
65+
// @ts-expect-error // TODO: our types don't quite align yet
66+
return this.#scopeManager.getDeclaredVariables(node);
67+
}
68+
69+
/**
70+
* Get the scope of a given AST node. The gotten scope's `block` property is the node.
71+
* This method never returns `function-expression-name` scope. If the node does not have their scope, this returns `null`.
72+
*
73+
* @param node An AST node to get their scope.
74+
* @param inner If the node has multiple scopes, this returns the outermost scope normally.
75+
* If `inner` is `true` then this returns the innermost scope.
76+
*/
77+
acquire(node: ESTree.Node, inner?: boolean): Scope | null {
78+
// @ts-expect-error // TODO: our types don't quite align yet
79+
return this.#scopeManager.acquire(node, inner);
80+
}
1981
}
2082

2183
export interface Scope {
@@ -24,7 +86,7 @@ export interface Scope {
2486
upper: Scope | null;
2587
childScopes: Scope[];
2688
variableScope: Scope;
27-
block: Node;
89+
block: ESTree.Node;
2890
variables: Variable[];
2991
set: Map<string, Variable>;
3092
references: Reference[];
@@ -74,8 +136,8 @@ export interface Reference {
74136
export interface Definition {
75137
type: DefinitionType;
76138
name: Identifier;
77-
node: Node;
78-
parent: Node | null;
139+
node: ESTree.Node;
140+
parent: ESTree.Node | null;
79141
}
80142

81143
export type DefinitionType =
@@ -92,9 +154,43 @@ export type DefinitionType =
92154
* @param node - `Identifier` node to check.
93155
* @returns `true` if the identifier is a reference to a global variable.
94156
*/
95-
// oxlint-disable-next-line no-unused-vars
96-
export function isGlobalReference(node: Node): boolean {
97-
throw new Error('`sourceCode.isGlobalReference` not implemented yet'); // TODO
157+
export function isGlobalReference(node: ESTree.Node): boolean {
158+
// ref: https://github.com/eslint/eslint/blob/e7cda3bdf1bdd664e6033503a3315ad81736b200/lib/languages/js/source-code/source-code.js#L934-L962
159+
if (!node) {
160+
throw new TypeError('Missing required argument: node.');
161+
}
162+
163+
if (node.type !== 'Identifier') {
164+
return false;
165+
}
166+
167+
const { name } = node;
168+
if (typeof name !== 'string') {
169+
return false;
170+
}
171+
172+
const globalScope = SOURCE_CODE.scopeManager.scopes[0];
173+
if (!globalScope) return false;
174+
175+
// If the identifier is a reference to a global variable, the global scope should have a variable with the name.
176+
const variable = globalScope.set.get(name);
177+
178+
// Global variables are not defined by any node, so they should have no definitions.
179+
if (!variable || variable.defs.length > 0) {
180+
return false;
181+
}
182+
183+
// If there is a variable by the same name exists in the global scope, we need to check our node is one of its references.
184+
const { references } = variable;
185+
186+
for (let i = 0; i < references.length; i++) {
187+
const reference = references[i];
188+
if (reference.identifier === node) {
189+
return true;
190+
}
191+
}
192+
193+
return false;
98194
}
99195

100196
/**
@@ -103,28 +199,37 @@ export function isGlobalReference(node: Node): boolean {
103199
* @param node - The node for which the variables are obtained.
104200
* @returns An array of variable nodes representing the variables that `node` defines.
105201
*/
106-
// oxlint-disable-next-line no-unused-vars
107-
export function getDeclaredVariables(node: Node): Variable[] {
108-
throw new Error('`sourceCode.getDeclaredVariables` not implemented yet'); // TODO
202+
export function getDeclaredVariables(node: ESTree.Node): Variable[] {
203+
// ref: https://github.com/eslint/eslint/blob/e7cda3bdf1bdd664e6033503a3315ad81736b200/lib/languages/js/source-code/source-code.js#L904
204+
return SOURCE_CODE.scopeManager.getDeclaredVariables(node);
109205
}
110206

111207
/**
112208
* Get the scope for the given node
113209
* @param node - The node to get the scope of.
114210
* @returns The scope information for this node.
115211
*/
116-
// oxlint-disable-next-line no-unused-vars
117-
export function getScope(node: Node): Scope {
118-
throw new Error('`sourceCode.getScope` not implemented yet'); // TODO
119-
}
212+
export function getScope(node: ESTree.Node): Scope {
213+
// ref: https://github.com/eslint/eslint/blob/e7cda3bdf1bdd664e6033503a3315ad81736b200/lib/languages/js/source-code/source-code.js#L862-L892
214+
if (!node) {
215+
throw new TypeError('Missing required argument: node.');
216+
}
120217

121-
/**
122-
* Mark a variable as used in the current scope
123-
* @param name - The name of the variable to mark as used.
124-
* @param refNode? - The closest node to the variable reference.
125-
* @returns `true` if the variable was found and marked as used, `false` if not.
126-
*/
127-
// oxlint-disable-next-line no-unused-vars
128-
export function markVariableAsUsed(name: string, refNode: Node): boolean {
129-
throw new Error('`sourceCode.markVariableAsUsed` not implemented yet'); // TODO
218+
const { scopeManager } = SOURCE_CODE;
219+
const inner = node.type !== 'Program';
220+
221+
// Traverse up the AST to find a `Node` whose scope can be acquired.
222+
for (let current: any = node; current; current = current.parent) {
223+
const scope = scopeManager.acquire(current, inner);
224+
225+
if (scope) {
226+
if (scope.type === 'function-expression-name') {
227+
return scope.childScopes[0];
228+
}
229+
230+
return scope;
231+
}
232+
}
233+
234+
return scopeManager.scopes[0];
130235
}

apps/oxlint/src-js/plugins/source_code.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@ import {
1515
lines,
1616
resetLines,
1717
} from './location.js';
18+
import { ScopeManager } from './scope.js';
1819
import * as scopeMethods from './scope.js';
1920
import * as tokenMethods from './tokens.js';
2021

2122
import type { Program } from '../generated/types.d.ts';
22-
import type { ScopeManager } from './scope.ts';
2323
import type { BufferWithArrays, Node, NodeOrToken, Ranged } from './types.ts';
2424

2525
const { max } = Math;
@@ -81,6 +81,7 @@ export function resetSourceAndAst(): void {
8181
buffer = null;
8282
sourceText = null;
8383
ast = null;
84+
scopeManagerInstance = null;
8485
resetBuffer();
8586
resetLines();
8687
}
@@ -94,6 +95,10 @@ export function resetSourceAndAst(): void {
9495
// 2. No need for private properties, which are somewhat expensive to access - use top-level variables instead.
9596
//
9697
// Freeze the object to prevent user mutating it.
98+
99+
// ScopeManager instance for current file (reset between files)
100+
let scopeManagerInstance: ScopeManager | null = null;
101+
97102
export const SOURCE_CODE = Object.freeze({
98103
// Get source text.
99104
get text(): string {
@@ -114,7 +119,8 @@ export const SOURCE_CODE = Object.freeze({
114119

115120
// Get `ScopeManager` for the file.
116121
get scopeManager(): ScopeManager {
117-
throw new Error('`sourceCode.scopeManager` not implemented yet'); // TODO
122+
if (ast === null) initAst();
123+
return (scopeManagerInstance ??= new ScopeManager(ast));
118124
},
119125

120126
// Get visitor keys to traverse this AST.
@@ -216,7 +222,6 @@ export const SOURCE_CODE = Object.freeze({
216222
isGlobalReference: scopeMethods.isGlobalReference,
217223
getDeclaredVariables: scopeMethods.getDeclaredVariables,
218224
getScope: scopeMethods.getScope,
219-
markVariableAsUsed: scopeMethods.markVariableAsUsed,
220225

221226
// Token methods
222227
getTokens: tokenMethods.getTokens,

apps/oxlint/test/e2e.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,14 @@ describe('oxlint CLI', () => {
177177
await testFixture('sourceCode_late_access_after_only');
178178
});
179179

180+
it('should support scopeManager', async () => {
181+
await testFixture('scope_manager');
182+
});
183+
184+
it('should support scope helper methods in `context.sourceCode`', async () => {
185+
await testFixture('sourceCode_scope_methods');
186+
});
187+
180188
it('should support selectors', async () => {
181189
await testFixture('selector');
182190
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"jsPlugins": ["./plugin.ts"],
3+
"categories": {
4+
"correctness": "off"
5+
},
6+
"rules": {
7+
"scope-manager-plugin/scope": "error"
8+
}
9+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
const { a, b, c } = {};
2+
3+
let x = 1;
4+
var y = 'hello';
5+
z = 'world';
6+
7+
function topLevelFunction(param: number) {
8+
const localVar = param + x;
9+
{
10+
const deepestVar = y + localVar;
11+
return deepestVar;
12+
}
13+
return localVar;
14+
}
15+
16+
export module TopLevelModule {
17+
interface ConcreteInterface {
18+
concreteVar: number;
19+
}
20+
export interface GenericInterface<T> extends ConcreteInterface {
21+
genericVar: T;
22+
}
23+
export const x: GenericInterface<string> = {
24+
concreteVar: 42,
25+
genericVar: 'string',
26+
};
27+
}
28+
29+
const concreteValue: TopLevelModule.GenericInterface<string> = {
30+
concreteVar: TopLevelModule.x.concreteVar,
31+
genericVar: 'string',
32+
};
33+
34+
class TestClass {
35+
instanceVar: string;
36+
#privateVar: string;
37+
static {
38+
const privateVar = 'private';
39+
this.prototype.#privateVar = arrowFunc(privateVar);
40+
41+
const arrowFunc = (param: string) => {
42+
const arrowVar = param;
43+
return arrowVar + y;
44+
};
45+
}
46+
47+
constructor(x: string) {
48+
if (x) {
49+
this.instanceVar = x;
50+
}
51+
}
52+
}
53+
54+
label: {
55+
const blockVar = 'block';
56+
console.log(blockVar);
57+
}
58+
59+
const unusedVar = 'should be detected';

0 commit comments

Comments
 (0)