mirror of
https://github.com/oxc-project/oxc.git
synced 2026-09-14 19:36:11 +08:00
c95951f563
Fixes #19971. Implement `sourceCode.markVariableAsUsed`. It has some shortcomings at present - it does mark vars as used, so any other JS plugins can see that, but it doesn't communicate this info back to Rust side, so `no-unused-vars` doesn't act on it. But at least it doesn't cause a crash with "unimplemented" error. The remaining issue is tracked in #20350.
460 lines
16 KiB
TypeScript
460 lines
16 KiB
TypeScript
/*
|
|
* `SourceCode` methods related to scopes.
|
|
*/
|
|
|
|
import { analyze, Variable as TSVariable } from "@typescript-eslint/scope-manager";
|
|
import { ecmaFeaturesOverride } from "./context.ts";
|
|
import { ast, initAst } from "./source_code.ts";
|
|
import { globals, envs, initGlobals } from "./globals.ts";
|
|
import { ENVS } from "../generated/envs.ts";
|
|
import { debugAssert, debugAssertIsNonNull } from "../utils/asserts.ts";
|
|
|
|
import type {
|
|
AnalyzeOptions,
|
|
ScopeManager as TSESLintScopeManager,
|
|
Scope as TSScope,
|
|
} from "@typescript-eslint/scope-manager";
|
|
import type { Writable } from "type-fest";
|
|
import type * as ESTree from "../generated/types.d.ts";
|
|
|
|
export interface Scope {
|
|
type: ScopeType;
|
|
isStrict: boolean;
|
|
upper: Scope | null;
|
|
childScopes: Scope[];
|
|
variableScope: Scope;
|
|
block: ESTree.Node;
|
|
variables: Variable[];
|
|
set: Map<string, Variable>;
|
|
references: Reference[];
|
|
through: Reference[];
|
|
functionExpressionScope: boolean;
|
|
implicit?: {
|
|
variables: Variable[];
|
|
set: Map<string, Variable>;
|
|
};
|
|
}
|
|
|
|
export type ScopeType =
|
|
| "block"
|
|
| "catch"
|
|
| "class"
|
|
| "class-field-initializer"
|
|
| "class-static-block"
|
|
| "for"
|
|
| "function"
|
|
| "function-expression-name"
|
|
| "global"
|
|
| "module"
|
|
| "switch"
|
|
| "with";
|
|
|
|
export interface Variable {
|
|
name: string;
|
|
scope: Scope;
|
|
identifiers: Identifier[];
|
|
references: Reference[];
|
|
defs: Definition[];
|
|
}
|
|
|
|
export interface Reference {
|
|
identifier: Identifier;
|
|
from: Scope;
|
|
resolved: Variable | null;
|
|
writeExpr: ESTree.Expression | null;
|
|
init: boolean;
|
|
isWrite(): boolean;
|
|
isRead(): boolean;
|
|
isReadOnly(): boolean;
|
|
isWriteOnly(): boolean;
|
|
isReadWrite(): boolean;
|
|
}
|
|
|
|
export interface Definition {
|
|
type: DefinitionType;
|
|
name: Identifier;
|
|
node: ESTree.Node;
|
|
parent: ESTree.Node | null;
|
|
}
|
|
|
|
export type DefinitionType =
|
|
| "CatchClause"
|
|
| "ClassName"
|
|
| "FunctionName"
|
|
| "ImplicitGlobalVariable"
|
|
| "ImportBinding"
|
|
| "Parameter"
|
|
| "Variable";
|
|
|
|
type Identifier =
|
|
| ESTree.IdentifierName
|
|
| ESTree.IdentifierReference
|
|
| ESTree.BindingIdentifier
|
|
| ESTree.LabelIdentifier
|
|
| ESTree.TSThisParameter
|
|
| ESTree.TSIndexSignatureName;
|
|
|
|
// TS-ESLint `ScopeManager` for current file.
|
|
// Created lazily only when needed.
|
|
let tsScopeManager: TSESLintScopeManager | null = null;
|
|
|
|
// Options for TS-ESLint's `analyze` method.
|
|
// `sourceType` property is set before calling `analyze`.
|
|
const analyzeOptions: AnalyzeOptions = {
|
|
childVisitorKeys: undefined,
|
|
globalReturn: false,
|
|
impliedStrict: false,
|
|
jsxPragma: "React",
|
|
jsxFragmentName: null,
|
|
lib: [],
|
|
sourceType: "module",
|
|
emitDecoratorMetadata: false,
|
|
};
|
|
|
|
/**
|
|
* Initialize TS-ESLint `ScopeManager` for current file.
|
|
*/
|
|
function initTsScopeManager() {
|
|
if (ast === null) initAst();
|
|
debugAssertIsNonNull(ast);
|
|
|
|
const { sourceType } = ast;
|
|
analyzeOptions.sourceType = sourceType;
|
|
analyzeOptions.globalReturn = sourceType === "commonjs";
|
|
analyzeOptions.impliedStrict = sourceType === "module";
|
|
|
|
// Override `globalReturn` and `impliedStrict` if in conformance build
|
|
if (CONFORMANCE) {
|
|
const { globalReturn, impliedStrict } = ecmaFeaturesOverride;
|
|
if (globalReturn !== null) analyzeOptions.globalReturn = globalReturn;
|
|
if (impliedStrict !== null) analyzeOptions.impliedStrict = impliedStrict;
|
|
}
|
|
|
|
// @ts-expect-error - TODO: Our types don't quite align yet
|
|
tsScopeManager = analyze(ast, analyzeOptions);
|
|
|
|
// Add globals from configuration and resolve references
|
|
addGlobals();
|
|
}
|
|
|
|
/**
|
|
* Add global variables from configuration and resolve references to them.
|
|
*
|
|
* With `lib: []`, no lib globals are created during analysis, so all global references
|
|
* end up in `globalScope.through`. This function creates Variables for each configured
|
|
* global and resolves references from `through`.
|
|
*
|
|
* This replicates ESLint's `scopeManager.addGlobals()` behavior.
|
|
*/
|
|
function addGlobals(): void {
|
|
debugAssertIsNonNull(tsScopeManager);
|
|
const globalScope = tsScopeManager.scopes[0];
|
|
|
|
// Ensure globals are initialized
|
|
if (globals === null) initGlobals();
|
|
debugAssertIsNonNull(globals);
|
|
debugAssertIsNonNull(envs);
|
|
|
|
// Create variables for enabled `envs`.
|
|
// All properties of `envs` are `true`, so no need to check the values.
|
|
// `envs` from JSON, so we can use simple `for..in` loop.
|
|
for (const envName in envs) {
|
|
// Get vars defined for this env.
|
|
// Rust side code ignores invalid env names, and passes them through to JS,
|
|
// so we can't assume they're valid here. But debug assert they're valid for tests.
|
|
const preset = ENVS.get(envName);
|
|
debugAssertIsNonNull(preset, `Unknown env: ${envName}`);
|
|
if (preset === undefined) continue;
|
|
|
|
const { readonly, writable } = preset;
|
|
|
|
for (let i = 0, len = readonly.length; i < len; i++) {
|
|
const varName = readonly[i];
|
|
// Skip vars that are defined in `globals`. They might be `"off"`.
|
|
if (!Object.hasOwn(globals, varName)) createGlobalVariable(varName, globalScope, false);
|
|
}
|
|
|
|
for (let i = 0, len = writable.length; i < len; i++) {
|
|
const varName = writable[i];
|
|
// Skip vars that are defined in `globals`. They might be `"off"`.
|
|
if (!Object.hasOwn(globals, varName)) createGlobalVariable(varName, globalScope, true);
|
|
}
|
|
}
|
|
|
|
// Create variables for enabled `globals`.
|
|
// `globals` from JSON, so we can use simple `for..in` loop.
|
|
for (const name in globals) {
|
|
const value = globals[name];
|
|
if (value !== "off") createGlobalVariable(name, globalScope, value === "writable");
|
|
}
|
|
|
|
// Resolve references from `through`
|
|
(globalScope as Writable<typeof globalScope>).through = globalScope.through.filter((ref) => {
|
|
const { name } = ref.identifier;
|
|
const variable = globalScope.set.get(name);
|
|
if (!variable) return true; // Keep in `through` (truly undefined)
|
|
|
|
// Resolve the reference, remove from `through`
|
|
ref.resolved = variable;
|
|
variable.references.push(ref);
|
|
return false;
|
|
});
|
|
|
|
// Clean up implicit globals.
|
|
// "implicit" contains information about implicit global variables (those created
|
|
// implicitly by assigning values to undeclared variables in non-strict code).
|
|
// Since we augment the global scope, we need to remove the ones that match declared globals.
|
|
// This matches eslint-scope's `__addVariables` behavior.
|
|
// @ts-expect-error - `implicit` is private but accessible at runtime
|
|
const { implicit } = globalScope;
|
|
implicit.variables = implicit.variables.filter((variable: Variable) => {
|
|
const { name } = variable;
|
|
if (globalScope.set.has(name)) {
|
|
implicit.set.delete(name);
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
// typescript-eslint uses `leftToBeResolved`, eslint-scope uses `left`
|
|
implicit.leftToBeResolved = implicit.leftToBeResolved.filter(
|
|
(ref: Reference) => !globalScope.set.has(ref.identifier.name),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Create a global variable with the given name and add it to the global scope.
|
|
* @param name - Var name
|
|
* @param globalScope - Global scope object
|
|
* @param isWritable - `true` if the variable is writable, `false` otherwise
|
|
*/
|
|
function createGlobalVariable(name: string, globalScope: TSScope, isWritable: boolean): void {
|
|
// If var already exists in the scope (from code declarations or previous envs), don't create a new one.
|
|
// TS-ESLint's scope manager doesn't resolve references in the global scope for `sourceType: "script"`,
|
|
// so we mustn't overwrite local `var` declarations with globals of the same name.
|
|
// But set properties on the existing variable to indicate it's a configured global.
|
|
let variable = globalScope.set.get(name);
|
|
if (variable === undefined) {
|
|
variable = new TSVariable(name, globalScope);
|
|
globalScope.set.set(name, variable);
|
|
globalScope.variables.push(variable);
|
|
|
|
// All globals are type + value
|
|
debugAssert(variable.isTypeVariable, "variable should have `isTypeVariable` set by default");
|
|
debugAssert(variable.isValueVariable, "variable should have `isValueVariable` set by default");
|
|
}
|
|
|
|
// @ts-expect-error - not present in types
|
|
variable.writeable = isWritable;
|
|
// @ts-expect-error - not present in types
|
|
variable.eslintImplicitGlobalSetting = isWritable ? "writable" : "readonly";
|
|
// @ts-expect-error - not present in types
|
|
variable.eslintExplicitGlobal = false;
|
|
// @ts-expect-error - not present in types
|
|
variable.eslintExplicitGlobalComments = undefined;
|
|
}
|
|
|
|
/**
|
|
* Discard TS-ESLint `ScopeManager`, to free memory.
|
|
*/
|
|
export function resetScopeManager() {
|
|
tsScopeManager = null;
|
|
}
|
|
|
|
// Wrapper around `@typescript-eslint/scope-manager` package's `ScopeManager` class.
|
|
//
|
|
// We only expose the methods and properties which ESLint lists as part of the `ScopeManager` interface:
|
|
// https://eslint.org/docs/latest/extend/scope-manager-interface#scopemanager-interface
|
|
// This does NOT include deprecated methods and properties.
|
|
// In future, we'll re-implement the scope manager using scopes data from `oxc_semantic`, and remove the dependency
|
|
// on `@typescript-eslint/scope-manager`. That will be easier with a more minimal API surface.
|
|
// If we find real-world usage of deprecated methods/properties in ESLint plugins, we can add them to the API surface.
|
|
//
|
|
// Only one file is linted at a time, so we can reuse a single object for all files.
|
|
//
|
|
// This has advantages:
|
|
// 1. Reduce object creation.
|
|
// 2. Property accesses don't need to go up prototype chain, as they would for instances of a class.
|
|
// 3. No need for private properties, which are somewhat expensive to access - use top-level variables instead.
|
|
//
|
|
// Freeze the object to prevent user mutating it.
|
|
export const SCOPE_MANAGER = Object.freeze({
|
|
/**
|
|
* All scopes.
|
|
*/
|
|
get scopes(): Scope[] {
|
|
if (tsScopeManager === null) initTsScopeManager();
|
|
// @ts-expect-error - TODO: Our types don't quite align yet
|
|
return tsScopeManager.scopes;
|
|
},
|
|
|
|
/**
|
|
* The root scope.
|
|
*/
|
|
get globalScope(): Scope | null {
|
|
if (tsScopeManager === null) initTsScopeManager();
|
|
// @ts-expect-error - TODO: Our types don't quite align yet
|
|
return tsScopeManager.globalScope;
|
|
},
|
|
|
|
/**
|
|
* Get the variables that a given AST node defines.
|
|
* The returned variables' `def[].node` / `def[].parent` property is the node.
|
|
* If the node does not define any variable, this returns an empty array.
|
|
* @param node AST node to get variables of.
|
|
*/
|
|
getDeclaredVariables(node: ESTree.Node): Variable[] {
|
|
if (tsScopeManager === null) initTsScopeManager();
|
|
// @ts-expect-error - TODO: Our types don't quite align yet
|
|
return tsScopeManager.getDeclaredVariables(node);
|
|
},
|
|
|
|
/**
|
|
* Get the scope of a given AST node. The returned scope's `block` property is the node.
|
|
* This method never returns `function-expression-name` scope.
|
|
* If the node does not have a scope, returns `null`.
|
|
*
|
|
* @param node An AST node to get their scope.
|
|
* @param inner If the node has multiple scopes, this returns the outermost scope normally.
|
|
* If `inner` is `true` then this returns the innermost scope.
|
|
*/
|
|
acquire(node: ESTree.Node, inner?: boolean): Scope | null {
|
|
if (tsScopeManager === null) initTsScopeManager();
|
|
// @ts-expect-error - TODO: Our types don't quite align yet
|
|
return tsScopeManager.acquire(node, inner);
|
|
},
|
|
});
|
|
|
|
export type ScopeManager = typeof SCOPE_MANAGER;
|
|
|
|
/**
|
|
* Determine whether the given identifier node is a reference to a global variable.
|
|
* @param node - `Identifier` node to check.
|
|
* @returns `true` if the identifier is a reference to a global variable.
|
|
*/
|
|
export function isGlobalReference(node: ESTree.Node): boolean {
|
|
// ref: https://github.com/eslint/eslint/blob/e7cda3bdf1bdd664e6033503a3315ad81736b200/lib/languages/js/source-code/source-code.js#L934-L962
|
|
if (!node) throw new TypeError("Missing required argument: `node`");
|
|
if (node.type !== "Identifier") return false;
|
|
|
|
if (tsScopeManager === null) initTsScopeManager();
|
|
debugAssertIsNonNull(tsScopeManager);
|
|
|
|
const { scopes } = tsScopeManager;
|
|
if (scopes.length === 0) return false;
|
|
const globalScope = scopes[0];
|
|
|
|
// If the identifier is a reference to a global variable, the global scope should have a variable with the name
|
|
const variable = globalScope.set.get(node.name);
|
|
|
|
// Global variables are not defined by any node, so they should have no definitions
|
|
if (variable === undefined || variable.defs.length > 0) return false;
|
|
|
|
// 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
|
|
const { references } = variable;
|
|
for (let i = 0, len = references.length; i < len; i++) {
|
|
if (references[i].identifier === node) return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Get the variables that `node` defines.
|
|
* This is a convenience method that passes through to the same method on the `ScopeManager`.
|
|
* @param node - The node for which the variables are obtained.
|
|
* @returns An array of variable nodes representing the variables that `node` defines.
|
|
*/
|
|
export function getDeclaredVariables(node: ESTree.Node): Variable[] {
|
|
// ref: https://github.com/eslint/eslint/blob/e7cda3bdf1bdd664e6033503a3315ad81736b200/lib/languages/js/source-code/source-code.js#L904
|
|
if (tsScopeManager === null) initTsScopeManager();
|
|
debugAssertIsNonNull(tsScopeManager);
|
|
|
|
// @ts-expect-error - TODO: Our types don't quite align yet
|
|
return tsScopeManager.getDeclaredVariables(node);
|
|
}
|
|
|
|
/**
|
|
* Get the scope for the given node.
|
|
* @param node - The node to get the scope of.
|
|
* @returns The scope information for this node.
|
|
*/
|
|
export function getScope(node: ESTree.Node): Scope {
|
|
// ref: https://github.com/eslint/eslint/blob/e7cda3bdf1bdd664e6033503a3315ad81736b200/lib/languages/js/source-code/source-code.js#L862-L892
|
|
if (!node) throw new TypeError("Missing required argument: `node`");
|
|
|
|
if (tsScopeManager === null) initTsScopeManager();
|
|
debugAssertIsNonNull(tsScopeManager);
|
|
|
|
const inner = node.type !== "Program";
|
|
|
|
// Traverse up the AST to find a `Node` whose scope can be acquired.
|
|
do {
|
|
// @ts-expect-error - TODO: Our types don't quite align yet
|
|
const scope = tsScopeManager.acquire(node, inner) as Scope;
|
|
if (scope !== null) {
|
|
return scope.type === "function-expression-name" ? scope.childScopes[0] : scope;
|
|
}
|
|
|
|
// @ts-expect-error - Don't want to create a new variable just to make it nullable
|
|
node = node.parent;
|
|
} while (node !== null);
|
|
|
|
// TODO: Is it possible to get here? Doesn't `Program` always have a scope?
|
|
// @ts-expect-error - TODO: Our types don't quite align yet
|
|
return tsScopeManager.scopes[0];
|
|
}
|
|
|
|
/**
|
|
* Marks as used a variable with the given name in a scope indicated by the given reference node.
|
|
*
|
|
* IMPORTANT: At present marking variables as used only affects other JS plugins.
|
|
* It does *not* get communicated to Oxlint's rules which are implemented on Rust side e.g. `no-unused-vars`.
|
|
* This is a known shortcoming, and will be addressed in a future release.
|
|
* https://github.com/oxc-project/oxc/issues/20350
|
|
*
|
|
* @param name - Variable name
|
|
* @param refNode - Reference node. Defaults to `Program` node if not provided.
|
|
* @returns `true` if a variable with the given name was found and marked as used, otherwise `false`
|
|
*/
|
|
export function markVariableAsUsed(name: string, refNode?: ESTree.Node): boolean {
|
|
// ref: https://github.com/eslint/eslint/blob/e7cda3bdf1bdd664e6033503a3315ad81736b200/lib/languages/js/source-code/source-code.js#L984-L1024
|
|
if (refNode === undefined) {
|
|
if (ast === null) initAst();
|
|
debugAssertIsNonNull(ast);
|
|
refNode = ast;
|
|
}
|
|
|
|
let currentScope = getScope(refNode);
|
|
|
|
// `getScope` calls `initTsScopeManager` which calls `initAst`, so `ast` must have been initialized
|
|
debugAssertIsNonNull(ast);
|
|
|
|
// When in the global scope, check if there's a module/function child scope whose `block` is the Program node.
|
|
// In ESM, top-level `var` declarations live in the module scope, not the global scope.
|
|
// In CommonJS, they live in the outer function scope. If we don't step down into that child scope,
|
|
// we'd miss those variables.
|
|
if (currentScope.type === "global") {
|
|
const { childScopes } = currentScope;
|
|
if (childScopes.length !== 0) {
|
|
// Top-level scopes refer to a `Program` node
|
|
const firstChild = childScopes[0];
|
|
if (firstChild.block === ast) currentScope = firstChild;
|
|
}
|
|
}
|
|
|
|
for (let scope: Scope | null = currentScope; scope !== null; scope = scope.upper) {
|
|
const { variables } = scope;
|
|
for (let i = 0, len = variables.length; i < len; i++) {
|
|
const variable = variables[i];
|
|
if (variable.name === name) {
|
|
// @ts-expect-error - `eslintUsed` is a dynamic property not in TS-ESLint's types
|
|
variable.eslintUsed = true;
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|