diff --git a/apps/oxlint/.npmignore b/apps/oxlint/.npmignore new file mode 100644 index 0000000000000..129fc5a1ecb66 --- /dev/null +++ b/apps/oxlint/.npmignore @@ -0,0 +1,6 @@ +# Avoid ATTW errors due to nested `node_modules` directories inside `test/fixtures` +# which contain packages with no `name` field in their `package.json`s. +# +# TSDown's `attw.excludeEntrypoints` option doesn't work - possible bug in TSDown? +# https://github.com/rolldown/tsdown/issues/656 +test/fixtures/**/node_modules diff --git a/apps/oxlint/src-js/bindings.d.ts b/apps/oxlint/src-js/bindings.d.ts index 36aa1afc900a6..f09686cc10aec 100644 --- a/apps/oxlint/src-js/bindings.d.ts +++ b/apps/oxlint/src-js/bindings.d.ts @@ -40,7 +40,7 @@ export type JsLintFileCb = /** JS callback to load a JS plugin. */ export type JsLoadPluginCb = - ((arg0: string, arg1?: string | undefined | null) => Promise) + ((arg0: string, arg1: string | undefined | null, arg2: boolean) => Promise) /** JS callback to setup configs. */ export type JsSetupConfigsCb = diff --git a/apps/oxlint/src-js/cli.ts b/apps/oxlint/src-js/cli.ts index 2b8ee9a6b9378..a548b4bd804ef 100644 --- a/apps/oxlint/src-js/cli.ts +++ b/apps/oxlint/src-js/cli.ts @@ -14,20 +14,25 @@ let lintFile: typeof lintFileWrapper | null = null; * Lazy-loads plugins code on first call, so that overhead is skipped if user doesn't use JS plugins. * * @param path - Absolute path of plugin file - * @param packageName - Optional package name from `package.json` (fallback if `plugin.meta.name` is not defined) + * @param pluginName - Plugin name (either alias or package name) + * @param pluginNameIsAlias - `true` if plugin name is an alias (takes priority over name that plugin defines itself) * @returns Plugin details or error serialized to JSON string */ -function loadPluginWrapper(path: string, packageName: string | null): Promise { +function loadPluginWrapper( + path: string, + pluginName: string | null, + pluginNameIsAlias: boolean, +): Promise { if (loadPlugin === null) { // Use promises here instead of making `loadPluginWrapper` an async function, // to avoid a micro-tick and extra wrapper `Promise` in all later calls to `loadPluginWrapper` return import("./plugins/index.ts").then((mod) => { ({ loadPlugin, lintFile, setupConfigs } = mod); - return loadPlugin(path, packageName); + return loadPlugin(path, pluginName, pluginNameIsAlias); }); } debugAssertIsNonNull(loadPlugin); - return loadPlugin(path, packageName); + return loadPlugin(path, pluginName, pluginNameIsAlias); } /** diff --git a/apps/oxlint/src-js/package/rule_tester.ts b/apps/oxlint/src-js/package/rule_tester.ts index 85c7fe1b0426f..8c78a257bd90c 100644 --- a/apps/oxlint/src-js/package/rule_tester.ts +++ b/apps/oxlint/src-js/package/rule_tester.ts @@ -962,7 +962,7 @@ function lint(test: TestCase, plugin: Plugin): Diagnostic[] { try { // Register plugin. This adds rule to `registeredRules` array. - registerPlugin(plugin, null); + registerPlugin(plugin, null, false); // Set up options const optionsId = setupOptions(test); diff --git a/apps/oxlint/src-js/plugins/load.ts b/apps/oxlint/src-js/plugins/load.ts index 945d03b44804b..3a383bdc8aefd 100644 --- a/apps/oxlint/src-js/plugins/load.ts +++ b/apps/oxlint/src-js/plugins/load.ts @@ -2,6 +2,7 @@ import { createContext } from "./context.ts"; import { deepFreezeJsonArray } from "./json.ts"; import { compileSchema, DEFAULT_OPTIONS } from "./options.ts"; import { getErrorMessage } from "../utils/utils.ts"; +import { debugAssertIsNonNull } from "../utils/asserts.ts"; import type { Writable } from "type-fest"; import type { Context } from "./context.ts"; @@ -106,10 +107,15 @@ interface PluginDetails { * Main logic is in separate function `loadPluginImpl`, because V8 cannot optimize functions containing try/catch. * * @param url - Absolute path of plugin file as a `file://...` URL - * @param packageName - Optional package name from `package.json` (fallback if `plugin.meta.name` is not defined) + * @param pluginName - Plugin name (either alias or package name) + * @param pluginNameIsAlias - `true` if plugin name is an alias (takes priority over name that plugin defines itself) * @returns Plugin details or error serialized to JSON string */ -export async function loadPlugin(url: string, packageName: string | null): Promise { +export async function loadPlugin( + url: string, + pluginName: string | null, + pluginNameIsAlias: boolean, +): Promise { try { if (DEBUG) { if (registeredPluginUrls.has(url)) throw new Error("This plugin has already been registered"); @@ -117,7 +123,7 @@ export async function loadPlugin(url: string, packageName: string | null): Promi } const plugin = (await import(url)).default as Plugin; - const res = registerPlugin(plugin, packageName); + const res = registerPlugin(plugin, pluginName, pluginNameIsAlias); return JSON.stringify({ Success: res }); } catch (err) { return JSON.stringify({ Failure: getErrorMessage(err) }); @@ -128,16 +134,21 @@ export async function loadPlugin(url: string, packageName: string | null): Promi * Register a plugin. * * @param plugin - Plugin - * @param packageName - Optional package name from `package.json` (fallback if `plugin.meta.name` is not defined) + * @param pluginName - Plugin name (either alias or package name) + * @param pluginNameIsAlias - `true` if plugin name is an alias (takes priority over name that plugin defines itself) * @returns - Plugin details * @throws {Error} If `plugin.meta.name` is `null` / `undefined` and `packageName` not provided * @throws {TypeError} If one of plugin's rules is malformed, or its `createOnce` method returns invalid visitor * @throws {TypeError} If `plugin.meta.name` is not a string */ -export function registerPlugin(plugin: Plugin, packageName: string | null): PluginDetails { +export function registerPlugin( + plugin: Plugin, + pluginName: string | null, + pluginNameIsAlias: boolean, +): PluginDetails { // TODO: Use a validation library to assert the shape of the plugin, and of rules - const pluginName = getPluginName(plugin, packageName); + pluginName = getPluginName(plugin, pluginName, pluginNameIsAlias); const offset = registeredRules.length; const { rules } = plugin; @@ -282,35 +293,83 @@ export function registerPlugin(plugin: Plugin, packageName: string | null): Plug /** * Get plugin name. + * + * - Plugin is named with an alias in config, return the alias. * - If `plugin.meta.name` is defined, return it. * - Otherwise, fall back to `packageName`, if defined. * - If neither is defined, throw an error. * * @param plugin - Plugin object - * @param packageName - Package name from `package.json` + * @param pluginName - Plugin name (either alias or package name) + * @param pluginNameIsAlias - `true` if plugin name is an alias (takes priority over name that plugin defines itself) * @returns Plugin name * @throws {TypeError} If `plugin.meta.name` is not a string * @throws {Error} If neither `plugin.meta.name` nor `packageName` are defined */ -function getPluginName(plugin: Plugin, packageName: string | null): string { - const pluginMeta = plugin.meta; - if (pluginMeta != null) { - const pluginMetaName = pluginMeta.name; - if (pluginMetaName != null) { - if (typeof pluginMetaName !== "string") { - throw new TypeError("`plugin.meta.name` must be a string if defined"); - } - return pluginMetaName; +function getPluginName( + plugin: Plugin, + pluginName: string | null, + pluginNameIsAlias: boolean, +): string { + // If plugin is defined with an alias in config, that takes priority + if (pluginNameIsAlias) { + debugAssertIsNonNull(pluginName); + return pluginName; + } + + // If plugin defines its own name, that takes priority over package name. + // Normalize plugin name. + const pluginMetaName = plugin.meta?.name; + if (pluginMetaName != null) { + if (typeof pluginMetaName !== "string") { + throw new TypeError("`plugin.meta.name` must be a string if defined"); } + return normalizePluginName(pluginMetaName); } - if (packageName !== null) return packageName; + // Fallback to package name (which is already normalized on Rust side) + if (pluginName !== null) return pluginName; throw new Error( - "Plugin must either define `meta.name`, or be loaded from an NPM package with a `name` field in `package.json`", + "Plugin must either define `meta.name`, be loaded from an NPM package with a `name` field in `package.json`, " + + "or be given an alias in config", ); } +/** + * Normalize plugin name by stripping common ESLint plugin prefixes and suffixes. + * + * This handles the various naming conventions used in the ESLint ecosystem: + * - `eslint-plugin-foo` -> `foo` + * - `@scope/eslint-plugin` -> `@scope` + * - `@scope/eslint-plugin-foo` -> `@scope/foo` + * + * This logic is replicated on Rust side in `normalize_plugin_name` in `crates/oxc_linter/src/config/plugins.rs`. + * The 2 implementations must be kept in sync. + * + * @param name - Plugin name defined by plugin + * @returns Normalized plugin name + */ +function normalizePluginName(name: string): string { + const slashIndex = name.indexOf("/"); + + // If no slash, it's a non-scoped package. Trim off `eslint-plugin-` prefix. + if (slashIndex === -1) { + return name.startsWith("eslint-plugin-") ? name.slice("eslint-plugin-".length) : name; + } + + const scope = name.slice(0, slashIndex), + rest = name.slice(slashIndex + 1); + + // `@scope/eslint-plugin` -> `@scope` + if (rest === "eslint-plugin") return scope; + // `@scope/eslint-plugin-foo` -> `@scope/foo` + if (rest.startsWith("eslint-plugin-")) return `${scope}/${rest.slice("eslint-plugin-".length)}`; + + // No normalization needed + return name; +} + /** * Validate and conform `before` / `after` hook function. * @param hookFn - Hook function, or `null` / `undefined` diff --git a/apps/oxlint/src/js_plugins/external_linter.rs b/apps/oxlint/src/js_plugins/external_linter.rs index 0b9de45db3f53..6059517b58ab4 100644 --- a/apps/oxlint/src/js_plugins/external_linter.rs +++ b/apps/oxlint/src/js_plugins/external_linter.rs @@ -45,11 +45,14 @@ pub enum LoadPluginReturnValue { /// /// The returned function will panic if called outside of a Tokio runtime. fn wrap_load_plugin(cb: JsLoadPluginCb) -> ExternalLinterLoadPluginCb { - Box::new(move |plugin_url, package_name| { + Box::new(move |plugin_url, plugin_name, plugin_name_is_alias| { let cb = &cb; let res = tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async move { - cb.call_async(FnArgs::from((plugin_url, package_name))).await?.into_future().await + cb.call_async(FnArgs::from((plugin_url, plugin_name, plugin_name_is_alias))) + .await? + .into_future() + .await }) }); diff --git a/apps/oxlint/src/run.rs b/apps/oxlint/src/run.rs index 71b119bd98042..d8e76f39377b6 100644 --- a/apps/oxlint/src/run.rs +++ b/apps/oxlint/src/run.rs @@ -20,11 +20,19 @@ use crate::{ #[napi] pub type JsLoadPluginCb = ThreadsafeFunction< // Arguments - FnArgs<(String, Option)>, // Absolute path of plugin file, optional package name + FnArgs<( + // File URL to load plugin from + String, + // Plugin name (either alias or package name). + // If is package name, it is pre-normalized. + Option, + // `true` if plugin name is an alias (takes priority over name that plugin defines itself) + bool, + )>, // Return value Promise, // `PluginLoadResult`, serialized to JSON // Arguments (repeated) - FnArgs<(String, Option)>, + FnArgs<(String, Option, bool)>, // Error status Status, // CalleeHandled diff --git a/apps/oxlint/test/fixtures/plugin_name/.oxlintrc.json b/apps/oxlint/test/fixtures/plugin_name/.oxlintrc.json new file mode 100644 index 0000000000000..388a463631435 --- /dev/null +++ b/apps/oxlint/test/fixtures/plugin_name/.oxlintrc.json @@ -0,0 +1,43 @@ +{ + "plugins": ["jsdoc"], + "jsPlugins": [ + // `plugin.meta.name` overrides package name + "plugin1", + // Package name used where no `plugin.meta.name` + "plugin2", + // `eslint-plugin-` prefix stripped + "eslint-plugin-plugin3", + "eslint-plugin-plugin4", + // `/eslint-plugin` suffix stripped + "@scope/eslint-plugin", + "@scope2/eslint-plugin", + // Plugin name is `jsdoc`, but that's overridden by alias + { "name": "js-jsdoc", "specifier": "./plugins/jsdoc.ts" }, + { "name": "js-jsdoc2", "specifier": "jsdoc" }, + // Plugin defines no `plugin.meta.name`, but not an error due to alias + { "name": "no-name-alias", "specifier": "./plugins/no_name.ts" }, + { "name": "no-name-alias2", "specifier": "no_name" } + ], + "categories": { "correctness": "off" }, + "rules": { + // `plugin1` + "plugin1-name-from-rule/rule": "error", + // `plugin2` + "plugin2-name-from-package/rule": "error", + // `eslint-plugin-plugin3` + "eslint-plugin-plugin3-name-from-package/rule": "error", + // `eslint-plugin-plugin4` + "eslint-plugin-plugin4-name-from-rule/rule": "error", + // `@scope/eslint-plugin` + "@scope/rule": "error", + // `@scope2/eslint-plugin` + "@scope2/rule": "error", + // Aliased + "js-jsdoc/rule": "error", + "js-jsdoc2/rule": "error", + "no-name-alias/rule": "error", + "no-name-alias2/rule": "error", + // Native `jsdoc` rule still works + "jsdoc/require-param": "error" + } +} diff --git a/apps/oxlint/test/fixtures/plugin_name/files/index.js b/apps/oxlint/test/fixtures/plugin_name/files/index.js new file mode 100644 index 0000000000000..d97105eacd7e5 --- /dev/null +++ b/apps/oxlint/test/fixtures/plugin_name/files/index.js @@ -0,0 +1,4 @@ +/** + * @param foo + */ +function f(foo, bar) {} diff --git a/apps/oxlint/test/fixtures/plugin_name/node_modules/@scope/eslint-plugin/index.js b/apps/oxlint/test/fixtures/plugin_name/node_modules/@scope/eslint-plugin/index.js new file mode 100644 index 0000000000000..fafa7f400b3cf --- /dev/null +++ b/apps/oxlint/test/fixtures/plugin_name/node_modules/@scope/eslint-plugin/index.js @@ -0,0 +1,17 @@ +export default { + // No name defined + rules: { + rule: { + create(context) { + return { + FunctionDeclaration(node) { + context.report({ + message: `id: ${context.id}`, + node, + }); + }, + }; + }, + }, + }, +}; diff --git a/apps/oxlint/test/fixtures/plugin_name/node_modules/@scope/eslint-plugin/package.json b/apps/oxlint/test/fixtures/plugin_name/node_modules/@scope/eslint-plugin/package.json new file mode 100644 index 0000000000000..cbf3905758231 --- /dev/null +++ b/apps/oxlint/test/fixtures/plugin_name/node_modules/@scope/eslint-plugin/package.json @@ -0,0 +1,5 @@ +{ + "name": "@scope/eslint-plugin", + "type": "module", + "main": "index.js" +} diff --git a/apps/oxlint/test/fixtures/plugin_name/node_modules/@scope2/eslint-plugin/index.js b/apps/oxlint/test/fixtures/plugin_name/node_modules/@scope2/eslint-plugin/index.js new file mode 100644 index 0000000000000..093178a838cf5 --- /dev/null +++ b/apps/oxlint/test/fixtures/plugin_name/node_modules/@scope2/eslint-plugin/index.js @@ -0,0 +1,19 @@ +export default { + meta: { + name: "@scope2/eslint-plugin", + }, + rules: { + rule: { + create(context) { + return { + FunctionDeclaration(node) { + context.report({ + message: `id: ${context.id}`, + node, + }); + }, + }; + }, + }, + }, +}; diff --git a/apps/oxlint/test/fixtures/plugin_name/node_modules/@scope2/eslint-plugin/package.json b/apps/oxlint/test/fixtures/plugin_name/node_modules/@scope2/eslint-plugin/package.json new file mode 100644 index 0000000000000..07aec65d5a4f3 --- /dev/null +++ b/apps/oxlint/test/fixtures/plugin_name/node_modules/@scope2/eslint-plugin/package.json @@ -0,0 +1,4 @@ +{ + "type": "module", + "main": "index.js" +} diff --git a/apps/oxlint/test/fixtures/plugin_name/node_modules/eslint-plugin-plugin3/index.js b/apps/oxlint/test/fixtures/plugin_name/node_modules/eslint-plugin-plugin3/index.js new file mode 100644 index 0000000000000..fafa7f400b3cf --- /dev/null +++ b/apps/oxlint/test/fixtures/plugin_name/node_modules/eslint-plugin-plugin3/index.js @@ -0,0 +1,17 @@ +export default { + // No name defined + rules: { + rule: { + create(context) { + return { + FunctionDeclaration(node) { + context.report({ + message: `id: ${context.id}`, + node, + }); + }, + }; + }, + }, + }, +}; diff --git a/apps/oxlint/test/fixtures/plugin_name/node_modules/eslint-plugin-plugin3/package.json b/apps/oxlint/test/fixtures/plugin_name/node_modules/eslint-plugin-plugin3/package.json new file mode 100644 index 0000000000000..cb106dedf4762 --- /dev/null +++ b/apps/oxlint/test/fixtures/plugin_name/node_modules/eslint-plugin-plugin3/package.json @@ -0,0 +1,5 @@ +{ + "name": "eslint-plugin-plugin3-name-from-package", + "type": "module", + "main": "index.js" +} diff --git a/apps/oxlint/test/fixtures/plugin_name/node_modules/eslint-plugin-plugin4/index.js b/apps/oxlint/test/fixtures/plugin_name/node_modules/eslint-plugin-plugin4/index.js new file mode 100644 index 0000000000000..aac7417855668 --- /dev/null +++ b/apps/oxlint/test/fixtures/plugin_name/node_modules/eslint-plugin-plugin4/index.js @@ -0,0 +1,19 @@ +export default { + meta: { + name: "eslint-plugin-plugin4-name-from-rule", + }, + rules: { + rule: { + create(context) { + return { + FunctionDeclaration(node) { + context.report({ + message: `id: ${context.id}`, + node, + }); + }, + }; + }, + }, + }, +}; diff --git a/apps/oxlint/test/fixtures/plugin_name/node_modules/eslint-plugin-plugin4/package.json b/apps/oxlint/test/fixtures/plugin_name/node_modules/eslint-plugin-plugin4/package.json new file mode 100644 index 0000000000000..07aec65d5a4f3 --- /dev/null +++ b/apps/oxlint/test/fixtures/plugin_name/node_modules/eslint-plugin-plugin4/package.json @@ -0,0 +1,4 @@ +{ + "type": "module", + "main": "index.js" +} diff --git a/apps/oxlint/test/fixtures/plugin_name/node_modules/jsdoc/index.js b/apps/oxlint/test/fixtures/plugin_name/node_modules/jsdoc/index.js new file mode 100644 index 0000000000000..ebde4412829c1 --- /dev/null +++ b/apps/oxlint/test/fixtures/plugin_name/node_modules/jsdoc/index.js @@ -0,0 +1,19 @@ +export default { + meta: { + name: "jsdoc", + }, + rules: { + rule: { + create(context) { + return { + FunctionDeclaration(node) { + context.report({ + message: `id: ${context.id}`, + node, + }); + }, + }; + }, + }, + }, +}; diff --git a/apps/oxlint/test/fixtures/plugin_name/node_modules/jsdoc/package.json b/apps/oxlint/test/fixtures/plugin_name/node_modules/jsdoc/package.json new file mode 100644 index 0000000000000..0e1c17582f4bc --- /dev/null +++ b/apps/oxlint/test/fixtures/plugin_name/node_modules/jsdoc/package.json @@ -0,0 +1,5 @@ +{ + "name": "jsdoc", + "type": "module", + "main": "index.js" +} diff --git a/apps/oxlint/test/fixtures/plugin_name/node_modules/no_name/index.js b/apps/oxlint/test/fixtures/plugin_name/node_modules/no_name/index.js new file mode 100644 index 0000000000000..fafa7f400b3cf --- /dev/null +++ b/apps/oxlint/test/fixtures/plugin_name/node_modules/no_name/index.js @@ -0,0 +1,17 @@ +export default { + // No name defined + rules: { + rule: { + create(context) { + return { + FunctionDeclaration(node) { + context.report({ + message: `id: ${context.id}`, + node, + }); + }, + }; + }, + }, + }, +}; diff --git a/apps/oxlint/test/fixtures/plugin_name/node_modules/no_name/package.json b/apps/oxlint/test/fixtures/plugin_name/node_modules/no_name/package.json new file mode 100644 index 0000000000000..07aec65d5a4f3 --- /dev/null +++ b/apps/oxlint/test/fixtures/plugin_name/node_modules/no_name/package.json @@ -0,0 +1,4 @@ +{ + "type": "module", + "main": "index.js" +} diff --git a/apps/oxlint/test/fixtures/plugin_name/node_modules/plugin1/index.js b/apps/oxlint/test/fixtures/plugin_name/node_modules/plugin1/index.js new file mode 100644 index 0000000000000..85bacba5445fc --- /dev/null +++ b/apps/oxlint/test/fixtures/plugin_name/node_modules/plugin1/index.js @@ -0,0 +1,19 @@ +export default { + meta: { + name: "plugin1-name-from-rule", + }, + rules: { + rule: { + create(context) { + return { + FunctionDeclaration(node) { + context.report({ + message: `id: ${context.id}`, + node, + }); + }, + }; + }, + }, + }, +}; diff --git a/apps/oxlint/test/fixtures/plugin_name/node_modules/plugin1/package.json b/apps/oxlint/test/fixtures/plugin_name/node_modules/plugin1/package.json new file mode 100644 index 0000000000000..d41e8e01af5ce --- /dev/null +++ b/apps/oxlint/test/fixtures/plugin_name/node_modules/plugin1/package.json @@ -0,0 +1,5 @@ +{ + "name": "plugin1-name-from-package", + "type": "module", + "main": "index.js" +} diff --git a/apps/oxlint/test/fixtures/plugin_name/node_modules/plugin2/index.js b/apps/oxlint/test/fixtures/plugin_name/node_modules/plugin2/index.js new file mode 100644 index 0000000000000..fafa7f400b3cf --- /dev/null +++ b/apps/oxlint/test/fixtures/plugin_name/node_modules/plugin2/index.js @@ -0,0 +1,17 @@ +export default { + // No name defined + rules: { + rule: { + create(context) { + return { + FunctionDeclaration(node) { + context.report({ + message: `id: ${context.id}`, + node, + }); + }, + }; + }, + }, + }, +}; diff --git a/apps/oxlint/test/fixtures/plugin_name/node_modules/plugin2/package.json b/apps/oxlint/test/fixtures/plugin_name/node_modules/plugin2/package.json new file mode 100644 index 0000000000000..d1023fea70548 --- /dev/null +++ b/apps/oxlint/test/fixtures/plugin_name/node_modules/plugin2/package.json @@ -0,0 +1,5 @@ +{ + "name": "plugin2-name-from-package", + "type": "module", + "main": "index.js" +} diff --git a/apps/oxlint/test/fixtures/plugin_name/output.snap.md b/apps/oxlint/test/fixtures/plugin_name/output.snap.md new file mode 100644 index 0000000000000..3d37037e529e4 --- /dev/null +++ b/apps/oxlint/test/fixtures/plugin_name/output.snap.md @@ -0,0 +1,92 @@ +# Exit code +1 + +# stdout +``` + x @scope(rule): id: @scope/rule + ,-[files/index.js:4:1] + 3 | */ + 4 | function f(foo, bar) {} + : ^^^^^^^^^^^^^^^^^^^^^^^ + `---- + + x @scope2(rule): id: @scope2/rule + ,-[files/index.js:4:1] + 3 | */ + 4 | function f(foo, bar) {} + : ^^^^^^^^^^^^^^^^^^^^^^^ + `---- + + x js-jsdoc(rule): id: js-jsdoc/rule + ,-[files/index.js:4:1] + 3 | */ + 4 | function f(foo, bar) {} + : ^^^^^^^^^^^^^^^^^^^^^^^ + `---- + + x js-jsdoc2(rule): id: js-jsdoc2/rule + ,-[files/index.js:4:1] + 3 | */ + 4 | function f(foo, bar) {} + : ^^^^^^^^^^^^^^^^^^^^^^^ + `---- + + x no-name-alias(rule): id: no-name-alias/rule + ,-[files/index.js:4:1] + 3 | */ + 4 | function f(foo, bar) {} + : ^^^^^^^^^^^^^^^^^^^^^^^ + `---- + + x no-name-alias2(rule): id: no-name-alias2/rule + ,-[files/index.js:4:1] + 3 | */ + 4 | function f(foo, bar) {} + : ^^^^^^^^^^^^^^^^^^^^^^^ + `---- + + x plugin1-name-from-rule(rule): id: plugin1-name-from-rule/rule + ,-[files/index.js:4:1] + 3 | */ + 4 | function f(foo, bar) {} + : ^^^^^^^^^^^^^^^^^^^^^^^ + `---- + + x plugin2-name-from-package(rule): id: plugin2-name-from-package/rule + ,-[files/index.js:4:1] + 3 | */ + 4 | function f(foo, bar) {} + : ^^^^^^^^^^^^^^^^^^^^^^^ + `---- + + x plugin3-name-from-package(rule): id: plugin3-name-from-package/rule + ,-[files/index.js:4:1] + 3 | */ + 4 | function f(foo, bar) {} + : ^^^^^^^^^^^^^^^^^^^^^^^ + `---- + + x plugin4-name-from-rule(rule): id: plugin4-name-from-rule/rule + ,-[files/index.js:4:1] + 3 | */ + 4 | function f(foo, bar) {} + : ^^^^^^^^^^^^^^^^^^^^^^^ + `---- + + x ]8;;https://oxc.rs/docs/guide/usage/linter/rules/jsdoc/require-param.html\eslint-plugin-jsdoc(require-param)]8;;\: Missing JSDoc `@param` declaration for function parameters. + ,-[files/index.js:4:17] + 3 | */ + 4 | function f(foo, bar) {} + : ^^^ + `---- + help: Add `@param` tag with name. + +Found 0 warnings and 11 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/plugin_name_alias/plugin.ts b/apps/oxlint/test/fixtures/plugin_name/plugins/jsdoc.ts similarity index 55% rename from apps/oxlint/test/fixtures/plugin_name_alias/plugin.ts rename to apps/oxlint/test/fixtures/plugin_name/plugins/jsdoc.ts index 829e10fce2dbe..198615c806123 100644 --- a/apps/oxlint/test/fixtures/plugin_name_alias/plugin.ts +++ b/apps/oxlint/test/fixtures/plugin_name/plugins/jsdoc.ts @@ -2,16 +2,17 @@ import type { Plugin } from "#oxlint"; const plugin: Plugin = { meta: { - name: "legal-plugin-name", + // Name is overridden by alias in config + name: "jsdoc", }, rules: { - "no-debugger": { + rule: { create(context) { return { - DebuggerStatement(debuggerStatement) { + FunctionDeclaration(node) { context.report({ - message: "Unexpected Debugger Statement", - node: debuggerStatement, + message: `id: ${context.id}`, + node, }); }, }; diff --git a/apps/oxlint/test/fixtures/plugin_name/plugins/no_name.ts b/apps/oxlint/test/fixtures/plugin_name/plugins/no_name.ts new file mode 100644 index 0000000000000..e4b9689ca8217 --- /dev/null +++ b/apps/oxlint/test/fixtures/plugin_name/plugins/no_name.ts @@ -0,0 +1,21 @@ +import type { Plugin } from "#oxlint"; + +const plugin: Plugin = { + // No name defined + rules: { + rule: { + create(context) { + return { + FunctionDeclaration(node) { + context.report({ + message: `id: ${context.id}`, + node, + }); + }, + }; + }, + }, + }, +}; + +export default plugin; diff --git a/apps/oxlint/test/fixtures/plugin_name_alias/.oxlintrc.json b/apps/oxlint/test/fixtures/plugin_name_alias/.oxlintrc.json deleted file mode 100644 index 5ed061e7c1a0b..0000000000000 --- a/apps/oxlint/test/fixtures/plugin_name_alias/.oxlintrc.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "plugins": ["jsdoc"], - "jsPlugins": [{ "name": "jsPluginJsDoc", "specifier": "./plugin.ts" }], - "categories": { "correctness": "off" }, - "rules": { - "jsPluginJsDoc/no-debugger": "error", - "jsdoc/rule-name": "error" - } -} diff --git a/apps/oxlint/test/fixtures/plugin_name_alias/files/index.js b/apps/oxlint/test/fixtures/plugin_name_alias/files/index.js deleted file mode 100644 index eab74692130a6..0000000000000 --- a/apps/oxlint/test/fixtures/plugin_name_alias/files/index.js +++ /dev/null @@ -1 +0,0 @@ -debugger; diff --git a/apps/oxlint/test/fixtures/plugin_name_alias/output.snap.md b/apps/oxlint/test/fixtures/plugin_name_alias/output.snap.md deleted file mode 100644 index 8548e1827b3fb..0000000000000 --- a/apps/oxlint/test/fixtures/plugin_name_alias/output.snap.md +++ /dev/null @@ -1,20 +0,0 @@ -# Exit code -1 - -# stdout -``` - x jsPluginJsDoc(no-debugger): Unexpected Debugger Statement - ,-[files/index.js:1:1] - 1 | debugger; - : ^^^^^^^^^ - `---- - -Found 0 warnings and 1 error. -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/plugin_name_missing/.oxlintrc.json b/apps/oxlint/test/fixtures/plugin_name_missing/.oxlintrc.json new file mode 100644 index 0000000000000..7e7e1cbeee829 --- /dev/null +++ b/apps/oxlint/test/fixtures/plugin_name_missing/.oxlintrc.json @@ -0,0 +1,3 @@ +{ + "jsPlugins": ["./plugin.ts"] +} diff --git a/apps/oxlint/test/fixtures/plugin_name_missing/output.snap.md b/apps/oxlint/test/fixtures/plugin_name_missing/output.snap.md new file mode 100644 index 0000000000000..8aaf505fcccf9 --- /dev/null +++ b/apps/oxlint/test/fixtures/plugin_name_missing/output.snap.md @@ -0,0 +1,16 @@ +# Exit code +1 + +# stdout +``` +Failed to parse configuration file. + + x Failed to load JS plugin: ./plugin.ts + | Error: Plugin must either define `meta.name`, be loaded from an NPM package with a `name` field in `package.json`, or be given an alias in config +``` + +# 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/plugin_name_missing/plugin.ts b/apps/oxlint/test/fixtures/plugin_name_missing/plugin.ts new file mode 100644 index 0000000000000..003633db9f5b0 --- /dev/null +++ b/apps/oxlint/test/fixtures/plugin_name_missing/plugin.ts @@ -0,0 +1,21 @@ +import type { Plugin } from "#oxlint"; + +const plugin: Plugin = { + // No name defined + rules: { + rule: { + create(context) { + return { + Program(node) { + context.report({ + message: `filename: ${context.filename}`, + node, + }); + }, + }; + }, + }, + }, +}; + +export default plugin; diff --git a/apps/oxlint/test/fixtures/plugin_name_missing2/.oxlintrc.json b/apps/oxlint/test/fixtures/plugin_name_missing2/.oxlintrc.json new file mode 100644 index 0000000000000..8c0ab655c0995 --- /dev/null +++ b/apps/oxlint/test/fixtures/plugin_name_missing2/.oxlintrc.json @@ -0,0 +1,3 @@ +{ + "jsPlugins": ["no_name"] +} diff --git a/apps/oxlint/test/fixtures/plugin_name_missing2/node_modules/no_name/index.js b/apps/oxlint/test/fixtures/plugin_name_missing2/node_modules/no_name/index.js new file mode 100644 index 0000000000000..fafa7f400b3cf --- /dev/null +++ b/apps/oxlint/test/fixtures/plugin_name_missing2/node_modules/no_name/index.js @@ -0,0 +1,17 @@ +export default { + // No name defined + rules: { + rule: { + create(context) { + return { + FunctionDeclaration(node) { + context.report({ + message: `id: ${context.id}`, + node, + }); + }, + }; + }, + }, + }, +}; diff --git a/apps/oxlint/test/fixtures/plugin_name_missing2/node_modules/no_name/package.json b/apps/oxlint/test/fixtures/plugin_name_missing2/node_modules/no_name/package.json new file mode 100644 index 0000000000000..07aec65d5a4f3 --- /dev/null +++ b/apps/oxlint/test/fixtures/plugin_name_missing2/node_modules/no_name/package.json @@ -0,0 +1,4 @@ +{ + "type": "module", + "main": "index.js" +} diff --git a/apps/oxlint/test/fixtures/plugin_name_missing2/output.snap.md b/apps/oxlint/test/fixtures/plugin_name_missing2/output.snap.md new file mode 100644 index 0000000000000..014d12861c9f4 --- /dev/null +++ b/apps/oxlint/test/fixtures/plugin_name_missing2/output.snap.md @@ -0,0 +1,16 @@ +# Exit code +1 + +# stdout +``` +Failed to parse configuration file. + + x Failed to load JS plugin: no_name + | Error: Plugin must either define `meta.name`, be loaded from an NPM package with a `name` field in `package.json`, or be given an alias in config +``` + +# 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/crates/oxc_linter/src/config/config_builder.rs b/crates/oxc_linter/src/config/config_builder.rs index 9897de2d147f0..172955f8e89bf 100644 --- a/crates/oxc_linter/src/config/config_builder.rs +++ b/crates/oxc_linter/src/config/config_builder.rs @@ -1,6 +1,6 @@ use std::{ fmt::{self, Debug, Display}, - path::{Path, PathBuf}, + path::{Component as PathComponent, Path, PathBuf}, }; use itertools::Itertools; @@ -550,27 +550,33 @@ impl ConfigStoreBuilder { return Ok(()); } - // Extract package name from `package.json` if available - let package_name = resolved.package_json().and_then(|pkg| pkg.name().map(String::from)); + // Get plugin name. + // Use alias if provided. + // Otherwise use package name if the specifier is not relative, and normalize it. + let plugin_name = if let Some(alias_name) = alias { + Some(alias_name.to_string()) + } else if let Some(pkg) = resolved.package_json() + && let Some(package_name) = pkg.name() + && !matches!( + Path::new(plugin_specifier).components().next(), + Some(PathComponent::CurDir | PathComponent::ParentDir) + ) + { + Some(normalize_plugin_name(package_name).into_owned()) + } else { + None + }; // Convert path to a `file://...` URL, as required by `import(...)` on JS side. // Note: `unwrap()` here is infallible as `plugin_path` is an absolute path. let plugin_url = Url::from_file_path(&plugin_path).unwrap().as_str().to_string(); - let result = (external_linter.load_plugin)(plugin_url, package_name).map_err(|error| { - ConfigBuilderError::PluginLoadFailed { + let result = (external_linter.load_plugin)(plugin_url, plugin_name, alias.is_some()) + .map_err(|error| ConfigBuilderError::PluginLoadFailed { plugin_specifier: plugin_specifier.to_string(), error, - } - })?; - - // Use alias if provided, otherwise normalize plugin name - let plugin_name = if let Some(alias_name) = alias { - alias_name.to_string() - } else { - // Normalize plugin name (e.g., "eslint-plugin-foo" -> "foo", "@foo/eslint-plugin" -> "@foo") - normalize_plugin_name(&result.name).into_owned() - }; + })?; + let plugin_name = result.name; if LintPlugins::try_from(plugin_name.as_str()).is_err() { external_plugin_store.register_plugin( diff --git a/crates/oxc_linter/src/config/plugins.rs b/crates/oxc_linter/src/config/plugins.rs index f30180e5b5265..6036a54523a9c 100644 --- a/crates/oxc_linter/src/config/plugins.rs +++ b/crates/oxc_linter/src/config/plugins.rs @@ -11,6 +11,9 @@ use serde::{Deserialize, Serialize, de::Deserializer, ser::Serializer}; /// - `@scope/eslint-plugin` → `@scope` /// - `@scope/eslint-plugin-foo` → `@scope/foo` /// +/// This logic is replicated on JS side in `normalizePluginName` in `apps/oxlint/src-js/plugins/load.ts`. +/// The 2 implementations must be kept in sync. +/// /// # Examples /// /// ``` diff --git a/crates/oxc_linter/src/external_linter.rs b/crates/oxc_linter/src/external_linter.rs index 99913b57adc32..c972f2aacca35 100644 --- a/crates/oxc_linter/src/external_linter.rs +++ b/crates/oxc_linter/src/external_linter.rs @@ -4,8 +4,19 @@ use serde::Deserialize; use oxc_allocator::Allocator; -pub type ExternalLinterLoadPluginCb = - Box) -> Result + Send + Sync>; +pub type ExternalLinterLoadPluginCb = Box< + dyn Fn( + // File URL to load plugin from + String, + // Plugin name (either alias or package name). + // If is package name, it is pre-normalized. + Option, + // `true` if plugin name is an alias (takes priority over name that plugin defines itself) + bool, + ) -> Result + + Send + + Sync, +>; pub type ExternalLinterSetupConfigsCb = Box Result<(), String> + Send + Sync>;