Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10,331 changes: 5 additions & 10,326 deletions apps/oxlint/conformance/snapshot.md

Large diffs are not rendered by default.

29 changes: 29 additions & 0 deletions apps/oxlint/src-js/generated/type_ids.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,5 +176,34 @@ export const NODE_TYPES_COUNT = 165;
/** Count of leaf node types */
export const LEAF_NODE_TYPES_COUNT = 27;

/** Type IDs which match `:statement` selector class */
export const STATEMENT_NODE_TYPE_IDS = [
0, 1, 35, 36, 41, 44, 46, 47, 48, 49, 51, 52, 53, 54, 55, 58, 60, 65, 78, 83, 86, 87, 91, 93, 94,
111, 114, 119, 124, 131, 140, 142, 153, 159,
];

/** Type IDs which match `:declaration` selector class */
export const DECLARATION_NODE_TYPE_IDS = [
41, 47, 48, 49, 55, 60, 91, 111, 114, 119, 124, 131, 140, 142, 153, 159,
];

/**
* Type IDs which may match `:pattern` selector class.
* Only *may* match because `Identifier` nodes only match this class if their parent is not a `MetaProperty`.
*/
export const PATTERN_NODE_TYPE_IDS = [
2, 6, 8, 28, 29, 30, 31, 32, 33, 34, 37, 39, 42, 43, 56, 57, 62, 66, 67, 68, 70, 71, 72, 73, 79,
84, 85, 88, 89, 90, 95, 101, 110, 117, 129, 143, 150, 156,
];

/**
* Type IDs which may match `:expression` selector class.
* Only *may* match because `Identifier` nodes only match this class if their parent is not a `MetaProperty`.
*/
export const EXPRESSION_NODE_TYPE_IDS = [
2, 6, 8, 28, 30, 31, 33, 34, 37, 39, 42, 43, 56, 57, 62, 66, 67, 68, 70, 71, 73, 79, 84, 85, 88,
89, 90, 95, 101, 110, 117, 129, 143, 150, 156,
];

/** Type IDs which match `:function` selector class */
export const FUNCTION_NODE_TYPE_IDS = [30, 55, 56];
108 changes: 92 additions & 16 deletions apps/oxlint/src-js/plugins/selector.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,36 @@
import esquery from "esquery";
import visitorKeys from "../generated/keys.ts";
import { FUNCTION_NODE_TYPE_IDS, NODE_TYPE_IDS_MAP } from "../generated/type_ids.ts";
import {
STATEMENT_NODE_TYPE_IDS,
DECLARATION_NODE_TYPE_IDS,
PATTERN_NODE_TYPE_IDS,
EXPRESSION_NODE_TYPE_IDS,
FUNCTION_NODE_TYPE_IDS,
NODE_TYPE_IDS_MAP,
} from "../generated/type_ids.ts";
import { ancestors } from "../generated/walk.js";
import { debugAssert } from "../utils/asserts.ts";
import { debugAssert, typeAssertIs } from "../utils/asserts.ts";

import type { ESQueryOptions, Selector as EsquerySelector } from "esquery";
import type { Node as EsqueryNode } from "estree";
import type { Node } from "./types.ts";
import type { VisitFn } from "./visitor.ts";
import type { Node as ESTreeNode } from "../generated/types.d.ts";

const { matches: esqueryMatches, parse: esqueryParse } = esquery;

type NodeTypeId = number;

// These arrays should never be mutated.
// If they are, then `analyzeSelector` has a bug.
if (DEBUG) {
Object.freeze(STATEMENT_NODE_TYPE_IDS);
Object.freeze(DECLARATION_NODE_TYPE_IDS);
Object.freeze(PATTERN_NODE_TYPE_IDS);
Object.freeze(EXPRESSION_NODE_TYPE_IDS);
Object.freeze(FUNCTION_NODE_TYPE_IDS);
}

