diff --git a/apps/oxlint/src-js/plugins/scope.ts b/apps/oxlint/src-js/plugins/scope.ts index 2466427e87089..3eaae92dd5193 100644 --- a/apps/oxlint/src-js/plugins/scope.ts +++ b/apps/oxlint/src-js/plugins/scope.ts @@ -407,14 +407,53 @@ export function getScope(node: ESTree.Node): Scope { /** * Marks as used a variable with the given name in a scope indicated by the given reference node. - * This affects the `no-unused-vars` rule. + * + * 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 + * @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` */ -/* oxlint-disable no-unused-vars */ -export function markVariableAsUsed(name: string, refNode: ESTree.Node): boolean { - // TODO: Implement - throw new Error("`sourceCode.markVariableAsUsed` not implemented yet"); +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; } -/* oxlint-enable no-unused-vars */ diff --git a/apps/oxlint/test/fixtures/sourceCode_markVariableAsUsed/.oxlintrc.json b/apps/oxlint/test/fixtures/sourceCode_markVariableAsUsed/.oxlintrc.json new file mode 100644 index 0000000000000..962fd5fa0b981 --- /dev/null +++ b/apps/oxlint/test/fixtures/sourceCode_markVariableAsUsed/.oxlintrc.json @@ -0,0 +1,9 @@ +{ + "jsPlugins": ["./plugin.ts"], + "categories": { + "correctness": "off" + }, + "rules": { + "mark-used-plugin/mark-used": "error" + } +} diff --git a/apps/oxlint/test/fixtures/sourceCode_markVariableAsUsed/files/index.cjs b/apps/oxlint/test/fixtures/sourceCode_markVariableAsUsed/files/index.cjs new file mode 100644 index 0000000000000..750fa1b49d90d --- /dev/null +++ b/apps/oxlint/test/fixtures/sourceCode_markVariableAsUsed/files/index.cjs @@ -0,0 +1,25 @@ +// `unusedTopLevel` is declared but never referenced in code, +// so `eslintUsed` should start as `false`. +const unusedTopLevel = "top"; +const unusedTopLevel2 = "top2"; + +// `shadowedName` exists at module scope AND inside `inner`. +// Used to test that omitting `refNode` finds the module-scope one, +// and that a name can be in scope or out of scope depending on `refNode`. +const shadowedName = "module-level"; + +function outer(param) { + // `nestedVar` is only in `outer`'s scope + const nestedVar = param; + const nestedVar2 = param + 1; + + function inner() { + // Shadows module-level `shadowedName` + const shadowedName = "inner-level"; + return shadowedName; + } + + return nestedVar + inner(); +} + +module.exports = outer(unusedTopLevel); diff --git a/apps/oxlint/test/fixtures/sourceCode_markVariableAsUsed/files/index.js b/apps/oxlint/test/fixtures/sourceCode_markVariableAsUsed/files/index.js new file mode 100644 index 0000000000000..6dcfee5b89d37 --- /dev/null +++ b/apps/oxlint/test/fixtures/sourceCode_markVariableAsUsed/files/index.js @@ -0,0 +1,25 @@ +// `unusedTopLevel` is declared but never referenced in code, +// so `eslintUsed` should start as `false`. +const unusedTopLevel = "top"; +const unusedTopLevel2 = "top2"; + +// `shadowedName` exists at module scope AND inside `inner`. +// Used to test that omitting `refNode` finds the module-scope one, +// and that a name can be in scope or out of scope depending on `refNode`. +const shadowedName = "module-level"; + +function outer(param) { + // `nestedVar` is only in `outer`'s scope + const nestedVar = param; + const nestedVar2 = param + 1; + + function inner() { + // Shadows module-level `shadowedName` + const shadowedName = "inner-level"; + return shadowedName; + } + + return nestedVar + inner(); +} + +export default outer(unusedTopLevel); diff --git a/apps/oxlint/test/fixtures/sourceCode_markVariableAsUsed/output.snap.md b/apps/oxlint/test/fixtures/sourceCode_markVariableAsUsed/output.snap.md new file mode 100644 index 0000000000000..a530c60a32c1f --- /dev/null +++ b/apps/oxlint/test/fixtures/sourceCode_markVariableAsUsed/output.snap.md @@ -0,0 +1,144 @@ +# Exit code +1 + +# stdout +``` + x mark-used-plugin(mark-used): [1] mark `unusedTopLevel` from Program: + | before: false + | result: true + | after: true + ,-[files/index.cjs:1:1] + 1 | // `unusedTopLevel` is declared but never referenced in code, + : ^ + 2 | // so `eslintUsed` should start as `false`. + `---- + + x mark-used-plugin(mark-used): [2] mark `nonExistent` from Program: + | result: false + ,-[files/index.cjs:1:1] + 1 | // `unusedTopLevel` is declared but never referenced in code, + : ^ + 2 | // so `eslintUsed` should start as `false`. + `---- + + x mark-used-plugin(mark-used): [3] mark `shadowedName` (no refNode): + | before: false + | result: true + | after: true + ,-[files/index.cjs:1:1] + 1 | // `unusedTopLevel` is declared but never referenced in code, + : ^ + 2 | // so `eslintUsed` should start as `false`. + `---- + + x mark-used-plugin(mark-used): [4] mark `nestedVar` from outer: + | before: false + | result: true + | after: true + ,-[files/index.cjs:1:1] + 1 | // `unusedTopLevel` is declared but never referenced in code, + : ^ + 2 | // so `eslintUsed` should start as `false`. + `---- + + x mark-used-plugin(mark-used): [5] mark `unusedTopLevel2` from outer: + | before: false + | result: true + | after: true + ,-[files/index.cjs:1:1] + 1 | // `unusedTopLevel` is declared but never referenced in code, + : ^ + 2 | // so `eslintUsed` should start as `false`. + `---- + + x mark-used-plugin(mark-used): [6] mark `nestedVar2` from inner: + | before: false + | result: true + | after: true + ,-[files/index.cjs:1:1] + 1 | // `unusedTopLevel` is declared but never referenced in code, + : ^ + 2 | // so `eslintUsed` should start as `false`. + `---- + + x mark-used-plugin(mark-used): [7] mark `doesNotExist` from inner: + | result: false + ,-[files/index.cjs:1:1] + 1 | // `unusedTopLevel` is declared but never referenced in code, + : ^ + 2 | // so `eslintUsed` should start as `false`. + `---- + + x mark-used-plugin(mark-used): [1] mark `unusedTopLevel` from Program: + | before: false + | result: true + | after: true + ,-[files/index.js:1:1] + 1 | // `unusedTopLevel` is declared but never referenced in code, + : ^ + 2 | // so `eslintUsed` should start as `false`. + `---- + + x mark-used-plugin(mark-used): [2] mark `nonExistent` from Program: + | result: false + ,-[files/index.js:1:1] + 1 | // `unusedTopLevel` is declared but never referenced in code, + : ^ + 2 | // so `eslintUsed` should start as `false`. + `---- + + x mark-used-plugin(mark-used): [3] mark `shadowedName` (no refNode): + | before: false + | result: true + | after: true + ,-[files/index.js:1:1] + 1 | // `unusedTopLevel` is declared but never referenced in code, + : ^ + 2 | // so `eslintUsed` should start as `false`. + `---- + + x mark-used-plugin(mark-used): [4] mark `nestedVar` from outer: + | before: false + | result: true + | after: true + ,-[files/index.js:1:1] + 1 | // `unusedTopLevel` is declared but never referenced in code, + : ^ + 2 | // so `eslintUsed` should start as `false`. + `---- + + x mark-used-plugin(mark-used): [5] mark `unusedTopLevel2` from outer: + | before: false + | result: true + | after: true + ,-[files/index.js:1:1] + 1 | // `unusedTopLevel` is declared but never referenced in code, + : ^ + 2 | // so `eslintUsed` should start as `false`. + `---- + + x mark-used-plugin(mark-used): [6] mark `nestedVar2` from inner: + | before: false + | result: true + | after: true + ,-[files/index.js:1:1] + 1 | // `unusedTopLevel` is declared but never referenced in code, + : ^ + 2 | // so `eslintUsed` should start as `false`. + `---- + + x mark-used-plugin(mark-used): [7] mark `doesNotExist` from inner: + | result: false + ,-[files/index.js:1:1] + 1 | // `unusedTopLevel` is declared but never referenced in code, + : ^ + 2 | // so `eslintUsed` should start as `false`. + `---- + +Found 0 warnings and 14 errors. +Finished in Xms on 2 files with 1 rules using X threads. +``` + +# stderr +``` +``` diff --git a/apps/oxlint/test/fixtures/sourceCode_markVariableAsUsed/plugin.ts b/apps/oxlint/test/fixtures/sourceCode_markVariableAsUsed/plugin.ts new file mode 100644 index 0000000000000..dd9f3b248c9b9 --- /dev/null +++ b/apps/oxlint/test/fixtures/sourceCode_markVariableAsUsed/plugin.ts @@ -0,0 +1,138 @@ +import type { Node, Plugin, Rule, Scope } from "#oxlint/plugins"; + +const SPAN: Node = { + start: 0, + end: 0, + range: [0, 0], + loc: { + start: { line: 0, column: 0 }, + end: { line: 0, column: 0 }, + }, +}; + +const rule: Rule = { + create(context) { + const { sourceCode } = context; + + return { + Program(node) { + // Use scopes[1] which is the module scope (ESM) or outer function scope (CJS). + const topLevelScope = sourceCode.scopeManager.scopes[1]; + + // 1. Mark `unusedTopLevel` from `Program` (tests module scope adjustment). + // Check `eslintUsed` before and after to verify it's being set. + const beforeUnused = getEslintUsed(topLevelScope, "unusedTopLevel"); + const markUnused = sourceCode.markVariableAsUsed("unusedTopLevel", node); + const afterUnused = getEslintUsed(topLevelScope, "unusedTopLevel"); + + context.report({ + message: + "[1] mark `unusedTopLevel` from Program:\n" + + `before: ${beforeUnused}\n` + + `result: ${markUnused}\n` + + `after: ${afterUnused}`, + node: SPAN, + }); + + // 2. Non-existent variable returns `false`. + context.report({ + message: + "[2] mark `nonExistent` from Program:\n" + + `result: ${sourceCode.markVariableAsUsed("nonExistent", node)}`, + node: SPAN, + }); + + // 3. Call without 2nd arg - defaults to Program node. + // Should find the module-level `shadowedName`, NOT `inner`'s local one. + const beforeShadowed = getEslintUsed(topLevelScope, "shadowedName"); + const markShadowed = sourceCode.markVariableAsUsed("shadowedName"); + const afterShadowed = getEslintUsed(topLevelScope, "shadowedName"); + + context.report({ + message: + "[3] mark `shadowedName` (no refNode):\n" + + `before: ${beforeShadowed}\n` + + `result: ${markShadowed}\n` + + `after: ${afterShadowed}`, + node: SPAN, + }); + }, + + "FunctionDeclaration[id.name='outer']"(node) { + const outerScope = sourceCode.getScope(node); + + // 4. Mark a variable in the current function scope. + const beforeNested = getEslintUsed(outerScope, "nestedVar"); + const markNested = sourceCode.markVariableAsUsed("nestedVar", node); + const afterNested = getEslintUsed(outerScope, "nestedVar"); + + context.report({ + message: + "[4] mark `nestedVar` from outer:\n" + + `before: ${beforeNested}\n` + + `result: ${markNested}\n` + + `after: ${afterNested}`, + node: SPAN, + }); + + // 5. Walk up from `outer` to module scope to find `unusedTopLevel2`. + const moduleScope = outerScope.upper!; + const beforeTop2 = getEslintUsed(moduleScope, "unusedTopLevel2"); + const markTop2 = sourceCode.markVariableAsUsed("unusedTopLevel2", node); + const afterTop2 = getEslintUsed(moduleScope, "unusedTopLevel2"); + + context.report({ + message: + "[5] mark `unusedTopLevel2` from outer:\n" + + `before: ${beforeTop2}\n` + + `result: ${markTop2}\n` + + `after: ${afterTop2}`, + node: SPAN, + }); + }, + + "FunctionDeclaration[id.name='inner']"(node) { + const innerScope = sourceCode.getScope(node); + const outerScope = innerScope.upper!; + + // 6. Walk up from `inner` through `outer` to find `nestedVar2` in parent scope. + const beforeNested = getEslintUsed(outerScope, "nestedVar2"); + const markNested = sourceCode.markVariableAsUsed("nestedVar2", node); + const afterNested = getEslintUsed(outerScope, "nestedVar2"); + + context.report({ + message: + "[6] mark `nestedVar2` from inner:\n" + + `before: ${beforeNested}\n` + + `result: ${markNested}\n` + + `after: ${afterNested}`, + node: SPAN, + }); + + // 7. Non-existent variable from nested scope returns `false`. + context.report({ + message: + "[7] mark `doesNotExist` from inner:\n" + + `result: ${sourceCode.markVariableAsUsed("doesNotExist", node)}`, + node: SPAN, + }); + }, + }; + }, +}; + +/** + * Helper to read `eslintUsed` from a variable found by name in a scope. + */ +function getEslintUsed(scope: Scope, name: string): boolean | undefined { + const variable = scope.set.get(name); + // @ts-expect-error - `eslintUsed` isn't part of public API + return variable?.eslintUsed; +} + +const plugin: Plugin = { + meta: { name: "mark-used-plugin" }, + rules: { "mark-used": rule }, +}; + +export default plugin;