mirror of
https://github.com/oxc-project/oxc.git
synced 2026-09-14 19:36:11 +08:00
d9a1b327d4
Small optimization to CFG visitor. Skip array lookups unless the result is actually used.
318 lines
11 KiB
TypeScript
318 lines
11 KiB
TypeScript
/*
|
|
* CFG (control flow graph) aka Code Path Analysis (what ESLint calls it).
|
|
*
|
|
* Function to construct CFG, and walk AST and call visitors for CFG events.
|
|
*/
|
|
|
|
// @ts-expect-error - internal module of ESLint with no types
|
|
import CodePathAnalyzer from "../../node_modules/eslint/lib/linter/code-path-analysis/code-path-analyzer.js";
|
|
|
|
import visitorKeys from "../generated/keys.ts";
|
|
import {
|
|
LEAF_NODE_TYPES_COUNT,
|
|
NODE_TYPE_IDS_MAP,
|
|
NODE_TYPES_COUNT,
|
|
TYPE_IDS_COUNT,
|
|
} from "../generated/type_ids.ts";
|
|
import { ancestors } from "../generated/walk.js";
|
|
import { debugAssert, debugAssertIsFunction } from "../utils/asserts.ts";
|
|
|
|
import type { CfgVisitFn, EnterExit, VisitFn } from "./visitor.ts";
|
|
import type { Node, Program } from "../generated/types.d.ts";
|
|
|
|
/**
|
|
* Offset added to type IDs for exit visits to distinguish them from enter visits.
|
|
* Using 256 as it's a power of 2 and larger than the maximum type ID (171).
|
|
*
|
|
* Type ID encoding:
|
|
* - Enter visit (nodes): 0 to NODE_TYPES_COUNT - 1 (0-164)
|
|
* - Call method (CFG events): NODE_TYPES_COUNT to TYPE_IDS_COUNT - 1 (165-171)
|
|
* - Exit visit (non-leaf nodes): Node type ID + EXIT_TYPE_ID_OFFSET (256+)
|
|
*/
|
|
const EXIT_TYPE_ID_OFFSET = 256;
|
|
|
|
debugAssert(
|
|
EXIT_TYPE_ID_OFFSET >= TYPE_IDS_COUNT,
|
|
"`EXIT_TYPE_ID_OFFSET` must be >= `TYPE_IDS_COUNT`",
|
|
);
|
|
debugAssert(isPowerOfTwo(EXIT_TYPE_ID_OFFSET), "`EXIT_TYPE_ID_OFFSET` must be a power of 2");
|
|
|
|
// Struct of Arrays (SoA) pattern for step storage.
|
|
// Using 2 arrays instead of an array of objects reduces object creation.
|
|
|
|
/**
|
|
* Encoded type IDs for each step.
|
|
* - For enter visits: Node type ID (0-164)
|
|
* - For CFG events: Event type ID (165-171)
|
|
* - For exit visits: Node type ID + `EXIT_TYPE_ID_OFFSET` (256+)
|
|
*/
|
|
const stepTypeIds: number[] = [];
|
|
|
|
/**
|
|
* Step data for each step.
|
|
* - For visit steps (enter/exit): AST node
|
|
* - For call steps (CFG events): Array of arguments to call CFG event handler with
|
|
*/
|
|
const stepData: (Node | unknown[])[] = [];
|
|
|
|
/**
|
|
* Reset state for walking AST with CFG.
|
|
*
|
|
* If walking AST completes without error, `walkProgramWithCfg` will reset the state itself.
|
|
* So it's only necessary to call this function if an error occurs during AST walking.
|
|
*/
|
|
export function resetCfgWalk(): void {
|
|
stepTypeIds.length = 0;
|
|
stepData.length = 0;
|
|
}
|
|
|
|
/**
|
|
* Walk AST with CFG (control flow graph) events.
|
|
*
|
|
* Use this function to walk AST instead of `walkProgram`, when visitor listens for CFG events.
|
|
*
|
|
* It's much slower than `walkProgram`, so prefer `walkProgram` unless visitor includes handlers for CFG events.
|
|
*
|
|
* It walks the whole AST twice:
|
|
*
|
|
* 1. First time to build the CFG graph.
|
|
* In this first pass, it builds a list of steps to walk AST (including visiting nodes and CFG events).
|
|
* This list is stored in the SoA arrays (stepTypeIds, stepData).
|
|
*
|
|
* 2. Visit AST with provided visitor.
|
|
* Run through the steps, in order, calling visit functions for each step.
|
|
*
|
|
* TODO: This is was originally copied from ESLint, and has been adapted for better performance.
|
|
* We could further improve its performance by copying ESLint's `CodePathAnalyzer` into this repo,
|
|
* and rewriting it to work entirely with type IDs instead of strings.
|
|
*
|
|
* @param ast - AST
|
|
* @param visitors - Visitors array
|
|
*/
|
|
export function walkProgramWithCfg(
|
|
ast: Program,
|
|
visitors: (VisitFn | EnterExit | CfgVisitFn | null)[],
|
|
): void {
|
|
// Get the steps that need to be run to walk the AST
|
|
prepareSteps(ast);
|
|
|
|
// Walk the AST
|
|
const stepsLen = stepTypeIds.length;
|
|
debugAssert(stepsLen > 0, "`stepTypeIds` should not be empty");
|
|
|
|
for (let i = 0; i < stepsLen; i++) {
|
|
let typeId = stepTypeIds[i];
|
|
|
|
if (typeId < NODE_TYPES_COUNT) {
|
|
// Enter node. `typeId` is node type ID.
|
|
const visit = visitors[typeId];
|
|
|
|
if (typeId < LEAF_NODE_TYPES_COUNT) {
|
|
// Leaf node
|
|
if (visit !== null) {
|
|
debugAssertIsFunction(visit);
|
|
visit(stepData[i] as Node);
|
|
}
|
|
// Don't add node to `ancestors`, because we don't visit leaf nodes on exit
|
|
} else {
|
|
// Non-leaf node
|
|
const node = stepData[i] as Node;
|
|
if (visit !== null) {
|
|
debugAssertIsEnterExitObject(visit);
|
|
const { enter } = visit;
|
|
if (enter !== null) enter(node);
|
|
}
|
|
|
|
ancestors.unshift(node);
|
|
}
|
|
} else if (typeId >= EXIT_TYPE_ID_OFFSET) {
|
|
// Exit non-leaf node. `typeId` is node type ID + `EXIT_TYPE_ID_OFFSET`.
|
|
//
|
|
// `EXIT_TYPE_ID_OFFSET` is a power of 2, and larger than max `typeId`, so we could use bitwise AND instead of
|
|
// subtraction here. But `typeId >= EXIT_TYPE_ID_OFFSET` check above may be compiled as a subtraction
|
|
// (`cmp` is essentially subtraction), in which case `- EXIT_TYPE_ID_OFFSET` would be cheaper than
|
|
// `& EXIT_TYPE_ID_OFFSET - 1`. That's just speculation though! Have not checked what ASM TurboFan generates.
|
|
typeId -= EXIT_TYPE_ID_OFFSET;
|
|
|
|
ancestors.shift();
|
|
|
|
const enterExit = visitors[typeId];
|
|
if (enterExit !== null) {
|
|
debugAssertIsEnterExitObject(enterExit);
|
|
const { exit } = enterExit;
|
|
if (exit !== null) exit(stepData[i] as Node);
|
|
}
|
|
} else {
|
|
// Call method (CFG event). `typeId` is event type ID.
|
|
debugAssert(Array.isArray(stepData[i]), "`stepData` should contain an array for CFG events");
|
|
|
|
const visit = visitors[typeId] as CfgVisitFn;
|
|
if (visit !== null) {
|
|
debugAssertIsFunction(visit);
|
|
visit.apply(undefined, stepData[i] as unknown[]);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Reset SoA arrays
|
|
stepTypeIds.length = 0;
|
|
stepData.length = 0;
|
|
}
|
|
|
|
/**
|
|
* Walk AST and put a list of all steps to walk AST into the SoA arrays.
|
|
* @param ast - AST
|
|
*/
|
|
function prepareSteps(ast: Program) {
|
|
debugAssert(stepTypeIds.length === 0, "`stepTypeIds` should be empty at start of `prepareSteps`");
|
|
debugAssert(stepData.length === 0, "`stepData` should be empty at start of `prepareSteps`");
|
|
|
|
// Length of arrays after entering each node.
|
|
// Used in debug build to check that no leaf nodes emit CFG events (see below).
|
|
// Minifier removes this var in release build.
|
|
let stepsLenAfterEnter = 0;
|
|
|
|
// Create `CodePathAnalyzer`.
|
|
// It stores steps to walk AST using the SoA (Struct of Arrays) pattern.
|
|
//
|
|
// Type ID encoding:
|
|
// - Enter visits: Node type ID directly (0-164 for node types)
|
|
// - CFG events: Node type ID directly (165-171 for event types)
|
|
// - Exit visits: Event type ID + `EXIT_TYPE_ID_OFFSET` (256+)
|
|
//
|
|
// This allows us to:
|
|
// 1. Avoid repeated `NODE_TYPE_IDS_MAP` hash map lookups during step execution.
|
|
// 2. Reduce object creation by using 2 flat arrays instead of step objects.
|
|
const analyzer = new CodePathAnalyzer({
|
|
enterNode(node: Node) {
|
|
const typeId = NODE_TYPE_IDS_MAP.get(node.type)!;
|
|
stepTypeIds.push(typeId);
|
|
stepData.push(node);
|
|
|
|
if (DEBUG) stepsLenAfterEnter = stepTypeIds.length;
|
|
},
|
|
|
|
leaveNode(node: Node) {
|
|
const typeId = NODE_TYPE_IDS_MAP.get(node.type)!;
|
|
|
|
if (typeId >= LEAF_NODE_TYPES_COUNT) {
|
|
// Non-leaf node - add exit step with offset.
|
|
// `EXIT_TYPE_ID_OFFSET` is a power of 2, larger than max `typeId`, so can use bitwise OR instead of addition.
|
|
stepTypeIds.push(typeId | EXIT_TYPE_ID_OFFSET);
|
|
stepData.push(node);
|
|
} else {
|
|
// Leaf node.
|
|
// Don't add a step.
|
|
|
|
// In debug build, check that no CFG events were emitted between entering and exiting this leaf node.
|
|
// If they were, it would break our assumptions.
|
|
// We combine enter and exit visit functions for leaf nodes into a single function which runs on entering node.
|
|
// That's fine if there are no CFG events emitted between entering and exiting node.
|
|
// But if CFG events were emitted between entering node and exiting node, then the order the rule's
|
|
// visit functions are called in would be wrong.
|
|
// `exit` visit fn would be called before the CFG event handlers, instead of after.
|
|
if (DEBUG && stepTypeIds.length !== stepsLenAfterEnter) {
|
|
const eventNames: string[] = [];
|
|
for (let i = stepsLenAfterEnter; i < stepTypeIds.length; i++) {
|
|
const typeId = stepTypeIds[i];
|
|
if (typeId < NODE_TYPES_COUNT) {
|
|
eventNames.push(`enter ${NODE_TYPE_IDS_MAP.get((stepData[i] as Node).type)}`);
|
|
} else if (typeId >= EXIT_TYPE_ID_OFFSET) {
|
|
eventNames.push(`exit ${NODE_TYPE_IDS_MAP.get((stepData[i] as Node).type)}`);
|
|
} else {
|
|
const eventName = NODE_TYPE_IDS_MAP.entries().find(([, id]) => id === typeId)![0];
|
|
eventNames.push(eventName);
|
|
}
|
|
}
|
|
|
|
throw new Error(
|
|
`CFG events emitted during visiting leaf node \`${node.type}\`: ${eventNames.join(", ")}`,
|
|
);
|
|
}
|
|
}
|
|
},
|
|
|
|
emit(eventName: string, args: unknown[]) {
|
|
const typeId = NODE_TYPE_IDS_MAP.get(eventName)!;
|
|
stepTypeIds.push(typeId);
|
|
stepData.push(args);
|
|
},
|
|
});
|
|
|
|
// Walk AST, calling `analyzer` methods for each node
|
|
traverseNode(ast, analyzer.enterNode.bind(analyzer), analyzer.leaveNode.bind(analyzer));
|
|
|
|
debugAssert(
|
|
stepTypeIds.length === stepData.length,
|
|
"`stepTypeIds` and `stepData` should have the same length",
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Lightweight AST traverser for CFG building.
|
|
* This is a simplified version that only calls enter/leave callbacks,
|
|
* without building ancestors array or other overhead that ESLint's `Traverser` has.
|
|
*
|
|
* @param node - AST node to traverse
|
|
* @param enter - Callback for entering a node
|
|
* @param leave - Callback for leaving a node
|
|
*/
|
|
function traverseNode(node: Node, enter: (node: Node) => void, leave: (node: Node) => void): void {
|
|
enter(node);
|
|
|
|
const keys = visitorKeys[node.type as keyof typeof visitorKeys];
|
|
const keysLen = keys.length;
|
|
for (let i = 0; i < keysLen; i++) {
|
|
const child = (node as any)[keys[i]] as Node | (Node | null)[] | null;
|
|
|
|
if (child === null) continue;
|
|
|
|
if (Array.isArray(child)) {
|
|
const len = child.length;
|
|
for (let i = 0; i < len; i++) {
|
|
const element = child[i];
|
|
if (element !== null) traverseNode(element, enter, leave);
|
|
}
|
|
} else {
|
|
traverseNode(child, enter, leave);
|
|
}
|
|
}
|
|
|
|
leave(node);
|
|
}
|
|
|
|
/**
|
|
* Debug assert that `enterExit` is an `EnterExit` object.
|
|
* In release build, this function does nothing and is removed entirely by minifier.
|
|
* @param enterExit - Object
|
|
* @throws {TypeError} If `enterExit` is not an `EnterExit` object in debug build
|
|
*/
|
|
export function debugAssertIsEnterExitObject(enterExit: unknown): asserts enterExit is EnterExit {
|
|
if (!DEBUG) return;
|
|
|
|
if (!isEnterExit(enterExit)) throw new TypeError("Expected to be an `EnterExit` object");
|
|
}
|
|
|
|
/**
|
|
* Check if an object is an `EnterExit` object.
|
|
* @param obj - Object
|
|
* @returns `true` if `obj` is an `EnterExit` object, `false` otherwise
|
|
*/
|
|
function isEnterExit(obj: any): obj is EnterExit {
|
|
if (obj === null) return false;
|
|
if (typeof obj !== "object") return false;
|
|
if (Object.keys(obj).length !== 2) return false;
|
|
if (obj.enter !== null && typeof obj.enter !== "function") return false;
|
|
if (obj.exit !== null && typeof obj.exit !== "function") return false;
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Check if a number is a power of two.
|
|
* @param n - Number
|
|
* @returns `true` if `n` is a power of two, `false` otherwise
|
|
*/
|
|
function isPowerOfTwo(n: number): boolean {
|
|
return n > 0 && (n & (n - 1)) === 0;
|
|
}
|