From 4c1978201837767961ddd66b8af6cb89abcbb147 Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Mon, 17 Nov 2025 20:27:20 -0800 Subject: [PATCH 01/12] Add support for options for jsPlugins --- apps/oxlint/src-js/bindings.d.ts | 8 +- apps/oxlint/src-js/bindings.js | 3 +- apps/oxlint/src-js/cli.ts | 4 +- apps/oxlint/src-js/plugins/lint.ts | 16 ++- apps/oxlint/src-js/plugins/options.ts | 42 ++++++- apps/oxlint/src/js_plugins/external_linter.rs | 3 +- apps/oxlint/src/lint.rs | 9 +- apps/oxlint/src/run.rs | 27 ++++- .../custom_plugin_with_options/.oxlintrc.json | 10 ++ .../custom_plugin_with_options/files/index.js | 4 + .../custom_plugin_with_options/output.snap.md | 22 ++++ .../custom_plugin_with_options/plugin.ts | 59 ++++++++++ .../src/linter/server_linter.rs | 13 ++- .../oxc_linter/src/config/config_builder.rs | 34 ++++-- crates/oxc_linter/src/config/config_store.rs | 84 ++++++++++++-- crates/oxc_linter/src/config/mod.rs | 4 +- crates/oxc_linter/src/config/rules.rs | 104 ++++++++++++++++-- crates/oxc_linter/src/external_linter.rs | 2 +- .../oxc_linter/src/external_plugin_store.rs | 29 +++++ crates/oxc_linter/src/lib.rs | 10 +- crates/oxc_linter/src/tester.rs | 2 +- napi/playground/src/lib.rs | 8 +- tasks/benchmark/benches/linter.rs | 5 +- 23 files changed, 439 insertions(+), 63 deletions(-) create mode 100644 apps/oxlint/test/fixtures/custom_plugin_with_options/.oxlintrc.json create mode 100644 apps/oxlint/test/fixtures/custom_plugin_with_options/files/index.js create mode 100644 apps/oxlint/test/fixtures/custom_plugin_with_options/output.snap.md create mode 100644 apps/oxlint/test/fixtures/custom_plugin_with_options/plugin.ts diff --git a/apps/oxlint/src-js/bindings.d.ts b/apps/oxlint/src-js/bindings.d.ts index 93921c1f0a557..17f7fc6044d30 100644 --- a/apps/oxlint/src-js/bindings.d.ts +++ b/apps/oxlint/src-js/bindings.d.ts @@ -1,8 +1,14 @@ /* auto-generated by NAPI-RS */ /* eslint-disable */ +/** + * JS callable function to retrieve the serialized external rule options. + * Returns a JSON string of options arrays. Called once from JS after creating the external linter. + */ +export declare function getExternalRuleOptions(): string | null + /** 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 = diff --git a/apps/oxlint/src-js/bindings.js b/apps/oxlint/src-js/bindings.js index fc343d7db1af6..288f89fb8453c 100644 --- a/apps/oxlint/src-js/bindings.js +++ b/apps/oxlint/src-js/bindings.js @@ -575,5 +575,6 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { lint } = nativeBinding +const { getExternalRuleOptions, lint } = nativeBinding +export { getExternalRuleOptions } export { lint } diff --git a/apps/oxlint/src-js/cli.ts b/apps/oxlint/src-js/cli.ts index 34450e0f6c0c9..60ab02ab930a3 100644 --- a/apps/oxlint/src-js/cli.ts +++ b/apps/oxlint/src-js/cli.ts @@ -38,6 +38,7 @@ 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 +56,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 }); @@ -145,6 +145,14 @@ function lintFileImpl( "Rule IDs and options IDs arrays must be the same length", ); + // Initialize external rule options if not already initialized + if (!areOptionsInitialized()) { + const optionsJson = getExternalRuleOptions(); + if (optionsJson !== null && optionsJson.length > 0) { + setOptions(optionsJson); + } + } + for (let i = 0, len = ruleIds.length; i < len; i++) { const ruleId = ruleIds[i]; debugAssert(ruleId < registeredRules.length, "Rule ID out of bounds"); diff --git a/apps/oxlint/src-js/plugins/options.ts b/apps/oxlint/src-js/plugins/options.ts index fdb70c038a14b..b4cc83cf44093 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,39 @@ function mergeValues(configValue: JsonValue, defaultValue: JsonValue): JsonValue return freeze(merged); } + +// Track if options have been initialized to avoid re-initialization +let optionsInitialized = false; + +/** + * 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 { + try { + const parsed = JSON.parse(optionsJson); + if (!Array.isArray(parsed)) throw new TypeError("Expected optionsJson to decode to an array"); + // 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 (!Array.isArray(el)) throw new TypeError("Each options entry must be an array"); + } + allOptions = parsed as Readonly[]; + optionsInitialized = true; + } catch (err) { + // Re-throw with clearer message for Rust side logging. + throw new Error( + "Failed to parse external rule options JSON: " + + (err instanceof Error ? err.message : String(err)), + ); + } +} + +/** + * Check if options have been initialized. + * @returns `true` if options have been set, `false` otherwise. + */ +export function areOptionsInitialized(): boolean { + return optionsInitialized; +} diff --git a/apps/oxlint/src/js_plugins/external_linter.rs b/apps/oxlint/src/js_plugins/external_linter.rs index 59e6bd5c9f24d..3e7d961f72139 100644 --- a/apps/oxlint/src/js_plugins/external_linter.rs +++ b/apps/oxlint/src/js_plugins/external_linter.rs @@ -72,6 +72,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 +88,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..44006dca5d138 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( @@ -329,6 +329,13 @@ impl CliRunner { return CliRunResult::None; } + // After building config, serialize external rule options for JS side. + #[cfg(all(feature = "napi", target_pointer_width = "64", target_endian = "little"))] + { + let store = config_store.external_plugin_store(); + crate::set_external_options_json(store); + } + let files_to_lint = paths .into_iter() .filter(|path| !ignore_matcher.should_ignore(Path::new(path))) diff --git a/apps/oxlint/src/run.rs b/apps/oxlint/src/run.rs index 526c39a65cd3d..c8c0d4dc4930a 100644 --- a/apps/oxlint/src/run.rs +++ b/apps/oxlint/src/run.rs @@ -16,6 +16,30 @@ use crate::{ result::CliRunResult, }; +#[cfg(all(feature = "napi", target_pointer_width = "64", target_endian = "little"))] +use oxc_linter::ExternalPluginStore; + +#[cfg(all(feature = "napi", target_pointer_width = "64", target_endian = "little"))] +use std::sync::OnceLock; + +#[cfg(all(feature = "napi", target_pointer_width = "64", target_endian = "little"))] +static EXTERNAL_OPTIONS_JSON: OnceLock = OnceLock::new(); + +/// Set serialized external rule options JSON after building configs. +/// Called from Rust side (internal) before any linting, then consumed on first call to `lint`. +#[cfg(all(feature = "napi", target_pointer_width = "64", target_endian = "little"))] +pub fn set_external_options_json(plugin_store: &ExternalPluginStore) { + let _ = EXTERNAL_OPTIONS_JSON.set(plugin_store.serialize_all_options()); +} + +/// JS callable function to retrieve the serialized external rule options. +/// Returns a JSON string of options arrays. Called once from JS after creating the external linter. +#[cfg(all(feature = "napi", target_pointer_width = "64", target_endian = "little"))] +#[napi] +pub fn get_external_rule_options() -> Option { + EXTERNAL_OPTIONS_JSON.get().cloned() +} + /// JS callback to load a JS plugin. #[napi] pub type JsLoadPluginCb = ThreadsafeFunction< @@ -40,12 +64,13 @@ 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 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..d4d622b99f9ad --- /dev/null +++ b/apps/oxlint/test/fixtures/custom_plugin_with_options/files/index.js @@ -0,0 +1,4 @@ +// Test file with debugger statement +debugger; + +console.log("Hello, world!"); 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..802248eaa34f5 --- /dev/null +++ b/apps/oxlint/test/fixtures/custom_plugin_with_options/output.snap.md @@ -0,0 +1,22 @@ +# Exit code +1 + +# stdout +``` + x test-plugin-options(check-options): Expected value to be production, got disabled + ,-[files/index.js:2:1] + 1 | // Test file with debugger statement + 2 | debugger; + : ^^^^^^^^^ + 3 | + `---- + +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..bf09e5751c2ee --- /dev/null +++ b/apps/oxlint/test/fixtures/custom_plugin_with_options/plugin.ts @@ -0,0 +1,59 @@ +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", + }, + }, + createOnce(context) { + // Don't access context.options here - it's not available yet! + // Options are only available in visitor methods after setupContextForFile is called. + + return { + before() { + // Options are now available since setupContextForFile was called + const options = context.options; + + // Check if options were passed correctly + if (!options || options.length === 0) { + context.report({ + messageId: "noOptions", + loc: { start: { line: 1, column: 0 }, end: { line: 1, column: 1 } }, + }); + return false; + } + return true; + }, + + DebuggerStatement(node) { + // Options are available in visitor methods + const options = context.options; + + // First option is a boolean + const shouldReport = options[0]; + // Second option is an object + const config = (options[1] || {}) as { expected?: string }; + + if (shouldReport) { + context.report({ + messageId: "wrongValue", + data: { + expected: String(config.expected || "enabled"), + actual: "disabled", + }, + 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..945d68ed6882a 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 { @@ -310,6 +316,11 @@ impl ConfigStore { self.base.base.config.plugins } + /// Access the external plugin store. Used by oxlint CLI to serialize external rule options. + pub fn external_plugin_store(&self) -> &ExternalPluginStore { + &self.external_plugin_store + } + pub(crate) fn get_related_config(&self, path: &Path) -> &Config { if self.nested_configs.is_empty() { &self.base @@ -356,7 +367,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 +718,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 +1063,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(), + "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..5e1ffff7fcc79 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(), + "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..c12d0305d7282 100644 --- a/crates/oxc_linter/src/external_linter.rs +++ b/crates/oxc_linter/src/external_linter.rs @@ -11,7 +11,7 @@ pub type ExternalLinterLoadPluginCb = Box< >; pub type ExternalLinterLintFileCb = Box< - dyn Fn(String, Vec, String, &Allocator) -> Result, String> + dyn Fn(String, Vec, Vec, String, &Allocator) -> Result, String> + Sync + Send, >; diff --git a/crates/oxc_linter/src/external_plugin_store.rs b/crates/oxc_linter/src/external_plugin_store.rs index 6b57bb0792136..e293a96bf477d 100644 --- a/crates/oxc_linter/src/external_plugin_store.rs +++ b/crates/oxc_linter/src/external_plugin_store.rs @@ -15,6 +15,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 +26,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 +40,16 @@ impl Default for ExternalPluginStore { impl ExternalPluginStore { pub fn new(is_enabled: bool) -> Self { + let mut options = IndexVec::default(); + // Index 0 is reserved for "no options" (empty array) + options.push(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,25 @@ 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) + } + + /// Serialize all options to JSON string. + /// + /// # Panics + /// Panics if serialization fails. + pub fn serialize_all_options(&self) -> String { + serde_json::to_string(&self.options).expect("Failed to serialize options") + } } #[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), From a3f1ba534264134ccb2ad80ee8214e43948a6717 Mon Sep 17 00:00:00 2001 From: Arsh <69170106+lilnasy@users.noreply.github.com> Date: Fri, 28 Nov 2025 02:44:51 +0530 Subject: [PATCH 02/12] lint issues --- crates/oxc_linter/src/config/config_store.rs | 2 +- crates/oxc_linter/src/config/rules.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/oxc_linter/src/config/config_store.rs b/crates/oxc_linter/src/config/config_store.rs index 945d68ed6882a..14fffb3a9d2ff 100644 --- a/crates/oxc_linter/src/config/config_store.rs +++ b/crates/oxc_linter/src/config/config_store.rs @@ -1069,7 +1069,7 @@ mod test { // Prepare external plugin store with a custom plugin and rule let mut store = ExternalPluginStore::new(true); store.register_plugin( - "path/to/custom".to_string(), + "path/to/custom".to_string().into(), "custom".to_string(), 0, vec!["my-rule".to_string()], diff --git a/crates/oxc_linter/src/config/rules.rs b/crates/oxc_linter/src/config/rules.rs index 5e1ffff7fcc79..d9d9962ad62b9 100644 --- a/crates/oxc_linter/src/config/rules.rs +++ b/crates/oxc_linter/src/config/rules.rs @@ -544,7 +544,7 @@ mod test { // Register a fake external plugin and rule let mut store = ExternalPluginStore::new(true); store.register_plugin( - "path/to/custom-plugin".to_string(), + "path/to/custom-plugin".to_string().into(), "custom".to_string(), 0, vec!["my-rule".to_string()], From 839d6afd87481277d2c0ad2ada5305e20cd47da0 Mon Sep 17 00:00:00 2001 From: Arsh <69170106+lilnasy@users.noreply.github.com> Date: Fri, 28 Nov 2025 18:27:25 +0530 Subject: [PATCH 03/12] delete `set_external_options_json()` --- apps/oxlint/src/lint.rs | 7 ------- apps/oxlint/src/run.rs | 24 ------------------------ 2 files changed, 31 deletions(-) diff --git a/apps/oxlint/src/lint.rs b/apps/oxlint/src/lint.rs index 44006dca5d138..ef045971c83b9 100644 --- a/apps/oxlint/src/lint.rs +++ b/apps/oxlint/src/lint.rs @@ -329,13 +329,6 @@ impl CliRunner { return CliRunResult::None; } - // After building config, serialize external rule options for JS side. - #[cfg(all(feature = "napi", target_pointer_width = "64", target_endian = "little"))] - { - let store = config_store.external_plugin_store(); - crate::set_external_options_json(store); - } - let files_to_lint = paths .into_iter() .filter(|path| !ignore_matcher.should_ignore(Path::new(path))) diff --git a/apps/oxlint/src/run.rs b/apps/oxlint/src/run.rs index c8c0d4dc4930a..de6aac5981927 100644 --- a/apps/oxlint/src/run.rs +++ b/apps/oxlint/src/run.rs @@ -16,30 +16,6 @@ use crate::{ result::CliRunResult, }; -#[cfg(all(feature = "napi", target_pointer_width = "64", target_endian = "little"))] -use oxc_linter::ExternalPluginStore; - -#[cfg(all(feature = "napi", target_pointer_width = "64", target_endian = "little"))] -use std::sync::OnceLock; - -#[cfg(all(feature = "napi", target_pointer_width = "64", target_endian = "little"))] -static EXTERNAL_OPTIONS_JSON: OnceLock = OnceLock::new(); - -/// Set serialized external rule options JSON after building configs. -/// Called from Rust side (internal) before any linting, then consumed on first call to `lint`. -#[cfg(all(feature = "napi", target_pointer_width = "64", target_endian = "little"))] -pub fn set_external_options_json(plugin_store: &ExternalPluginStore) { - let _ = EXTERNAL_OPTIONS_JSON.set(plugin_store.serialize_all_options()); -} - -/// JS callable function to retrieve the serialized external rule options. -/// Returns a JSON string of options arrays. Called once from JS after creating the external linter. -#[cfg(all(feature = "napi", target_pointer_width = "64", target_endian = "little"))] -#[napi] -pub fn get_external_rule_options() -> Option { - EXTERNAL_OPTIONS_JSON.get().cloned() -} - /// JS callback to load a JS plugin. #[napi] pub type JsLoadPluginCb = ThreadsafeFunction< From 26513bbfda718a7e8125e49868e7dcc8ded3c852 Mon Sep 17 00:00:00 2001 From: Arsh <69170106+lilnasy@users.noreply.github.com> Date: Fri, 28 Nov 2025 18:30:26 +0530 Subject: [PATCH 04/12] delete `getExternalRuleOptions()` --- apps/oxlint/src-js/bindings.d.ts | 6 ------ apps/oxlint/src-js/bindings.js | 3 +-- apps/oxlint/src-js/plugins/lint.ts | 9 --------- 3 files changed, 1 insertion(+), 17 deletions(-) diff --git a/apps/oxlint/src-js/bindings.d.ts b/apps/oxlint/src-js/bindings.d.ts index 17f7fc6044d30..d4b0e7c628284 100644 --- a/apps/oxlint/src-js/bindings.d.ts +++ b/apps/oxlint/src-js/bindings.d.ts @@ -1,11 +1,5 @@ /* auto-generated by NAPI-RS */ /* eslint-disable */ -/** - * JS callable function to retrieve the serialized external rule options. - * Returns a JSON string of options arrays. Called once from JS after creating the external linter. - */ -export declare function getExternalRuleOptions(): string | null - /** JS callback to lint a file. */ export type JsLintFileCb = ((arg0: string, arg1: number, arg2: Uint8Array | undefined | null, arg3: Array, arg4: Array, arg5: string) => string) diff --git a/apps/oxlint/src-js/bindings.js b/apps/oxlint/src-js/bindings.js index 288f89fb8453c..fc343d7db1af6 100644 --- a/apps/oxlint/src-js/bindings.js +++ b/apps/oxlint/src-js/bindings.js @@ -575,6 +575,5 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { getExternalRuleOptions, lint } = nativeBinding -export { getExternalRuleOptions } +const { lint } = nativeBinding export { lint } diff --git a/apps/oxlint/src-js/plugins/lint.ts b/apps/oxlint/src-js/plugins/lint.ts index 3052c1fde373a..daf69fe67154a 100644 --- a/apps/oxlint/src-js/plugins/lint.ts +++ b/apps/oxlint/src-js/plugins/lint.ts @@ -12,7 +12,6 @@ import { finalizeCompiledVisitor, initCompiledVisitor, } from "./visitor.js"; -import { getExternalRuleOptions } from "../bindings.js"; // Lazy implementation /* @@ -145,14 +144,6 @@ function lintFileImpl( "Rule IDs and options IDs arrays must be the same length", ); - // Initialize external rule options if not already initialized - if (!areOptionsInitialized()) { - const optionsJson = getExternalRuleOptions(); - if (optionsJson !== null && optionsJson.length > 0) { - setOptions(optionsJson); - } - } - for (let i = 0, len = ruleIds.length; i < len; i++) { const ruleId = ruleIds[i]; debugAssert(ruleId < registeredRules.length, "Rule ID out of bounds"); From 1b4cd57c93f7e2f5f49c090d588fdd7dfdc07659 Mon Sep 17 00:00:00 2001 From: Arsh <69170106+lilnasy@users.noreply.github.com> Date: Sat, 29 Nov 2025 00:38:27 +0530 Subject: [PATCH 05/12] introduce `setupConfigs()` --- apps/oxlint/src-js/bindings.d.ts | 9 +++- apps/oxlint/src-js/cli.ts | 20 +++++++-- apps/oxlint/src-js/plugins/config.ts | 26 +++++++++++ apps/oxlint/src-js/plugins/index.ts | 1 + apps/oxlint/src-js/plugins/lint.ts | 2 +- apps/oxlint/src-js/plugins/options.ts | 29 +++--------- apps/oxlint/src/js_plugins/external_linter.rs | 45 ++++++++++++++++++- apps/oxlint/src/lint.rs | 12 +++++ apps/oxlint/src/run.rs | 33 +++++++++++--- crates/oxc_linter/src/external_linter.rs | 6 ++- .../oxc_linter/src/external_plugin_store.rs | 16 ++++--- 11 files changed, 156 insertions(+), 43 deletions(-) create mode 100644 apps/oxlint/src-js/plugins/config.ts diff --git a/apps/oxlint/src-js/bindings.d.ts b/apps/oxlint/src-js/bindings.d.ts index d4b0e7c628284..25b850b162e1f 100644 --- a/apps/oxlint/src-js/bindings.d.ts +++ b/apps/oxlint/src-js/bindings.d.ts @@ -8,14 +8,19 @@ export type JsLintFileCb = 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 60ab02ab930a3..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[]; - optionsInitialized = true; - } catch (err) { - // Re-throw with clearer message for Rust side logging. - throw new Error( - "Failed to parse external rule options JSON: " + - (err instanceof Error ? err.message : String(err)), - ); } -} - -/** - * Check if options have been initialized. - * @returns `true` if options have been set, `false` otherwise. - */ -export function areOptionsInitialized(): boolean { - return optionsInitialized; + 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 3e7d961f72139..70f96739f8136 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 `lintFile` +/// 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`, diff --git a/apps/oxlint/src/lint.rs b/apps/oxlint/src/lint.rs index ef045971c83b9..fa4132b88f030 100644 --- a/apps/oxlint/src/lint.rs +++ b/apps/oxlint/src/lint.rs @@ -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 de6aac5981927..1790efdd1a136 100644 --- a/apps/oxlint/src/run.rs +++ b/apps/oxlint/src/run.rs @@ -53,25 +53,47 @@ pub type JsLintFileCb = ThreadsafeFunction< 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 + false, +>; + /// 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. #[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 @@ -105,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/crates/oxc_linter/src/external_linter.rs b/crates/oxc_linter/src/external_linter.rs index c12d0305d7282..5d2c4b31ef9b2 100644 --- a/crates/oxc_linter/src/external_linter.rs +++ b/crates/oxc_linter/src/external_linter.rs @@ -10,6 +10,8 @@ pub type ExternalLinterLoadPluginCb = Box< + Sync, >; +pub type ExternalLinterSetupConfigsCb = Box Result<(), String> + Send + Sync>; + pub type ExternalLinterLintFileCb = Box< dyn Fn(String, Vec, Vec, String, &Allocator) -> Result, String> + Sync @@ -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 e293a96bf477d..764317ebcba1a 100644 --- a/crates/oxc_linter/src/external_plugin_store.rs +++ b/crates/oxc_linter/src/external_plugin_store.rs @@ -7,6 +7,8 @@ use rustc_hash::{FxHashMap, FxHashSet}; use oxc_index::{IndexVec, define_index_type}; +use crate::ExternalLinter; + define_index_type! { pub struct ExternalPluginId = u32; } @@ -141,12 +143,14 @@ impl ExternalPluginStore { self.options.push(options) } - /// Serialize all options to JSON string. - /// - /// # Panics - /// Panics if serialization fails. - pub fn serialize_all_options(&self) -> String { - serde_json::to_string(&self.options).expect("Failed to serialize 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}")), + } } } From a20747dd348c0e043c93ff11da982c5c3673e193 Mon Sep 17 00:00:00 2001 From: Arsh <69170106+lilnasy@users.noreply.github.com> Date: Sat, 29 Nov 2025 01:25:37 +0530 Subject: [PATCH 06/12] `createOnce()` -> `create()` --- apps/oxlint/test/fixtures/custom_plugin_with_options/plugin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/oxlint/test/fixtures/custom_plugin_with_options/plugin.ts b/apps/oxlint/test/fixtures/custom_plugin_with_options/plugin.ts index bf09e5751c2ee..6acf4ef48ebd7 100644 --- a/apps/oxlint/test/fixtures/custom_plugin_with_options/plugin.ts +++ b/apps/oxlint/test/fixtures/custom_plugin_with_options/plugin.ts @@ -12,7 +12,7 @@ export default definePlugin({ noOptions: "No options provided", }, }, - createOnce(context) { + create(context) { // Don't access context.options here - it's not available yet! // Options are only available in visitor methods after setupContextForFile is called. From 970159667e739e2eb37bda489bfa8b8771d0cd9f Mon Sep 17 00:00:00 2001 From: Arsh <69170106+lilnasy@users.noreply.github.com> Date: Sat, 29 Nov 2025 01:33:20 +0530 Subject: [PATCH 07/12] refactor: init `options` using `index_vec!` --- crates/oxc_linter/src/external_plugin_store.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/oxc_linter/src/external_plugin_store.rs b/crates/oxc_linter/src/external_plugin_store.rs index 764317ebcba1a..a6f361f98d161 100644 --- a/crates/oxc_linter/src/external_plugin_store.rs +++ b/crates/oxc_linter/src/external_plugin_store.rs @@ -5,7 +5,7 @@ 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; @@ -42,9 +42,7 @@ impl Default for ExternalPluginStore { impl ExternalPluginStore { pub fn new(is_enabled: bool) -> Self { - let mut options = IndexVec::default(); - // Index 0 is reserved for "no options" (empty array) - options.push(serde_json::json!([])); + let options = index_vec![serde_json::json!([])]; Self { registered_plugin_paths: FxHashSet::default(), From f56f6628d7f016876779cbdf5d1af1f4d87f8216 Mon Sep 17 00:00:00 2001 From: Arsh <69170106+lilnasy@users.noreply.github.com> Date: Sat, 29 Nov 2025 01:34:17 +0530 Subject: [PATCH 08/12] deep freeze the options array parsing --- apps/oxlint/src-js/plugins/options.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/oxlint/src-js/plugins/options.ts b/apps/oxlint/src-js/plugins/options.ts index 387a074130cd1..b85e9b2e95264 100644 --- a/apps/oxlint/src-js/plugins/options.ts +++ b/apps/oxlint/src-js/plugins/options.ts @@ -147,5 +147,6 @@ export function setOptions(optionsJson: string): void { if (!isArray(el)) throw new TypeError("Each options entry must be an array", { cause: el }); } } + deepFreezeArray(parsed); allOptions = parsed as Readonly[]; } From ad6d9fda8f700272715f24498c0eed4b9b0f2ad2 Mon Sep 17 00:00:00 2001 From: Arsh <69170106+lilnasy@users.noreply.github.com> Date: Sat, 29 Nov 2025 01:37:48 +0530 Subject: [PATCH 09/12] comment: explain `ruleDetails.options` assignment --- apps/oxlint/src-js/plugins/lint.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/oxlint/src-js/plugins/lint.ts b/apps/oxlint/src-js/plugins/lint.ts index e229a62579736..66baa23a9be3f 100644 --- a/apps/oxlint/src-js/plugins/lint.ts +++ b/apps/oxlint/src-js/plugins/lint.ts @@ -155,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]; From 88f5f37d495bc1b9ae2ac077f77518781b3e5b9c Mon Sep 17 00:00:00 2001 From: Arsh <69170106+lilnasy@users.noreply.github.com> Date: Sat, 29 Nov 2025 02:10:41 +0530 Subject: [PATCH 10/12] update copy-pasted comment --- apps/oxlint/src/js_plugins/external_linter.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/oxlint/src/js_plugins/external_linter.rs b/apps/oxlint/src/js_plugins/external_linter.rs index 70f96739f8136..4d3a85e5a1108 100644 --- a/apps/oxlint/src/js_plugins/external_linter.rs +++ b/apps/oxlint/src/js_plugins/external_linter.rs @@ -63,7 +63,7 @@ pub enum LintFileReturnValue { /// 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 `lintFile` +/// 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, From 4e03bc9f839c7f99824204d860d4bc84918cda46 Mon Sep 17 00:00:00 2001 From: Arsh <69170106+lilnasy@users.noreply.github.com> Date: Sat, 29 Nov 2025 02:15:14 +0530 Subject: [PATCH 11/12] simplify test --- .../custom_plugin_with_options/files/index.js | 3 -- .../custom_plugin_with_options/output.snap.md | 13 ++++--- .../custom_plugin_with_options/plugin.ts | 37 +++++-------------- 3 files changed, 18 insertions(+), 35 deletions(-) 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 index d4d622b99f9ad..eab74692130a6 100644 --- a/apps/oxlint/test/fixtures/custom_plugin_with_options/files/index.js +++ b/apps/oxlint/test/fixtures/custom_plugin_with_options/files/index.js @@ -1,4 +1 @@ -// Test file with debugger statement debugger; - -console.log("Hello, world!"); 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 index 802248eaa34f5..930a603c9b735 100644 --- a/apps/oxlint/test/fixtures/custom_plugin_with_options/output.snap.md +++ b/apps/oxlint/test/fixtures/custom_plugin_with_options/output.snap.md @@ -3,12 +3,15 @@ # stdout ``` - x test-plugin-options(check-options): Expected value to be production, got disabled - ,-[files/index.js:2:1] - 1 | // Test file with debugger statement - 2 | debugger; + x test-plugin-options(check-options): [ + | true, + | { + | "expected": "production" + | } + | ] + ,-[files/index.js:1:1] + 1 | debugger; : ^^^^^^^^^ - 3 | `---- Found 0 warnings and 1 error. diff --git a/apps/oxlint/test/fixtures/custom_plugin_with_options/plugin.ts b/apps/oxlint/test/fixtures/custom_plugin_with_options/plugin.ts index 6acf4ef48ebd7..93e7dfd058505 100644 --- a/apps/oxlint/test/fixtures/custom_plugin_with_options/plugin.ts +++ b/apps/oxlint/test/fixtures/custom_plugin_with_options/plugin.ts @@ -13,41 +13,24 @@ export default definePlugin({ }, }, create(context) { - // Don't access context.options here - it's not available yet! - // Options are only available in visitor methods after setupContextForFile is called. + const { options } = context; - return { - before() { - // Options are now available since setupContextForFile was called - const options = context.options; - - // Check if options were passed correctly - if (!options || options.length === 0) { - context.report({ - messageId: "noOptions", - loc: { start: { line: 1, column: 0 }, end: { line: 1, column: 1 } }, - }); - return false; - } - return true; - }, + // 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) { - // Options are available in visitor methods - const options = context.options; - // First option is a boolean const shouldReport = options[0]; - // Second option is an object - const config = (options[1] || {}) as { expected?: string }; if (shouldReport) { context.report({ - messageId: "wrongValue", - data: { - expected: String(config.expected || "enabled"), - actual: "disabled", - }, + message: JSON.stringify(context.options, null, 2), node, }); } From 42eee5da4b86947b40d2e8ef056f6c238f6776d1 Mon Sep 17 00:00:00 2001 From: Arsh <69170106+lilnasy@users.noreply.github.com> Date: Sat, 29 Nov 2025 02:22:59 +0530 Subject: [PATCH 12/12] remove unused method --- crates/oxc_linter/src/config/config_store.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/crates/oxc_linter/src/config/config_store.rs b/crates/oxc_linter/src/config/config_store.rs index 14fffb3a9d2ff..bb389ac157f14 100644 --- a/crates/oxc_linter/src/config/config_store.rs +++ b/crates/oxc_linter/src/config/config_store.rs @@ -316,11 +316,6 @@ impl ConfigStore { self.base.base.config.plugins } - /// Access the external plugin store. Used by oxlint CLI to serialize external rule options. - pub fn external_plugin_store(&self) -> &ExternalPluginStore { - &self.external_plugin_store - } - pub(crate) fn get_related_config(&self, path: &Path) -> &Config { if self.nested_configs.is_empty() { &self.base