Skip to content

Commit 75a6a13

Browse files
committed
perf(linter/plugins): use singleton object for ScopeManager
1 parent b5d6360 commit 75a6a13

File tree

3 files changed

+88
-44
lines changed

3 files changed

+88
-44
lines changed

.serena/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/cache

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

Lines changed: 83 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ import {
99
type AnalyzeOptions,
1010
type ScopeManager as TSESLintScopeManager,
1111
} from '@typescript-eslint/scope-manager';
12-
import { SOURCE_CODE } from './source_code.js';
12+
import { ast, initAst } from './source_code.js';
13+
import { assertIs } from './utils.js';
1314

1415
type Identifier =
1516
| ESTree.IdentifierName
@@ -19,52 +20,88 @@ type Identifier =
1920
| ESTree.TSThisParameter
2021
| ESTree.TSIndexSignatureName;
2122

23+
// TS-ESLint `ScopeManager` for current file.
24+
// Created lazily only when needed.
25+
let tsScopeManager: TSESLintScopeManager | null = null;
26+
27+
// Options for TS-ESLint's `analyze` method.
28+
// `sourceType` property is set before calling `analyze`.
29+
interface AnalyzeOptionsWithNullableSourceType extends Omit<AnalyzeOptions, 'sourceType'> {
30+
sourceType: AnalyzeOptions['sourceType'] | number;
31+
}
32+
33+
const analyzeOptions: AnalyzeOptionsWithNullableSourceType = {
34+
globalReturn: false,
35+
jsxFragmentName: null,
36+
jsxPragma: 'React',
37+
lib: ['esnext'],
38+
sourceType: null,
39+
};
40+
41+
/**
42+
* Initialize TS-ESLint `ScopeManager` for current file.
43+
*/
44+
function initTsScopeManager() {
45+
if (ast === null) initAst();
46+
47+
analyzeOptions.sourceType = ast.sourceType;
48+
assertIs<AnalyzeOptions>(analyzeOptions);
49+
// The effectiveness of this assertion depends on our alignment with ESTree.
50+
// It could eventually be removed as we align the remaining corner cases and the typegen.
51+
// @ts-expect-error // TODO: Our types don't quite align yet
52+
tsScopeManager = analyze(ast, analyzeOptions);
53+
}
54+
55+
/**
56+
* Discard TS-ESLint `ScopeManager`, to free memory.
57+
*/
58+
export function resetScopeManager() {
59+
tsScopeManager = null;
60+
}
61+
2262
/**
2363
* @see https://eslint.org/docs/latest/developer-guide/scope-manager-interface#scopemanager-interface
2464
*/
25-
// This is a wrapper class around the @typescript-eslint/scope-manager package.
65+
// This is a wrapper around `@typescript-eslint/scope-manager` package's `ScopeManager` class.
2666
// We want to control what APIs are exposed to the user to limit breaking changes when we switch our implementation.
27-
export class ScopeManager {
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-
67+
//
68+
// Only one file is linted at a time, so we can reuse a single object for all files.
69+
//
70+
// This has advantages:
71+
// 1. Reduce object creation.
72+
// 2. Property accesses don't need to go up prototype chain, as they would for instances of a class.
73+
// 3. No need for private properties, which are somewhat expensive to access - use top-level variables instead.
74+
//
75+
// Freeze the object to prevent user mutating it.
76+
export const SCOPE_MANAGER = Object.freeze({
4477
/**
4578
* All scopes
4679
*/
4780
get scopes(): Scope[] {
48-
// @ts-expect-error // TODO: our types don't quite align yet
49-
return this.#scopeManager.scopes;
50-
}
81+
if (tsScopeManager === null) initTsScopeManager();
82+
// @ts-expect-error // TODO: Our types don't quite align yet
83+
return tsScopeManager.scopes;
84+
},
5185

5286
/**
5387
* The root scope
5488
*/
5589
get globalScope(): Scope | null {
56-
return this.#scopeManager.globalScope as any;
57-
}
90+
if (tsScopeManager === null) initTsScopeManager();
91+
// @ts-expect-error // TODO: Our types don't quite align yet
92+
return tsScopeManager.globalScope;
93+
},
5894

5995
/**
6096
* Get the variables that a given AST node defines. The gotten variables' `def[].node`/`def[].parent` property is the node.
6197
* If the node does not define any variable, this returns an empty array.
6298
* @param node An AST node to get their variables.
6399
*/
64100
getDeclaredVariables(node: ESTree.Node): Variable[] {
65-
// @ts-expect-error // TODO: our types don't quite align yet
66-
return this.#scopeManager.getDeclaredVariables(node);
67-
}
101+
if (tsScopeManager === null) initTsScopeManager();
102+
// @ts-expect-error // TODO: Our types don't quite align yet
103+
return tsScopeManager.getDeclaredVariables(node);
104+
},
68105