// Options to call `esquery.matches` with.
const ESQUERY_OPTIONS: ESQueryOptions = {
nodeTypeKey: "type",
Expand All @@ -24,6 +42,21 @@ const ESQUERY_OPTIONS: ESQueryOptions = {
matchClass: matchesSelectorClass,
};

// This function is copied from ESLint.
// Implementation here is functionally identical to ESLint's, except for a couple of perf optimizations noted below.
//
// TODO: Does TS-ESLint alter this function in any way to handle TS nodes?
//
// IMPORTANT: This function must be kept in sync with `class` case in `analyzeSelector` below,
// which means that it must be kept in sync with `SelectorClassNodeIds::add`
// in `tasks/ast_tools/src/generators/estree_visit.rs`, which generates `STATEMENT_NODE_TYPE_IDS` etc.
//
// ESLint notes that its implementation is copied from ESQuery, and contains ESQuery's license inline.
// ESLint's implementation is identical to ESQuery's original, except for formatting differences.
// ESLint code: https://github.com/eslint/eslint/blob/eafd727a060131f7fc79b2eb5698d8d27683c3a2/lib/languages/js/index.js#L155-L229
// ESLint license (MIT): https://github.com/eslint/eslint/blob/eafd727a060131f7fc79b2eb5698d8d27683c3a2/LICENSE
// ESQuery code: https://github.com/estools/esquery/blob/6c4f10370606c5a08adfcb5becc8248fb1edad43/esquery.js#L308-L344
// ESQuery license: https://github.com/estools/esquery/blob/6c4f10370606c5a08adfcb5becc8248fb1edad43/license.txt
/**
* Check if an AST node matches a selector class.
* @param className - Class name parsed from selector
Expand All @@ -36,15 +69,46 @@ function matchesSelectorClass(
node: EsqueryNode,
_ancestors: EsqueryNode[],
): boolean {
if (className.toLowerCase() === "function") {
const { type } = node;
return (
type === "FunctionDeclaration" ||
type === "FunctionExpression" ||
type === "ArrowFunctionExpression"
);
// Types don't match exactly.
// All AST nodes have `parent` property (which `EsqueryNode` doesn't).
typeAssertIs<ESTreeNode>(node);

const { type } = node;

switch (className.toLowerCase()) {
case "statement":
if (type.endsWith("Statement")) return true;
// fallthrough: interface Declaration <: Statement { }

case "declaration":
return type.endsWith("Declaration");

case "pattern":
if (type.endsWith("Pattern")) return true;
// fallthrough: interface Expression <: Node, Pattern { }

case "expression":
return (
type.endsWith("Expression") ||
type.endsWith("Literal") ||
// ESLint / ESQuery uses `ancestors[0].type` instead of `node.parent.type`.
// `node.parent.type` is faster, but functionally equivalent.
(type === "Identifier" && node.parent.type !== "MetaProperty") ||
type === "MetaProperty"
);

case "function":
return (
type === "FunctionDeclaration" ||
type === "FunctionExpression" ||
type === "ArrowFunctionExpression"
);

default:
// Should have been caught already when compiling the selector in `analyzeSelector`
debugAssert(false, `Unknown selector class not caught in \`analyzeSelector\`: ${className}`);
return false;
}
return false;
}

// Specificity is a combination of:
Expand Down Expand Up @@ -252,12 +316,24 @@ function analyzeSelector(
return analyzeSelector(esquerySelector.right, selector);

case "class":
// TODO: Should TS function types be included in `FUNCTION_NODE_TYPE_IDS`?
// This TODO comment is from ESLint's implementation. Not sure what it means!
// TODO: Abstract into JSLanguage somehow.
if (esquerySelector.name.toLowerCase() === "function") return FUNCTION_NODE_TYPE_IDS;
selector.isComplex = true;
return null;
switch (esquerySelector.name.toLowerCase()) {
case "statement":
return STATEMENT_NODE_TYPE_IDS;
case "declaration":
return DECLARATION_NODE_TYPE_IDS;
case "pattern":
// Complex because `Identifier` nodes don't match this class if their parent is a `MetaProperty`
selector.isComplex = true;
return PATTERN_NODE_TYPE_IDS;
case "expression":
// Complex because `Identifier` nodes don't match this class if their parent is a `MetaProperty`
selector.isComplex = true;
return EXPRESSION_NODE_TYPE_IDS;
case "function":
return FUNCTION_NODE_TYPE_IDS;
default:
throw new Error(`Invalid class in selector: \`:${esquerySelector.name}\``);
}

case "wildcard":
return null;
Expand Down
186 changes: 186 additions & 0 deletions apps/oxlint/test/compile_visitor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
initCompiledVisitor,
} from "../src-js/plugins/visitor.ts";

import type { Mock } from "vitest";
import type { Node } from "../src-js/plugins/types.ts";
import type { EnterExit, VisitFn } from "../src-js/plugins/visitor.ts";

Expand Down Expand Up @@ -567,6 +568,191 @@ describe("compile visitor", () => {
expect(exit).toHaveBeenCalledWith(ident);
});

it("class adds visitor function only for node types in those classes", () => {
type ClassName = "statement" | "declaration" | "pattern" | "expression" | "function";

const enterStatement = vi.fn(() => {});
const exitStatement = vi.fn(() => {});
const enterDeclaration = vi.fn(() => {});
const exitDeclaration = vi.fn(() => {});
const enterPattern = vi.fn(() => {});
const exitPattern = vi.fn(() => {});
const enterExpression = vi.fn(() => {});
const exitExpression = vi.fn(() => {});
const enterFunction = vi.fn(() => {});
const exitFunction = vi.fn(() => {});

// `:pattern` and `:expression` are "complex" selectors.
// Visit fns are wrapped for them. The rest of classes are simple.
const classes = new Map<
ClassName,
{ enter: Mock<VisitFn>; exit: Mock<VisitFn>; isComplex: boolean }
>([
["statement", { enter: enterStatement, exit: exitStatement, isComplex: false }],
["declaration", { enter: enterDeclaration, exit: exitDeclaration, isComplex: false }],
["pattern", { enter: enterPattern, exit: exitPattern, isComplex: true }],
["expression", { enter: enterExpression, exit: exitExpression, isComplex: true }],
["function", { enter: enterFunction, exit: exitFunction, isComplex: false }],
]);

function resetSpies() {
for (const { enter, exit } of classes.values()) {
enter.mockClear();
exit.mockClear();
}
}

addVisitorToCompiled({
":statement": enterStatement,
":statement:exit": exitStatement,
":declaration": enterDeclaration,
":declaration:exit": exitDeclaration,
":pattern": enterPattern,
":pattern:exit": exitPattern,
":expression": enterExpression,
":expression:exit": exitExpression,
":function": enterFunction,
":function:exit": exitFunction,
});

expect(finalizeCompiledVisitor()).toBe(true);

for (const [typeName, typeId] of NODE_TYPE_IDS_MAP.entries()) {
// Check that compiled visitor is set up correctly

// Get classes that this node type matches
const classNames: ClassName[] = [];

if (typeName.endsWith("Statement")) {
classNames.push("statement");
} else if (typeName.endsWith("Declaration")) {
// Declarations are also statements
classNames.push("declaration", "statement");
} else if (typeName.endsWith("Pattern")) {
classNames.push("pattern");
} else if (
typeName.endsWith("Expression") ||
typeName.endsWith("Literal") ||
typeName === "Identifier" ||
typeName === "MetaProperty"
) {
// Expressions are also patterns
classNames.push("expression", "pattern");
}

if (
["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"].includes(
typeName,
)
) {
classNames.push("function");
}

const visit = compiledVisitor[typeId];

// If no classes match this node type, then visitor should be `null`
if (classNames.length === 0) {
expect(visit).toBeNull();
continue;
}

// Check the compiler visitor for this type is what it should be.
// Check that visit fns have/have not been wrapped/merged, depending on the class.
if (
classNames.length === 1 && // Type is only matched by 1 class
typeId >= LEAF_NODE_TYPES_COUNT && // Non leaf node
!classes.get(classNames[0])!.isComplex // Class is not complex
) {
const enterExit = visit as EnterExit;
const { enter, exit } = classes.get(classNames[0])!;
expect(enterExit.enter).toBe(enter);
expect(enterExit.exit).toBe(exit);
} else {
expect(visit).not.toBeNull();

// Check visit fn has been wrapped/merged
if (typeId < LEAF_NODE_TYPES_COUNT) {
// Leaf node
expect(typeof visit).toBe("function");

for (const { enter, exit } of classes.values()) {
expect(visit).not.toBe(enter);
expect(visit).not.toBe(exit);
}
} else {
// Non-leaf node
expect(typeof visit).toBe("object");

const enterExit = visit as EnterExit;
expect(typeof enterExit.enter).toBe("function");
expect(typeof enterExit.exit).toBe("function");

for (const { enter, exit } of classes.values()) {
expect(enterExit.enter).not.toBe(enter);
expect(enterExit.enter).not.toBe(exit);
expect(enterExit.exit).not.toBe(enter);
expect(enterExit.exit).not.toBe(exit);
}
}
}

// Check that correct functions are called when visiting a node of this type
const node = {
type: typeName,
parent: { type: "Program", ...SPAN },
...SPAN,
};

if (typeId < LEAF_NODE_TYPES_COUNT) {
// Leaf node
(visit as VisitFn)(node);

for (const [className, { enter, exit }] of classes.entries()) {
if (classNames.includes(className)) {
expect(enter).toHaveBeenCalledWith(node);
expect(exit).toHaveBeenCalledWith(node);
} else {
expect(enter).not.toHaveBeenCalled();
expect(exit).not.toHaveBeenCalled();
}
}

resetSpies();
} else {
// Non-leaf node
const enterExit = visit as EnterExit;

// Test enter
enterExit.enter!(node);

for (const [className, { enter, exit }] of classes.entries()) {
if (classNames.includes(className)) {
expect(enter).toHaveBeenCalledWith(node);
} else {
expect(enter).not.toHaveBeenCalled();
}
expect(exit).not.toHaveBeenCalled();
}

resetSpies();

// Test exit
enterExit.exit!(node);

for (const [className, { enter, exit }] of classes.entries()) {
expect(enter).not.toHaveBeenCalled();
if (classNames.includes(className)) {
expect(exit).toHaveBeenCalledWith(node);
} else {
expect(exit).not.toHaveBeenCalled();
}
}

resetSpies();
}
}
});

it("combined", () => {
const enter1 = vi.fn(() => {});
const exit1 = vi.fn(() => {});
Expand Down
4 changes: 3 additions & 1 deletion apps/oxlint/test/fixtures/selector/files/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@ const obj = { a: [b, c], ...d };
function foo() {}
function bar() {}

() => {};
({ e: f }) => {};

import.meta;
Loading
Loading