diff --git a/apps/oxlint/src-js/bindings.d.ts b/apps/oxlint/src-js/bindings.d.ts index 93921c1f0a557..25b850b162e1f 100644 --- a/apps/oxlint/src-js/bindings.d.ts +++ b/apps/oxlint/src-js/bindings.d.ts @@ -2,20 +2,25 @@ /* eslint-disable */ /** JS callback to lint a file. */ export type JsLintFileCb = - ((arg0: string, arg1: number, arg2: Uint8Array | undefined | null, arg3: Array, arg4: string) => string) + ((arg0: string, arg1: number, arg2: Uint8Array | undefined | null, arg3: Array, arg4: Array, arg5: string) => string) /** JS callback to load a JS plugin. */ export type JsLoadPluginCb = ((arg0: string, arg1?: string | undefined | null) => Promise) +/** JS callback to setup configs. */ +export type JsSetupConfigsCb = + ((arg0: string) => string) + /** * NAPI entry point. * * JS side passes in: * 1. `args`: Command line arguments (process.argv.slice(2)) * 2. `load_plugin`: Load a JS plugin from a file path. - * 3. `lint_file`: Lint a file. + * 3. `setup_configs`: Setup configuration options. + * 4. `lint_file`: Lint a file. * * Returns `true` if linting succeeded without errors, `false` otherwise. */ -export declare function lint(args: Array, loadPlugin: JsLoadPluginCb, lintFile: JsLintFileCb): Promise +export declare function lint(args: Array, loadPlugin: JsLoadPluginCb, setupConfigs: JsSetupConfigsCb, lintFile: JsLintFileCb): Promise diff --git a/apps/oxlint/src-js/cli.ts b/apps/oxlint/src-js/cli.ts index 34450e0f6c0c9..2ed717154693c 100644 --- a/apps/oxlint/src-js/cli.ts +++ b/apps/oxlint/src-js/cli.ts @@ -5,6 +5,7 @@ import { debugAssertIsNonNull } from "./utils/asserts.js"; // Using `typeof wrapper` here makes TS check that the function signatures of `loadPlugin` and `loadPluginWrapper` // are identical. Ditto `lintFile` and `lintFileWrapper`. let loadPlugin: typeof loadPluginWrapper | null = null; +let setupConfigs: typeof setupConfigsWrapper | null = null; let lintFile: typeof lintFileWrapper | null = null; /** @@ -21,7 +22,7 @@ function loadPluginWrapper(path: string, packageName: string | null): Promise { - ({ loadPlugin, lintFile } = mod); + ({ loadPlugin, lintFile, setupConfigs } = mod); return loadPlugin(path, packageName); }); } @@ -29,6 +30,19 @@ function loadPluginWrapper(path: string, packageName: string | null): Promise = Object.freeze({}); * @param bufferId - ID of buffer containing file data * @param buffer - Buffer containing file data, or `null` if buffer with this ID was previously sent to JS * @param ruleIds - IDs of rules to run on this file + * @param optionsIds - IDs of options to use for rules on this file * @param settingsJSON - Settings for file, as JSON * @returns Diagnostics or error serialized to JSON string */ @@ -54,11 +55,9 @@ export function lintFile( bufferId: number, buffer: Uint8Array | null, ruleIds: number[], + optionsIds: number[], settingsJSON: string, ): string { - // TODO: Get `optionsIds` from Rust side - const optionsIds = ruleIds.map((_) => DEFAULT_OPTIONS_ID); - try { lintFileImpl(filePath, bufferId, buffer, ruleIds, optionsIds, settingsJSON); return JSON.stringify({ Success: diagnostics }); @@ -156,6 +155,9 @@ function lintFileImpl( // Set `options` for rule const optionsId = optionsIds[i]; debugAssert(optionsId < allOptions.length, "Options ID out of bounds"); + + // If the rule has no user-provided options, use the plugin-provided default + // options (which falls back to `DEFAULT_OPTIONS`) ruleDetails.options = optionsId === DEFAULT_OPTIONS_ID ? ruleDetails.defaultOptions : allOptions[optionsId]; diff --git a/apps/oxlint/src-js/plugins/options.ts b/apps/oxlint/src-js/plugins/options.ts index fdb70c038a14b..b85e9b2e95264 100644 --- a/apps/oxlint/src-js/plugins/options.ts +++ b/apps/oxlint/src-js/plugins/options.ts @@ -22,8 +22,10 @@ export type Options = JsonValue[]; // Default rule options export const DEFAULT_OPTIONS: Readonly = Object.freeze([]); -// All rule options -export const allOptions: Readonly[] = [DEFAULT_OPTIONS]; +// All rule options. +// Indexed by options ID sent alongside ruleId for each file. +// Element 0 is always the default options (empty array). +export let allOptions: Readonly[] = [DEFAULT_OPTIONS]; // Index into `allOptions` for default options export const DEFAULT_OPTIONS_ID = 0; @@ -128,3 +130,23 @@ function mergeValues(configValue: JsonValue, defaultValue: JsonValue): JsonValue return freeze(merged); } + +/** + * Set all external rule options. + * Called once from Rust after config building, before any linting occurs. + * @param optionsJson - JSON string of outer array of per-options arrays. + */ +export function setOptions(optionsJson: string): void { + const parsed = JSON.parse(optionsJson); + if (DEBUG) { + if (!isArray(parsed)) + throw new TypeError("Expected optionsJson to decode to an array", { cause: parsed }); + // Basic shape validation: each element must be an array (options tuple array) + for (let i = 0; i < parsed.length; i++) { + const el = parsed[i]; + if (!isArray(el)) throw new TypeError("Each options entry must be an array", { cause: el }); + } + } + deepFreezeArray(parsed); + allOptions = parsed as Readonly[]; +} diff --git a/apps/oxlint/src/js_plugins/external_linter.rs b/apps/oxlint/src/js_plugins/external_linter.rs index 59e6bd5c9f24d..4d3a85e5a1108 100644 --- a/apps/oxlint/src/js_plugins/external_linter.rs +++ b/apps/oxlint/src/js_plugins/external_linter.rs @@ -15,18 +15,20 @@ use oxc_linter::{ use crate::{ generated::raw_transfer_constants::{BLOCK_ALIGN, BUFFER_SIZE}, - run::{JsLintFileCb, JsLoadPluginCb}, + run::{JsLintFileCb, JsLoadPluginCb, JsSetupConfigsCb}, }; /// Wrap JS callbacks as normal Rust functions, and create [`ExternalLinter`]. pub fn create_external_linter( load_plugin: JsLoadPluginCb, + setup_configs: JsSetupConfigsCb, lint_file: JsLintFileCb, ) -> ExternalLinter { let rust_load_plugin = wrap_load_plugin(load_plugin); + let rust_setup_configs = wrap_setup_configs(setup_configs); let rust_lint_file = wrap_lint_file(lint_file); - ExternalLinter::new(rust_load_plugin, rust_lint_file) + ExternalLinter::new(rust_load_plugin, rust_setup_configs, rust_lint_file) } /// Wrap `loadPlugin` JS callback as a normal Rust function. @@ -59,6 +61,45 @@ pub enum LintFileReturnValue { Failure(String), } +/// Wrap `setupConfigs` JS callback as a normal Rust function. +/// +/// Use an `mpsc::channel` to wait for the result from JS side, and block current thread until `setupConfigs` +/// completes execution. +fn wrap_setup_configs( + cb: JsSetupConfigsCb, +) -> Box Result<(), String> + Send + Sync> { + Box::new(move |options_json: String| { + let (tx, rx) = channel(); + + // Send data to JS + let status = cb.call_with_return_value( + FnArgs::from((options_json,)), + ThreadsafeFunctionCallMode::Blocking, + move |result, _env| { + let _ = match &result { + Ok(r) => tx.send(Ok(r.clone())), + Err(e) => tx.send(Err(e.to_string())), + }; + result.map(|_| ()) + }, + ); + + assert!(status == Status::Ok, "Failed to schedule setupConfigs callback: {status:?}"); + + match rx.recv() { + Ok(Ok(result)) => { + if result == "ok" { + Ok(()) + } else { + Err(result) + } + } + Ok(Err(err)) => Err(err), + Err(err) => panic!("setupConfigs callback did not respond: {err}"), + } + }) +} + /// Wrap `lintFile` JS callback as a normal Rust function. /// /// The returned function creates a `Uint8Array` referencing the memory of the given `Allocator`, @@ -72,6 +113,7 @@ fn wrap_lint_file(cb: JsLintFileCb) -> ExternalLinterLintFileCb { Box::new( move |file_path: String, rule_ids: Vec, + options_ids: Vec, settings_json: String, allocator: &Allocator| { let (tx, rx) = channel(); @@ -87,7 +129,7 @@ fn wrap_lint_file(cb: JsLintFileCb) -> ExternalLinterLintFileCb { // Send data to JS let status = cb.call_with_return_value( - FnArgs::from((file_path, buffer_id, buffer, rule_ids, settings_json)), + FnArgs::from((file_path, buffer_id, buffer, rule_ids, options_ids, settings_json)), ThreadsafeFunctionCallMode::NonBlocking, move |result, _env| { let _ = match &result { diff --git a/apps/oxlint/src/lint.rs b/apps/oxlint/src/lint.rs index 07e13aa61e1ee..fa4132b88f030 100644 --- a/apps/oxlint/src/lint.rs +++ b/apps/oxlint/src/lint.rs @@ -277,7 +277,7 @@ impl CliRunner { || nested_configs.values().any(|config| config.plugins().has_import()); let mut options = LintServiceOptions::new(self.cwd).with_cross_module(use_cross_module); - let lint_config = match config_builder.build(&external_plugin_store) { + let lint_config = match config_builder.build(&mut external_plugin_store) { Ok(config) => config, Err(e) => { print_and_flush_stdout( @@ -291,6 +291,18 @@ impl CliRunner { } }; + // TODO: refactor this elsewhere. + // This code is in the oxlint app, not in oxc_linter crate + if let Some(ref external_linter) = external_linter + && let Err(err) = external_plugin_store.setup_configs(external_linter) + { + print_and_flush_stdout( + stdout, + &format!("Failed to setup external plugin options: {err}\n"), + ); + return CliRunResult::InvalidOptionConfig; + } + let report_unused_directives = match inline_config_options.report_unused_directives { ReportUnusedDirectives::WithoutSeverity(true) => Some(AllowWarnDeny::Warn), ReportUnusedDirectives::WithSeverity(Some(severity)) => Some(severity), diff --git a/apps/oxlint/src/run.rs b/apps/oxlint/src/run.rs index 526c39a65cd3d..1790efdd1a136 100644 --- a/apps/oxlint/src/run.rs +++ b/apps/oxlint/src/run.rs @@ -40,12 +40,28 @@ pub type JsLintFileCb = ThreadsafeFunction< u32, // Buffer ID Option, // Buffer (optional) Vec, // Array of rule IDs + Vec, // Array of options IDs String, // Stringified settings effective for the file )>, // Return value String, // `Vec`, serialized to JSON // Arguments (repeated) - FnArgs<(String, u32, Option, Vec, String)>, + FnArgs<(String, u32, Option, Vec, Vec, String)>, + // Error status + Status, + // CalleeHandled + false, +>; + +/// JS callback to setup configs. +#[napi] +pub type JsSetupConfigsCb = ThreadsafeFunction< + // Arguments + FnArgs<(String,)>, // Stringified options array + // Return value + String, // Result ("ok" or error message) + // Arguments (repeated) + FnArgs<(String,)>, // Error status Status, // CalleeHandled @@ -57,20 +73,27 @@ pub type JsLintFileCb = ThreadsafeFunction< /// JS side passes in: /// 1. `args`: Command line arguments (process.argv.slice(2)) /// 2. `load_plugin`: Load a JS plugin from a file path. -/// 3. `lint_file`: Lint a file. +/// 3. `setup_configs`: Setup configuration options. +/// 4. `lint_file`: Lint a file. /// /// Returns `true` if linting succeeded without errors, `false` otherwise. #[expect(clippy::allow_attributes)] #[allow(clippy::trailing_empty_array, clippy::unused_async)] // https://github.com/napi-rs/napi-rs/issues/2758 #[napi] -pub async fn lint(args: Vec, load_plugin: JsLoadPluginCb, lint_file: JsLintFileCb) -> bool { - lint_impl(args, load_plugin, lint_file).await.report() == ExitCode::SUCCESS +pub async fn lint( + args: Vec, + load_plugin: JsLoadPluginCb, + setup_configs: JsSetupConfigsCb, + lint_file: JsLintFileCb, +) -> bool { + lint_impl(args, load_plugin, setup_configs, lint_file).await.report() == ExitCode::SUCCESS } /// Run the linter. async fn lint_impl( args: Vec, load_plugin: JsLoadPluginCb, + setup_configs: JsSetupConfigsCb, lint_file: JsLintFileCb, ) -> CliRunResult { // Convert String args to OsString for compatibility with bpaf @@ -104,10 +127,11 @@ async fn lint_impl( // JS plugins are only supported on 64-bit little-endian platforms at present #[cfg(all(target_pointer_width = "64", target_endian = "little"))] - let external_linter = Some(crate::js_plugins::create_external_linter(load_plugin, lint_file)); + let external_linter = + Some(crate::js_plugins::create_external_linter(load_plugin, setup_configs, lint_file)); #[cfg(not(all(target_pointer_width = "64", target_endian = "little")))] let external_linter = { - let (_, _) = (load_plugin, lint_file); + let (_, _, _) = (load_plugin, setup_configs, lint_file); None }; diff --git a/apps/oxlint/test/fixtures/custom_plugin_with_options/.oxlintrc.json b/apps/oxlint/test/fixtures/custom_plugin_with_options/.oxlintrc.json new file mode 100644 index 0000000000000..845b8c9693ccd --- /dev/null +++ b/apps/oxlint/test/fixtures/custom_plugin_with_options/.oxlintrc.json @@ -0,0 +1,10 @@ +{ + "categories": { + "correctness": "off" + }, + "jsPlugins": ["./plugin.ts"], + "rules": { + "test-plugin-options/check-options": ["error", true, { "expected": "production" }] + } +} + diff --git a/apps/oxlint/test/fixtures/custom_plugin_with_options/files/index.js b/apps/oxlint/test/fixtures/custom_plugin_with_options/files/index.js new file mode 100644 index 0000000000000..eab74692130a6 --- /dev/null +++ b/apps/oxlint/test/fixtures/custom_plugin_with_options/files/index.js @@ -0,0 +1 @@ +debugger; diff --git a/apps/oxlint/test/fixtures/custom_plugin_with_options/output.snap.md b/apps/oxlint/test/fixtures/custom_plugin_with_options/output.snap.md new file mode 100644 index 0000000000000..930a603c9b735 --- /dev/null +++ b/apps/oxlint/test/fixtures/custom_plugin_with_options/output.snap.md @@ -0,0 +1,25 @@ +# Exit code +1 + +# stdout +``` + x test-plugin-options(check-options): [ + | true, + | { + | "expected": "production" + | } + | ] + ,-[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/custom_plugin_with_options/plugin.ts b/apps/oxlint/test/fixtures/custom_plugin_with_options/plugin.ts new file mode 100644 index 0000000000000..93e7dfd058505 --- /dev/null +++ b/apps/oxlint/test/fixtures/custom_plugin_with_options/plugin.ts @@ -0,0 +1,42 @@ +import { definePlugin } from "oxlint"; + +export default definePlugin({ + meta: { + name: "test-plugin-options", + }, + rules: { + "check-options": { + meta: { + messages: { + wrongValue: "Expected value to be {{expected}}, got {{actual}}", + noOptions: "No options provided", + }, + }, + create(context) { + const { options } = context; + + // Check if options were passed correctly + if (!options || options.length === 0) { + context.report({ + message: "No options provided", + loc: { start: { line: 1, column: 0 }, end: { line: 1, column: 1 } }, + }); + } + + return { + DebuggerStatement(node) { + // First option is a boolean + const shouldReport = options[0]; + + if (shouldReport) { + context.report({ + message: JSON.stringify(context.options, null, 2), + node, + }); + } + }, + }; + }, + }, + }, +}); diff --git a/crates/oxc_language_server/src/linter/server_linter.rs b/crates/oxc_language_server/src/linter/server_linter.rs index 6f0628bc3a02f..822a455b9b3da 100644 --- a/crates/oxc_language_server/src/linter/server_linter.rs +++ b/crates/oxc_language_server/src/linter/server_linter.rs @@ -90,9 +90,9 @@ impl ServerLinterBuilder { && nested_configs.pin().values().any(|config| config.plugins().has_import())); extended_paths.extend(config_builder.extended_paths.clone()); - let base_config = config_builder.build(&external_plugin_store).unwrap_or_else(|err| { + let base_config = config_builder.build(&mut external_plugin_store).unwrap_or_else(|err| { warn!("Failed to build config: {err}"); - ConfigStoreBuilder::empty().build(&external_plugin_store).unwrap() + ConfigStoreBuilder::empty().build(&mut external_plugin_store).unwrap() }); let lint_options = LintOptions { @@ -249,10 +249,11 @@ impl ServerLinterBuilder { continue; }; extended_paths.extend(config_store_builder.extended_paths.clone()); - let config = config_store_builder.build(&external_plugin_store).unwrap_or_else(|err| { - warn!("Failed to build nested config for {}: {:?}", dir_path.display(), err); - ConfigStoreBuilder::empty().build(&external_plugin_store).unwrap() - }); + let config = + config_store_builder.build(&mut external_plugin_store).unwrap_or_else(|err| { + warn!("Failed to build nested config for {}: {:?}", dir_path.display(), err); + ConfigStoreBuilder::empty().build(&mut external_plugin_store).unwrap() + }); nested_configs.pin().insert(dir_path.to_path_buf(), config); } diff --git a/crates/oxc_linter/src/config/config_builder.rs b/crates/oxc_linter/src/config/config_builder.rs index 88200ebacbcfc..b7765d2babdb4 100644 --- a/crates/oxc_linter/src/config/config_builder.rs +++ b/crates/oxc_linter/src/config/config_builder.rs @@ -17,7 +17,7 @@ use crate::{ ESLintRule, OxlintOverrides, OxlintRules, overrides::OxlintOverride, plugins::LintPlugins, }, external_linter::ExternalLinter, - external_plugin_store::{ExternalRuleId, ExternalRuleLookupError}, + external_plugin_store::{ExternalOptionsId, ExternalRuleId, ExternalRuleLookupError}, rules::RULES, }; @@ -30,7 +30,7 @@ use super::{ #[must_use = "You dropped your builder without building a Linter! Did you mean to call .build()?"] pub struct ConfigStoreBuilder { pub(super) rules: FxHashMap, - pub(super) external_rules: FxHashMap, + pub(super) external_rules: FxHashMap, config: LintConfig, categories: OxlintCategories, overrides: OxlintOverrides, @@ -386,7 +386,7 @@ impl ConfigStoreBuilder { /// Returns [`ConfigBuilderError::UnknownRules`] if there are rules that could not be matched. pub fn build( mut self, - external_plugin_store: &ExternalPluginStore, + external_plugin_store: &mut ExternalPluginStore, ) -> Result { // When a plugin gets disabled before build(), rules for that plugin aren't removed until // with_filters() gets called. If the user never calls it, those now-undesired rules need @@ -413,8 +413,14 @@ impl ConfigStoreBuilder { .collect(); rules.sort_unstable_by_key(|(r, _)| r.id()); - let mut external_rules: Vec<_> = self.external_rules.into_iter().collect(); - external_rules.sort_unstable_by_key(|(r, _)| *r); + // Convert HashMap entries (ExternalRuleId -> (ExternalOptionsId, AllowWarnDeny)) + // into Vec<(ExternalRuleId, ExternalOptionsId, AllowWarnDeny)> and sort by rule id. + let mut external_rules: Vec<_> = self + .external_rules + .into_iter() + .map(|(rule_id, (options_id, severity))| (rule_id, options_id, severity)) + .collect(); + external_rules.sort_unstable_by_key(|(r, _, _)| *r); Ok(Config::new(rules, external_rules, self.categories, self.config, resolved_overrides)) } @@ -422,7 +428,7 @@ impl ConfigStoreBuilder { fn resolve_overrides( &self, overrides: OxlintOverrides, - external_plugin_store: &ExternalPluginStore, + external_plugin_store: &mut ExternalPluginStore, ) -> Result { let resolved = overrides .into_iter() @@ -444,7 +450,11 @@ impl ConfigStoreBuilder { // Convert to vectors builtin_rules.extend(rules_map.into_iter()); - external_rules.extend(external_rules_map.into_iter()); + external_rules.extend( + external_rules_map + .into_iter() + .map(|(rule_id, (options_id, severity))| (rule_id, options_id, severity)), + ); Ok(ResolvedOxlintOverride { files: override_config.files, @@ -814,10 +824,10 @@ mod test { let mut desired_plugins = LintPlugins::default(); desired_plugins.set(LintPlugins::TYPESCRIPT, false); - let external_plugin_store = ExternalPluginStore::default(); + let mut external_plugin_store = ExternalPluginStore::default(); let linter = ConfigStoreBuilder::default() .with_builtin_plugins(desired_plugins) - .build(&external_plugin_store) + .build(&mut external_plugin_store) .unwrap(); for (rule, _) in linter.base.rules.iter() { let name = rule.name(); @@ -1262,7 +1272,7 @@ mod test { ) .unwrap(); - let config = builder.build(&external_plugin_store).unwrap(); + let config = builder.build(&mut external_plugin_store).unwrap(); // Apply overrides for a foo.test.ts file (matches both overrides) let resolved = config.apply_overrides(Path::new("foo.test.ts")); @@ -1288,7 +1298,7 @@ mod test { &mut external_plugin_store, ) .unwrap() - .build(&external_plugin_store) + .build(&mut external_plugin_store) .unwrap() } @@ -1301,7 +1311,7 @@ mod test { &mut external_plugin_store, ) .unwrap() - .build(&external_plugin_store) + .build(&mut external_plugin_store) .unwrap() } } diff --git a/crates/oxc_linter/src/config/config_store.rs b/crates/oxc_linter/src/config/config_store.rs index 0b94ad4944221..bb389ac157f14 100644 --- a/crates/oxc_linter/src/config/config_store.rs +++ b/crates/oxc_linter/src/config/config_store.rs @@ -7,7 +7,7 @@ use rustc_hash::FxHashMap; use crate::{ AllowWarnDeny, - external_plugin_store::{ExternalPluginStore, ExternalRuleId}, + external_plugin_store::{ExternalOptionsId, ExternalPluginStore, ExternalRuleId}, rules::{RULES, RuleEnum}, }; @@ -23,7 +23,7 @@ pub struct ResolvedLinterState { pub rules: Arc<[(RuleEnum, AllowWarnDeny)]>, pub config: Arc, - pub external_rules: Arc<[(ExternalRuleId, AllowWarnDeny)]>, + pub external_rules: Arc<[(ExternalRuleId, ExternalOptionsId, AllowWarnDeny)]>, } #[derive(Debug, Default, Clone)] @@ -55,7 +55,7 @@ pub struct ResolvedOxlintOverride { #[derive(Debug, Clone)] pub struct ResolvedOxlintOverrideRules { pub(crate) builtin_rules: Vec<(RuleEnum, AllowWarnDeny)>, - pub(crate) external_rules: Vec<(ExternalRuleId, AllowWarnDeny)>, + pub(crate) external_rules: Vec<(ExternalRuleId, ExternalOptionsId, AllowWarnDeny)>, } #[derive(Debug, Clone)] @@ -80,7 +80,7 @@ pub struct Config { impl Config { pub fn new( rules: Vec<(RuleEnum, AllowWarnDeny)>, - mut external_rules: Vec<(ExternalRuleId, AllowWarnDeny)>, + mut external_rules: Vec<(ExternalRuleId, ExternalOptionsId, AllowWarnDeny)>, categories: OxlintCategories, config: LintConfig, overrides: ResolvedOxlintOverrides, @@ -97,7 +97,7 @@ impl Config { ), config: Arc::new(config), external_rules: Arc::from({ - external_rules.retain(|(_, sev)| sev.is_warn_deny()); + external_rules.retain(|(_, _, sev)| sev.is_warn_deny()); external_rules.into_boxed_slice() }), }, @@ -174,8 +174,13 @@ impl Config { .cloned() .collect::>(); - let mut external_rules = - self.base.external_rules.iter().copied().collect::>(); + // Build a hashmap of existing external rules keyed by rule id with value (options_id, severity) + let mut external_rules: FxHashMap = + self.base + .external_rules + .iter() + .map(|(rule_id, options_id, severity)| (*rule_id, (*options_id, *severity))) + .collect(); // Track which plugins have already had their category rules applied. // Start with the root plugins since they already have categories applied in base_rules. @@ -219,8 +224,8 @@ impl Config { } } - for (external_rule_id, severity) in &override_config.rules.external_rules { - external_rules.insert(*external_rule_id, *severity); + for (external_rule_id, options_id, severity) in &override_config.rules.external_rules { + external_rules.insert(*external_rule_id, (*options_id, *severity)); } if let Some(override_env) = &override_config.env { @@ -253,7 +258,8 @@ impl Config { let external_rules = external_rules .into_iter() - .filter(|(_, severity)| severity.is_warn_deny()) + .filter(|(_, (.., severity))| severity.is_warn_deny()) + .map(|(rule_id, (options_id, severity))| (rule_id, options_id, severity)) .collect::>(); ResolvedLinterState { @@ -356,7 +362,7 @@ mod test { use super::{ConfigStore, ResolvedOxlintOverrides}; use crate::{ - AllowWarnDeny, ExternalPluginStore, LintPlugins, RuleCategory, RuleEnum, + AllowWarnDeny, ExternalOptionsId, ExternalPluginStore, LintPlugins, RuleCategory, RuleEnum, config::{ LintConfig, OxlintEnv, OxlintGlobals, OxlintSettings, categories::OxlintCategories, @@ -707,7 +713,7 @@ mod test { let store = ConfigStore::new( Config::new( vec![], - vec![(rule_id, AllowWarnDeny::Deny)], + vec![(rule_id, ExternalOptionsId::from_usize(0), AllowWarnDeny::Deny)], OxlintCategories::default(), LintConfig::default(), overrides, @@ -1052,4 +1058,53 @@ mod test { assert_eq!(store_with_nested_configs.number_of_rules(false), None); assert_eq!(store_with_nested_configs.number_of_rules(true), None); } + + #[test] + fn test_external_rule_options_override_precedence() { + // Prepare external plugin store with a custom plugin and rule + let mut store = ExternalPluginStore::new(true); + store.register_plugin( + "path/to/custom".to_string().into(), + "custom".to_string(), + 0, + vec!["my-rule".to_string()], + ); + + // Base config has external rule with options A, severity warn + let base_external_rule_id = store.lookup_rule_id("custom", "my-rule").unwrap(); + let base_options_id = store.add_options(serde_json::json!([{ "opt": "A" }])); + + let base = Config::new( + vec![], + vec![(base_external_rule_id, base_options_id, AllowWarnDeny::Warn)], + OxlintCategories::default(), + LintConfig::default(), + ResolvedOxlintOverrides::new(vec![ResolvedOxlintOverride { + files: GlobSet::new(vec!["*.js"]), + env: None, + globals: None, + plugins: None, + // Override redefines the same rule with options B and severity error + rules: ResolvedOxlintOverrideRules { + builtin_rules: vec![], + external_rules: vec![( + base_external_rule_id, + store.add_options(serde_json::json!([{ "opt": "B" }])), + AllowWarnDeny::Deny, + )], + }, + }]), + ); + + let config_store = ConfigStore::new(base, FxHashMap::default(), store); + let resolved = config_store.resolve(&PathBuf::from_str("/root/a.js").unwrap()); + + // Should prefer override (options B, severity error) + assert_eq!(resolved.external_rules.len(), 1); + let (rid, opts_id, sev) = resolved.external_rules[0]; + assert_eq!(rid, base_external_rule_id); + assert!(sev.is_warn_deny()); + // opts_id should not equal the base options id + assert_ne!(opts_id, base_options_id); + } } diff --git a/crates/oxc_linter/src/config/mod.rs b/crates/oxc_linter/src/config/mod.rs index 84c5ce2902bbc..4dda1aa486788 100644 --- a/crates/oxc_linter/src/config/mod.rs +++ b/crates/oxc_linter/src/config/mod.rs @@ -114,14 +114,14 @@ mod test { let config = Oxlintrc::from_file(&fixture_path).unwrap(); let mut set = FxHashMap::default(); let mut external_rules_for_override = FxHashMap::default(); - let external_linter_store = ExternalPluginStore::default(); + let mut external_linter_store = ExternalPluginStore::default(); config .rules .override_rules( &mut set, &mut external_rules_for_override, &RULES, - &external_linter_store, + &mut external_linter_store, ) .unwrap(); diff --git a/crates/oxc_linter/src/config/rules.rs b/crates/oxc_linter/src/config/rules.rs index d42bb08e3c7bc..d9d9962ad62b9 100644 --- a/crates/oxc_linter/src/config/rules.rs +++ b/crates/oxc_linter/src/config/rules.rs @@ -13,7 +13,7 @@ use oxc_diagnostics::{Error, OxcDiagnostic}; use crate::{ AllowWarnDeny, ExternalPluginStore, LintPlugins, - external_plugin_store::{ExternalRuleId, ExternalRuleLookupError}, + external_plugin_store::{ExternalOptionsId, ExternalRuleId, ExternalRuleLookupError}, rules::{RULES, RuleEnum}, utils::{is_eslint_rule_adapted_to_typescript, is_jest_rule_adapted_to_vitest}, }; @@ -63,9 +63,12 @@ impl OxlintRules { pub(crate) fn override_rules( &self, rules_for_override: &mut RuleSet, - external_rules_for_override: &mut FxHashMap, + external_rules_for_override: &mut FxHashMap< + ExternalRuleId, + (ExternalOptionsId, AllowWarnDeny), + >, all_rules: &[RuleEnum], - external_plugin_store: &ExternalPluginStore, + external_plugin_store: &mut ExternalPluginStore, ) -> Result<(), ExternalRuleLookupError> { let mut rules_to_replace = vec![]; @@ -106,10 +109,22 @@ impl OxlintRules { if external_plugin_store.is_enabled() { let external_rule_id = external_plugin_store.lookup_rule_id(plugin_name, rule_name)?; + + // Add options to store and get options ID + let options_id = if let Some(config) = &rule_config.config { + external_plugin_store.add_options(config.clone()) + } else { + // No options - use reserved index 0 + ExternalOptionsId::from_usize(0) + }; + external_rules_for_override .entry(external_rule_id) - .and_modify(|sev| *sev = severity) - .or_insert(severity); + .and_modify(|(opts_id, sev)| { + *opts_id = options_id; + *sev = severity; + }) + .or_insert((options_id, severity)); } } } @@ -329,6 +344,7 @@ mod test { use serde::Deserialize; use serde_json::{Value, json}; + use crate::external_plugin_store::ExternalOptionsId; use crate::{ AllowWarnDeny, ExternalPluginStore, rules::{RULES, RuleEnum}, @@ -381,9 +397,14 @@ mod test { fn r#override(rules: &mut RuleSet, rules_rc: &Value) { let rules_config = OxlintRules::deserialize(rules_rc).unwrap(); let mut external_rules_for_override = FxHashMap::default(); - let external_linter_store = ExternalPluginStore::default(); + let mut external_linter_store = ExternalPluginStore::default(); rules_config - .override_rules(rules, &mut external_rules_for_override, &RULES, &external_linter_store) + .override_rules( + rules, + &mut external_rules_for_override, + &RULES, + &mut external_linter_store, + ) .unwrap(); } @@ -517,4 +538,73 @@ mod test { assert_eq!(r2.plugin_name, "unicorn"); assert!(r2.severity.is_warn_deny()); } + + #[test] + fn test_external_rule_options_are_recorded() { + // Register a fake external plugin and rule + let mut store = ExternalPluginStore::new(true); + store.register_plugin( + "path/to/custom-plugin".to_string().into(), + "custom".to_string(), + 0, + vec!["my-rule".to_string()], + ); + + // Configure rule with options array (non-empty) and ensure options id != 0 + let rules = OxlintRules::deserialize(&json!({ + "custom/my-rule": ["warn", {"foo": 1}] + })) + .unwrap(); + + let mut builtin_rules = RuleSet::default(); + let mut external_rules = FxHashMap::default(); + rules.override_rules(&mut builtin_rules, &mut external_rules, &[], &mut store).unwrap(); + + assert_eq!(builtin_rules.len(), 0); + assert_eq!(external_rules.len(), 1); + let (_rule_id, (options_id, severity)) = + external_rules.iter().next().map(|(k, v)| (*k, *v)).unwrap(); + assert_ne!( + options_id, + ExternalOptionsId::from_usize(0), + "non-empty options should allocate a new id" + ); + assert!(severity.is_warn_deny()); + + // Now configure with no options which should map to reserved index 0 + let rules_no_opts = OxlintRules::deserialize(&json!({ + "custom/my-rule": "error" + })) + .unwrap(); + let mut builtin_rules2 = RuleSet::default(); + let mut external_rules2 = FxHashMap::default(); + rules_no_opts + .override_rules(&mut builtin_rules2, &mut external_rules2, &[], &mut store) + .unwrap(); + let (_rid2, (options_id2, severity2)) = + external_rules2.iter().next().map(|(k, v)| (*k, *v)).unwrap(); + assert_eq!( + options_id2, + ExternalOptionsId::from_usize(0), + "no options should use reserved id 0" + ); + assert!(severity2.is_warn_deny()); + + // Test that null config values also map to reserved index 0 + // This tests the case where config might be explicitly null (though unlikely in practice) + let null_options_id = store.add_options(serde_json::Value::Null); + assert_eq!( + null_options_id, + ExternalOptionsId::from_usize(0), + "null options should use reserved id 0" + ); + + // Test that empty array also maps to reserved index 0 + let empty_array_id = store.add_options(serde_json::json!([])); + assert_eq!( + empty_array_id, + ExternalOptionsId::from_usize(0), + "empty array options should use reserved id 0" + ); + } } diff --git a/crates/oxc_linter/src/external_linter.rs b/crates/oxc_linter/src/external_linter.rs index d9f3a213444fe..5d2c4b31ef9b2 100644 --- a/crates/oxc_linter/src/external_linter.rs +++ b/crates/oxc_linter/src/external_linter.rs @@ -10,8 +10,10 @@ pub type ExternalLinterLoadPluginCb = Box< + Sync, >; +pub type ExternalLinterSetupConfigsCb = Box Result<(), String> + Send + Sync>; + pub type ExternalLinterLintFileCb = Box< - dyn Fn(String, Vec, String, &Allocator) -> Result, String> + dyn Fn(String, Vec, Vec, String, &Allocator) -> Result, String> + Sync + Send, >; @@ -46,15 +48,17 @@ pub struct JsFix { pub struct ExternalLinter { pub(crate) load_plugin: ExternalLinterLoadPluginCb, + pub(crate) setup_configs: ExternalLinterSetupConfigsCb, pub(crate) lint_file: ExternalLinterLintFileCb, } impl ExternalLinter { pub fn new( load_plugin: ExternalLinterLoadPluginCb, + setup_configs: ExternalLinterSetupConfigsCb, lint_file: ExternalLinterLintFileCb, ) -> Self { - Self { load_plugin, lint_file } + Self { load_plugin, setup_configs, lint_file } } } diff --git a/crates/oxc_linter/src/external_plugin_store.rs b/crates/oxc_linter/src/external_plugin_store.rs index 6b57bb0792136..a6f361f98d161 100644 --- a/crates/oxc_linter/src/external_plugin_store.rs +++ b/crates/oxc_linter/src/external_plugin_store.rs @@ -5,7 +5,9 @@ use std::{ use rustc_hash::{FxHashMap, FxHashSet}; -use oxc_index::{IndexVec, define_index_type}; +use oxc_index::{IndexVec, define_index_type, index_vec}; + +use crate::ExternalLinter; define_index_type! { pub struct ExternalPluginId = u32; @@ -15,6 +17,10 @@ define_index_type! { pub struct ExternalRuleId = u32; } +define_index_type! { + pub struct ExternalOptionsId = u32; +} + #[derive(Debug)] pub struct ExternalPluginStore { registered_plugin_paths: FxHashSet, @@ -22,6 +28,7 @@ pub struct ExternalPluginStore { plugins: IndexVec, plugin_names: FxHashMap, rules: IndexVec, + options: IndexVec, // `true` for `oxlint`, `false` for language server is_enabled: bool, @@ -35,11 +42,14 @@ impl Default for ExternalPluginStore { impl ExternalPluginStore { pub fn new(is_enabled: bool) -> Self { + let options = index_vec![serde_json::json!([])]; + Self { registered_plugin_paths: FxHashSet::default(), plugins: IndexVec::default(), plugin_names: FxHashMap::default(), rules: IndexVec::default(), + options, is_enabled, } } @@ -119,6 +129,27 @@ impl ExternalPluginStore { let plugin = &self.plugins[external_rule.plugin_id]; (&plugin.name, &external_rule.name) } + + /// Add options to the store and return its ID. + /// Returns index 0 for empty arrays or null values (no options). + pub fn add_options(&mut self, options: serde_json::Value) -> ExternalOptionsId { + // If it's null or an empty array, return reserved index 0 + if options.is_null() || options.as_array().is_some_and(Vec::is_empty) { + return ExternalOptionsId::from_usize(0); + } + + self.options.push(options) + } + + /// # Errors + /// Returns an error if serialization of rule options fails. + pub fn setup_configs(&self, external_linter: &ExternalLinter) -> Result<(), String> { + let json = serde_json::to_string(&self.options); + match json { + Ok(options_json) => (external_linter.setup_configs)(options_json), + Err(err) => Err(format!("Failed to serialize external plugin options: {err}")), + } + } } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/oxc_linter/src/lib.rs b/crates/oxc_linter/src/lib.rs index fe1900dd6068e..3b97d02c5faab 100644 --- a/crates/oxc_linter/src/lib.rs +++ b/crates/oxc_linter/src/lib.rs @@ -63,7 +63,7 @@ pub use crate::{ ExternalLinter, ExternalLinterLintFileCb, ExternalLinterLoadPluginCb, JsFix, LintFileResult, PluginLoadResult, }, - external_plugin_store::{ExternalPluginStore, ExternalRuleId}, + external_plugin_store::{ExternalOptionsId, ExternalPluginStore, ExternalRuleId}, fixer::{Fix, FixKind, Message, PossibleFixes}, frameworks::FrameworkFlags, lint_runner::{DirectivesStore, LintRunner, LintRunnerBuilder}, @@ -384,7 +384,7 @@ impl Linter { fn run_external_rules<'a>( &self, - external_rules: &[(ExternalRuleId, AllowWarnDeny)], + external_rules: &[(ExternalRuleId, ExternalOptionsId, AllowWarnDeny)], path: &Path, ctx_host: &mut Rc>, allocator: &'a Allocator, @@ -461,9 +461,11 @@ impl Linter { None => "{}".to_string(), }; + // Pass AST and rule IDs + options IDs to JS let result = (external_linter.lint_file)( path.to_string_lossy().into_owned(), - external_rules.iter().map(|(rule_id, _)| rule_id.raw()).collect(), + external_rules.iter().map(|(rule_id, _, _)| rule_id.raw()).collect(), + external_rules.iter().map(|(_, options_id, _)| options_id.raw()).collect(), settings_json, allocator, ); @@ -477,7 +479,7 @@ impl Linter { let mut span = Span::new(diagnostic.start, diagnostic.end); span_converter.convert_span_back(&mut span); - let (external_rule_id, severity) = + let (external_rule_id, _options_id, severity) = external_rules[diagnostic.rule_index as usize]; let (plugin_name, rule_name) = self.config.resolve_plugin_rule_names(external_rule_id); diff --git a/crates/oxc_linter/src/tester.rs b/crates/oxc_linter/src/tester.rs index 759fc521c01ed..4a65830a32baf 100644 --- a/crates/oxc_linter/src/tester.rs +++ b/crates/oxc_linter/src/tester.rs @@ -505,7 +505,7 @@ impl Tester { }), ) .with_rule(rule, AllowWarnDeny::Warn) - .build(&external_plugin_store) + .build(&mut external_plugin_store) .unwrap(), FxHashMap::default(), external_plugin_store, diff --git a/napi/playground/src/lib.rs b/napi/playground/src/lib.rs index d5e1e56b98467..56b028355f085 100644 --- a/napi/playground/src/lib.rs +++ b/napi/playground/src/lib.rs @@ -375,7 +375,7 @@ impl Oxc { ) { // Only lint if there are no syntax errors if run_options.lint && self.diagnostics.is_empty() { - let external_plugin_store = ExternalPluginStore::default(); + let mut external_plugin_store = ExternalPluginStore::default(); let semantic_ret = SemanticBuilder::new().with_cfg(true).build(program); let semantic = semantic_ret.semantic; let lint_config = if linter_options.config.is_some() { @@ -386,12 +386,12 @@ impl Oxc { false, oxlintrc, None, - &mut ExternalPluginStore::default(), + &mut external_plugin_store, ) .unwrap_or_default(); - config_builder.build(&external_plugin_store) + config_builder.build(&mut external_plugin_store) } else { - ConfigStoreBuilder::default().build(&external_plugin_store) + ConfigStoreBuilder::default().build(&mut external_plugin_store) }; let lint_config = lint_config.unwrap(); let linter_ret = Linter::new( diff --git a/tasks/benchmark/benches/linter.rs b/tasks/benchmark/benches/linter.rs index bcea7bdde3af1..e4bcdb5298836 100644 --- a/tasks/benchmark/benches/linter.rs +++ b/tasks/benchmark/benches/linter.rs @@ -38,8 +38,9 @@ fn bench_linter(criterion: &mut Criterion) { let semantic = semantic_ret.semantic; let module_record = Arc::new(ModuleRecord::new(path, &parser_ret.module_record, &semantic)); - let external_plugin_store = ExternalPluginStore::default(); - let lint_config = ConfigStoreBuilder::all().build(&external_plugin_store).unwrap(); + let mut external_plugin_store = ExternalPluginStore::default(); + let lint_config = + ConfigStoreBuilder::all().build(&mut external_plugin_store).unwrap(); let linter = Linter::new( LintOptions::default(), ConfigStore::new(lint_config, FxHashMap::default(), external_plugin_store),