69106
/**
70107
* Get the scope of a given AST node. The gotten scope's `block` property is the node.
@@ -75,10 +112,13 @@ export class ScopeManager {
75112
* If `inner` is `true` then this returns the innermost scope.
76113
*/
77114
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-
}
81-
}
115+
if (tsScopeManager === null) initTsScopeManager();
116+
// @ts-expect-error // TODO: Our types don't quite align yet
117+
return tsScopeManager.acquire(node, inner);
118+
},
119+
});
120+
121+
export type ScopeManager = typeof SCOPE_MANAGER;
82122

83123
export interface Scope {
84124
type: ScopeType;
@@ -169,7 +209,8 @@ export function isGlobalReference(node: ESTree.Node): boolean {
169209
return false;
170210
}
171211

172-
const globalScope = SOURCE_CODE.scopeManager.scopes[0];
212+
if (tsScopeManager === null) initTsScopeManager();
213+
const globalScope = tsScopeManager.scopes[0];
173214
if (!globalScope) return false;
174215

175216
// If the identifier is a reference to a global variable, the global scope should have a variable with the name.
@@ -201,7 +242,9 @@ export function isGlobalReference(node: ESTree.Node): boolean {
201242
*/
202243
export function getDeclaredVariables(node: ESTree.Node): Variable[] {
203244
// ref: https://github.com/eslint/eslint/blob/e7cda3bdf1bdd664e6033503a3315ad81736b200/lib/languages/js/source-code/source-code.js#L904
204-
return SOURCE_CODE.scopeManager.getDeclaredVariables(node);
245+
if (tsScopeManager === null) initTsScopeManager();
246+
// @ts-expect-error // TODO: Our types don't quite align yet
247+
return tsScopeManager.getDeclaredVariables(node);
205248
}
206249

207250
/**
@@ -215,21 +258,25 @@ export function getScope(node: ESTree.Node): Scope {
215258
throw new TypeError('Missing required argument: node.');
216259
}
217260

218-
const { scopeManager } = SOURCE_CODE;
261+
if (tsScopeManager === null) initTsScopeManager();
262+
219263
const inner = node.type !== 'Program';
220264

221265
// Traverse up the AST to find a `Node` whose scope can be acquired.
222266
for (let current: any = node; current; current = current.parent) {
223-
const scope = scopeManager.acquire(current, inner);
267+
const scope = tsScopeManager.acquire(current, inner);
224268

225269
if (scope) {
226270
if (scope.type === 'function-expression-name') {
271+
// @ts-expect-error // TODO: Our types don't quite align yet
227272
return scope.childScopes[0];
228273
}
229274

275+
// @ts-expect-error // TODO: Our types don't quite align yet
230276
return scope;
231277
}
232278
}
233279

234-
return scopeManager.scopes[0];
280+
// @ts-expect-error // TODO: Our types don't quite align yet
281+
return tsScopeManager.scopes[0];
235282
}

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

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,13 @@ import {
1515
lines,
1616
resetLines,
1717
} from './location.js';
18-
import { ScopeManager } from './scope.js';
18+
import { resetScopeManager, SCOPE_MANAGER } from './scope.js';
1919
import * as scopeMethods from './scope.js';
2020
import * as tokenMethods from './tokens.js';
2121

2222
import type { Program } from '../generated/types.d.ts';
2323
import type { BufferWithArrays, Node, NodeOrToken, Ranged } from './types.ts';
24+
import type { ScopeManager } from './scope.ts';
2425

2526
const { max } = Math;
2627

@@ -81,9 +82,9 @@ export function resetSourceAndAst(): void {
8182
buffer = null;
8283
sourceText = null;
8384
ast = null;
84-
scopeManagerInstance = null;
8585
resetBuffer();
8686
resetLines();
87+
resetScopeManager();
8788
}
8889

8990
// `SourceCode` object.
@@ -96,10 +97,6 @@ export function resetSourceAndAst(): void {
9697
// 3. No need for private properties, which are somewhat expensive to access - use top-level variables instead.
9798
//
9899
// Freeze the object to prevent user mutating it.
99-
100-
// ScopeManager instance for current file (reset between files)
101-
let scopeManagerInstance: ScopeManager | null = null;
102-
103100
export const SOURCE_CODE = Object.freeze({
104101
// Get source text.
105102
get text(): string {
@@ -120,8 +117,7 @@ export const SOURCE_CODE = Object.freeze({
120117

121118
// Get `ScopeManager` for the file.
122119
get scopeManager(): ScopeManager {
123-
if (ast === null) initAst();
124-
return (scopeManagerInstance ??= new ScopeManager(ast));
120+
return SCOPE_MANAGER;
125121
},
126122

127123
// Get visitor keys to traverse this AST.

0 commit comments

Comments
 (0)