diff --git a/apps/oxlint/src-js/plugins/location.ts b/apps/oxlint/src-js/plugins/location.ts index 759e7ca3838be..d4df75cfb5af0 100644 --- a/apps/oxlint/src-js/plugins/location.ts +++ b/apps/oxlint/src-js/plugins/location.ts @@ -3,12 +3,15 @@ * Functions for converting between `LineColumn` and offsets, and splitting source text into lines. */ -import { initSourceText, sourceText } from "./source_code.js"; +import { ast, initAst, initSourceText, sourceText } from "./source_code.js"; +import visitorKeys from "../generated/keys.js"; import { debugAssertIsNonNull } from "../utils/asserts.js"; import type { Node } from "./types.ts"; +import type { Node as ESTreeNode } from "../generated/types.d.ts"; -const { defineProperty } = Object; +const { defineProperty } = Object, + { isArray } = Array; /** * Range of source offsets. @@ -237,3 +240,72 @@ export function getNodeLoc(node: Node): Location { return loc; } + +/** + * Get the deepest node containing a range index. + * @param offset - Range index of the desired node + * @returns The node if found, or `null` if not found + */ +export function getNodeByRangeIndex(offset: number): ESTreeNode | null { + if (ast === null) initAst(); + debugAssertIsNonNull(ast); + + // If index is outside of `Program`, return `null` + // TODO: Once `Program`'s span covers the entire file (as per ESLint v10), `index < ast.start` check can be removed + // (or changed to `index < 0` if we want to check for negative indices) + if (offset < ast.start || offset >= ast.end) return null; + + // Search for the node containing the index + index = offset; + return traverse(ast); +} + +let index: number = 0; + +/** + * Find deepest node containing `index`. + * `node` must contain `index` itself. This function finds a deeper node if one exists. + * + * @param node - Node to start traversal from + * @returns Deepest node containing `index` + */ +function traverse(node: ESTreeNode): ESTreeNode { + // TODO: Handle decorators on exports e.g. `@dec export class C {}`. + // Decorators in that position have spans outside of the `export` node's span. + // ESLint doesn't handle this case correctly, so not a big deal that we don't at present either. + + const keys = (visitorKeys as Record)[node.type]; + + // All nodes' properties are in source order, so we could use binary search here. + // But the max number of visitable properties is 5, so linear search is fine. Possibly linear is faster anyway. + for (let keyIndex = 0, keysLen = keys.length; keyIndex < keysLen; keyIndex++) { + const child = (node as unknown as Record)[keys[keyIndex]]; + + if (isArray(child)) { + // TODO: Binary search would be faster, especially for arrays of statements, which can be large + for (let arrIndex = 0, arrLen = child.length; arrIndex < arrLen; arrIndex++) { + const entry = child[arrIndex]; + if (entry !== null) { + // Array entries are in source order, so if this node is after the index, + // all remaining nodes in the array are after the index too. So we can skip checking the rest of them. + // We cannot skip all the rest of the outer loop, because in `TemplateLiteral`, + // the 2 arrays `quasis` and `expressions` are interleaved. Ditto `TSTemplateLiteralType`. + if (entry.start > index) break; + // This node starts on or before the index. If it ends after the index, index is within this node. + // Traverse into this node to find a deeper node if there is one. + if (entry.end > index) return traverse(entry); + } + } + } else if (child !== null) { + // Node properties are in source order, so if this node is after the index, + // all other properties are too. So we can skip checking the rest of them. + if (child.start > index) break; + // This node starts on or before the index. If it ends after the index, index is within this node. + // Traverse into this node to find a deeper node if there is one. + if (child.end > index) return traverse(child); + } + } + + // Index is not within any child node, so this is the deepest node containing the index + return node; +} diff --git a/apps/oxlint/src-js/plugins/source_code.ts b/apps/oxlint/src-js/plugins/source_code.ts index 5a65c1b433a81..4f0b57abbe104 100644 --- a/apps/oxlint/src-js/plugins/source_code.ts +++ b/apps/oxlint/src-js/plugins/source_code.ts @@ -9,6 +9,7 @@ import visitorKeys from "../generated/keys.js"; import * as commentMethods from "./comments.js"; import { getLineColumnFromOffset, + getNodeByRangeIndex, getNodeLoc, getOffsetFromLineColumn, initLines, @@ -78,7 +79,10 @@ export function initSourceText(): void { */ export function initAst(): void { if (sourceText === null) initSourceText(); + debugAssertIsNonNull(sourceText); + ast = deserializeProgramOnly(buffer, sourceText, sourceByteLen, getNodeLoc); + debugAssertIsNonNull(ast); } /** @@ -196,17 +200,8 @@ export const SOURCE_CODE = Object.freeze({ return ancestors.reverse(); }, - /** - * Get the deepest node containing a range index. - * @param index Range index of the desired node. - * @returns The node if found, or `null` if not found. - */ - // oxlint-disable-next-line no-unused-vars - getNodeByRangeIndex(index: number): Node | null { - throw new Error("`sourceCode.getNodeByRangeIndex` not implemented yet"); // TODO - }, - // Location methods + getNodeByRangeIndex, getLocFromIndex: getLineColumnFromOffset, getIndexFromLoc: getOffsetFromLineColumn, diff --git a/apps/oxlint/test/fixtures/getNodeByRangeIndex/.oxlintrc.json b/apps/oxlint/test/fixtures/getNodeByRangeIndex/.oxlintrc.json new file mode 100644 index 0000000000000..22fbf72441ba5 --- /dev/null +++ b/apps/oxlint/test/fixtures/getNodeByRangeIndex/.oxlintrc.json @@ -0,0 +1,9 @@ +{ + "jsPlugins": ["./plugin.ts"], + "categories": { + "correctness": "off" + }, + "rules": { + "getNode-plugin/getNode": "error" + } +} diff --git a/apps/oxlint/test/fixtures/getNodeByRangeIndex/files/index.ts b/apps/oxlint/test/fixtures/getNodeByRangeIndex/files/index.ts new file mode 100644 index 0000000000000..855e7629a1348 --- /dev/null +++ b/apps/oxlint/test/fixtures/getNodeByRangeIndex/files/index.ts @@ -0,0 +1,9 @@ +// Comment + +let foo = 1 + 2; + +`___${123}___`; + +type T = `___${123}___`; + +// Comment diff --git a/apps/oxlint/test/fixtures/getNodeByRangeIndex/output.snap.md b/apps/oxlint/test/fixtures/getNodeByRangeIndex/output.snap.md new file mode 100644 index 0000000000000..0526bc37557f1 --- /dev/null +++ b/apps/oxlint/test/fixtures/getNodeByRangeIndex/output.snap.md @@ -0,0 +1,366 @@ +# Exit code +1 + +# stdout +``` + x getNode-plugin(getNode): type: null + ,-[files/index.ts:1:1] + 1 | // Comment + : ^ + 2 | + `---- + + x getNode-plugin(getNode): type: null + ,-[files/index.ts:2:1] + 1 | // Comment + 2 | + : ^ + 3 | let foo = 1 + 2; + `---- + + x getNode-plugin(getNode): type: VariableDeclaration + ,-[files/index.ts:3:1] + 2 | + 3 | let foo = 1 + 2; + : ^ + 4 | + `---- + + x getNode-plugin(getNode): type: VariableDeclaration + ,-[files/index.ts:3:3] + 2 | + 3 | let foo = 1 + 2; + : ^ + 4 | + `---- + + x getNode-plugin(getNode): type: VariableDeclaration + ,-[files/index.ts:3:4] + 2 | + 3 | let foo = 1 + 2; + : ^ + 4 | + `---- + + x getNode-plugin(getNode): type: Identifier + ,-[files/index.ts:3:5] + 2 | + 3 | let foo = 1 + 2; + : ^ + 4 | + `---- + + x getNode-plugin(getNode): type: Identifier + ,-[files/index.ts:3:7] + 2 | + 3 | let foo = 1 + 2; + : ^ + 4 | + `---- + + x getNode-plugin(getNode): type: VariableDeclarator + ,-[files/index.ts:3:8] + 2 | + 3 | let foo = 1 + 2; + : ^ + 4 | + `---- + + x getNode-plugin(getNode): type: VariableDeclarator + ,-[files/index.ts:3:9] + 2 | + 3 | let foo = 1 + 2; + : ^ + 4 | + `---- + + x getNode-plugin(getNode): type: VariableDeclarator + ,-[files/index.ts:3:10] + 2 | + 3 | let foo = 1 + 2; + : ^ + 4 | + `---- + + x getNode-plugin(getNode): type: Literal + ,-[files/index.ts:3:11] + 2 | + 3 | let foo = 1 + 2; + : ^ + 4 | + `---- + + x getNode-plugin(getNode): type: BinaryExpression + ,-[files/index.ts:3:12] + 2 | + 3 | let foo = 1 + 2; + : ^ + 4 | + `---- + + x getNode-plugin(getNode): type: BinaryExpression + ,-[files/index.ts:3:13] + 2 | + 3 | let foo = 1 + 2; + : ^ + 4 | + `---- + + x getNode-plugin(getNode): type: BinaryExpression + ,-[files/index.ts:3:14] + 2 | + 3 | let foo = 1 + 2; + : ^ + 4 | + `---- + + x getNode-plugin(getNode): type: Literal + ,-[files/index.ts:3:15] + 2 | + 3 | let foo = 1 + 2; + : ^ + 4 | + `---- + + x getNode-plugin(getNode): type: VariableDeclaration + ,-[files/index.ts:3:16] + 2 | + 3 | let foo = 1 + 2; + : ^ + 4 | + `---- + + x getNode-plugin(getNode): type: Program + ,-[files/index.ts:3:17] + 2 | + 3 | let foo = 1 + 2; + : ^ + 4 | + `---- + + x getNode-plugin(getNode): type: Program + ,-[files/index.ts:4:1] + 3 | let foo = 1 + 2; + 4 | + : ^ + 5 | `___${123}___`; + `---- + + x getNode-plugin(getNode): type: TemplateElement + ,-[files/index.ts:5:1] + 4 | + 5 | `___${123}___`; + : ^ + 6 | + `---- + + x getNode-plugin(getNode): type: TemplateElement + ,-[files/index.ts:5:6] + 4 | + 5 | `___${123}___`; + : ^ + 6 | + `---- + + x getNode-plugin(getNode): type: Literal + ,-[files/index.ts:5:7] + 4 | + 5 | `___${123}___`; + : ^ + 6 | + `---- + + x getNode-plugin(getNode): type: Literal + ,-[files/index.ts:5:9] + 4 | + 5 | `___${123}___`; + : ^ + 6 | + `---- + + x getNode-plugin(getNode): type: TemplateElement + ,-[files/index.ts:5:10] + 4 | + 5 | `___${123}___`; + : ^ + 6 | + `---- + + x getNode-plugin(getNode): type: TemplateElement + ,-[files/index.ts:5:14] + 4 | + 5 | `___${123}___`; + : ^ + 6 | + `---- + + x getNode-plugin(getNode): type: ExpressionStatement + ,-[files/index.ts:5:15] + 4 | + 5 | `___${123}___`; + : ^ + 6 | + `---- + + x getNode-plugin(getNode): type: Program + ,-[files/index.ts:5:16] + 4 | + 5 | `___${123}___`; + : ^ + 6 | + `---- + + x getNode-plugin(getNode): type: Program + ,-[files/index.ts:6:1] + 5 | `___${123}___`; + 6 | + : ^ + 7 | type T = `___${123}___`; + `---- + + x getNode-plugin(getNode): type: TSTypeAliasDeclaration + ,-[files/index.ts:7:1] + 6 | + 7 | type T = `___${123}___`; + : ^ + 8 | + `---- + + x getNode-plugin(getNode): type: TSTypeAliasDeclaration + ,-[files/index.ts:7:4] + 6 | + 7 | type T = `___${123}___`; + : ^ + 8 | + `---- + + x getNode-plugin(getNode): type: TSTypeAliasDeclaration + ,-[files/index.ts:7:5] + 6 | + 7 | type T = `___${123}___`; + : ^ + 8 | + `---- + + x getNode-plugin(getNode): type: Identifier + ,-[files/index.ts:7:6] + 6 | + 7 | type T = `___${123}___`; + : ^ + 8 | + `---- + + x getNode-plugin(getNode): type: TSTypeAliasDeclaration + ,-[files/index.ts:7:7] + 6 | + 7 | type T = `___${123}___`; + : ^ + 8 | + `---- + + x getNode-plugin(getNode): type: TSTypeAliasDeclaration + ,-[files/index.ts:7:8] + 6 | + 7 | type T = `___${123}___`; + : ^ + 8 | + `---- + + x getNode-plugin(getNode): type: TSTypeAliasDeclaration + ,-[files/index.ts:7:9] + 6 | + 7 | type T = `___${123}___`; + : ^ + 8 | + `---- + + x getNode-plugin(getNode): type: TemplateElement + ,-[files/index.ts:7:10] + 6 | + 7 | type T = `___${123}___`; + : ^ + 8 | + `---- + + x getNode-plugin(getNode): type: TemplateElement + ,-[files/index.ts:7:15] + 6 | + 7 | type T = `___${123}___`; + : ^ + 8 | + `---- + + x getNode-plugin(getNode): type: Literal + ,-[files/index.ts:7:16] + 6 | + 7 | type T = `___${123}___`; + : ^ + 8 | + `---- + + x getNode-plugin(getNode): type: Literal + ,-[files/index.ts:7:18] + 6 | + 7 | type T = `___${123}___`; + : ^ + 8 | + `---- + + x getNode-plugin(getNode): type: TemplateElement + ,-[files/index.ts:7:19] + 6 | + 7 | type T = `___${123}___`; + : ^ + 8 | + `---- + + x getNode-plugin(getNode): type: TemplateElement + ,-[files/index.ts:7:23] + 6 | + 7 | type T = `___${123}___`; + : ^ + 8 | + `---- + + x getNode-plugin(getNode): type: TSTypeAliasDeclaration + ,-[files/index.ts:7:24] + 6 | + 7 | type T = `___${123}___`; + : ^ + 8 | + `---- + + x getNode-plugin(getNode): type: Program + ,-[files/index.ts:7:25] + 6 | + 7 | type T = `___${123}___`; + : ^ + 8 | + `---- + + x getNode-plugin(getNode): type: Program + ,-[files/index.ts:9:11] + 8 | + 9 | // Comment + : ^ + `---- + + x getNode-plugin(getNode): type: null + ,-[files/index.ts:10:1] + 9 | // Comment + `---- + + x getNode-plugin(getNode): type: null + ,-[files/index.ts:10:1] + 9 | // Comment + `---- + +Found 0 warnings and 45 errors. +Finished in Xms on 1 file using X threads. +``` + +# stderr +``` +WARNING: JS plugins are experimental and not subject to semver. +Breaking changes are possible while JS plugins support is under development. +``` diff --git a/apps/oxlint/test/fixtures/getNodeByRangeIndex/plugin.ts b/apps/oxlint/test/fixtures/getNodeByRangeIndex/plugin.ts new file mode 100644 index 0000000000000..41e7571c1bfc8 --- /dev/null +++ b/apps/oxlint/test/fixtures/getNodeByRangeIndex/plugin.ts @@ -0,0 +1,55 @@ +import type { Plugin, Rule } from "#oxlint"; + +const rule: Rule = { + create(context) { + const { sourceCode } = context; + + // Check no error calling before AST or source code is accessed + sourceCode.getNodeByRangeIndex(0); + + // Get nodes: + // * At start of file + // * Before, at start of, inside, and at end of every token + const { tokens } = sourceCode.ast; + + const indexes = new Set([0]); + for (const token of tokens) { + const [start, end] = token.range; + indexes.add(start - 1); + indexes.add(start); + if (end - start > 1) indexes.add(end - 1); + indexes.add(end); + } + const sourceTextLen = sourceCode.text.length; + indexes.add(sourceTextLen - 1); + indexes.add(sourceTextLen); + + for (const index of indexes) { + const node = sourceCode.getNodeByRangeIndex(index); + context.report({ + message: `type: ${node === null ? null : node.type}`, + node: { range: [index, index] }, + }); + } + + // Get node after end of file + const node = sourceCode.getNodeByRangeIndex(sourceTextLen + 1); + context.report({ + message: `type: ${node === null ? null : node.type}`, + node: { range: [sourceTextLen, sourceTextLen] }, + }); + + return {}; + }, +}; + +const plugin: Plugin = { + meta: { + name: "getNode-plugin", + }, + rules: { + getNode: rule, + }, +}; + +export default plugin;