From e59b08d6859b78eb0777603726a0efbbc3b5f85d Mon Sep 17 00:00:00 2001 From: Cameron Clark Date: Mon, 10 Nov 2025 14:33:15 +0000 Subject: [PATCH 1/6] feat(config): enhance external plugins support with optional aliases and improve serialization --- .../custom_plugin_name_alias/.oxlintrc.json | 9 + .../custom_plugin_name_alias/files/index.js | 1 + .../custom_plugin_name_alias/output.snap.md | 20 + .../custom_plugin_name_alias/plugin.ts | 23 ++ .../.oxlintrc.json | 6 + .../files/index.js | 1 + .../output.snap.md | 30 ++ .../plugin.ts | 23 ++ .../fixtures/reserved_name/output.snap.md | 17 +- .../oxc_linter/src/config/config_builder.rs | 46 ++- .../oxc_linter/src/config/external_plugins.rs | 372 ++++++++++++++++++ crates/oxc_linter/src/config/mod.rs | 1 + crates/oxc_linter/src/config/overrides.rs | 45 +-- crates/oxc_linter/src/config/oxlintrc.rs | 131 ++++-- .../oxc_linter/src/snapshots/schema_json.snap | 67 +++- npm/oxlint/configuration_schema.json | 67 +++- .../src/snapshots/schema_markdown.snap | 92 ++++- 17 files changed, 833 insertions(+), 118 deletions(-) create mode 100644 apps/oxlint/test/fixtures/custom_plugin_name_alias/.oxlintrc.json create mode 100644 apps/oxlint/test/fixtures/custom_plugin_name_alias/files/index.js create mode 100644 apps/oxlint/test/fixtures/custom_plugin_name_alias/output.snap.md create mode 100644 apps/oxlint/test/fixtures/custom_plugin_name_alias/plugin.ts create mode 100644 apps/oxlint/test/fixtures/custom_plugin_name_alias_reserved_name/.oxlintrc.json create mode 100644 apps/oxlint/test/fixtures/custom_plugin_name_alias_reserved_name/files/index.js create mode 100644 apps/oxlint/test/fixtures/custom_plugin_name_alias_reserved_name/output.snap.md create mode 100644 apps/oxlint/test/fixtures/custom_plugin_name_alias_reserved_name/plugin.ts create mode 100644 crates/oxc_linter/src/config/external_plugins.rs diff --git a/apps/oxlint/test/fixtures/custom_plugin_name_alias/.oxlintrc.json b/apps/oxlint/test/fixtures/custom_plugin_name_alias/.oxlintrc.json new file mode 100644 index 0000000000000..5ed061e7c1a0b --- /dev/null +++ b/apps/oxlint/test/fixtures/custom_plugin_name_alias/.oxlintrc.json @@ -0,0 +1,9 @@ +{ + "plugins": ["jsdoc"], + "jsPlugins": [{ "name": "jsPluginJsDoc", "specifier": "./plugin.ts" }], + "categories": { "correctness": "off" }, + "rules": { + "jsPluginJsDoc/no-debugger": "error", + "jsdoc/rule-name": "error" + } +} diff --git a/apps/oxlint/test/fixtures/custom_plugin_name_alias/files/index.js b/apps/oxlint/test/fixtures/custom_plugin_name_alias/files/index.js new file mode 100644 index 0000000000000..eab74692130a6 --- /dev/null +++ b/apps/oxlint/test/fixtures/custom_plugin_name_alias/files/index.js @@ -0,0 +1 @@ +debugger; diff --git a/apps/oxlint/test/fixtures/custom_plugin_name_alias/output.snap.md b/apps/oxlint/test/fixtures/custom_plugin_name_alias/output.snap.md new file mode 100644 index 0000000000000..8548e1827b3fb --- /dev/null +++ b/apps/oxlint/test/fixtures/custom_plugin_name_alias/output.snap.md @@ -0,0 +1,20 @@ +# Exit code +1 + +# stdout +``` + x jsPluginJsDoc(no-debugger): Unexpected Debugger Statement + ,-[files/index.js:1:1] + 1 | debugger; + : ^^^^^^^^^ + `---- + +Found 0 warnings and 1 error. +Finished in Xms on 1 file using X threads. +``` + +# stderr +``` +WARNING: JS plugins are experimental and not subject to semver. +Breaking changes are possible while JS plugins support is under development. +``` diff --git a/apps/oxlint/test/fixtures/custom_plugin_name_alias/plugin.ts b/apps/oxlint/test/fixtures/custom_plugin_name_alias/plugin.ts new file mode 100644 index 0000000000000..a7eb62c453024 --- /dev/null +++ b/apps/oxlint/test/fixtures/custom_plugin_name_alias/plugin.ts @@ -0,0 +1,23 @@ +import type { Plugin } from "../../../dist/index.js"; + +const plugin: Plugin = { + meta: { + name: "jsdoc", + }, + rules: { + "no-debugger": { + create(context) { + return { + DebuggerStatement(debuggerStatement) { + context.report({ + message: "Unexpected Debugger Statement", + node: debuggerStatement, + }); + }, + }; + }, + }, + }, +}; + +export default plugin; diff --git a/apps/oxlint/test/fixtures/custom_plugin_name_alias_reserved_name/.oxlintrc.json b/apps/oxlint/test/fixtures/custom_plugin_name_alias_reserved_name/.oxlintrc.json new file mode 100644 index 0000000000000..1f321b286e3fd --- /dev/null +++ b/apps/oxlint/test/fixtures/custom_plugin_name_alias_reserved_name/.oxlintrc.json @@ -0,0 +1,6 @@ +{ + "plugins": ["jsdoc"], + "jsPlugins": [{ "name": "jsdoc", "specifier": "./plugin.ts" }], + "rules": { + } +} diff --git a/apps/oxlint/test/fixtures/custom_plugin_name_alias_reserved_name/files/index.js b/apps/oxlint/test/fixtures/custom_plugin_name_alias_reserved_name/files/index.js new file mode 100644 index 0000000000000..eab74692130a6 --- /dev/null +++ b/apps/oxlint/test/fixtures/custom_plugin_name_alias_reserved_name/files/index.js @@ -0,0 +1 @@ +debugger; diff --git a/apps/oxlint/test/fixtures/custom_plugin_name_alias_reserved_name/output.snap.md b/apps/oxlint/test/fixtures/custom_plugin_name_alias_reserved_name/output.snap.md new file mode 100644 index 0000000000000..7317811eceb00 --- /dev/null +++ b/apps/oxlint/test/fixtures/custom_plugin_name_alias_reserved_name/output.snap.md @@ -0,0 +1,30 @@ +# Exit code +1 + +# stdout +``` +Failed to parse configuration file. + + x Plugin name 'jsdoc' is reserved, and cannot be used for JS plugins. + | + | The 'jsdoc' plugin is already implemented natively in Rust within oxlint. + | Using both the native and JS versions would create ambiguity about which rules to use. + | + | To use an external 'jsdoc' plugin instead, provide a custom alias: + | + | "jsPlugins": [{ "name": "jsdoc-js", "specifier": "eslint-plugin-jsdoc" }] + | + | Then reference rules using your alias: + | + | "rules": { + | "jsdoc-js/rule-name": "error" + | } + | + | See: https://oxc.rs/docs/guide/usage/linter/js-plugins.html +``` + +# 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_name_alias_reserved_name/plugin.ts b/apps/oxlint/test/fixtures/custom_plugin_name_alias_reserved_name/plugin.ts new file mode 100644 index 0000000000000..a7eb62c453024 --- /dev/null +++ b/apps/oxlint/test/fixtures/custom_plugin_name_alias_reserved_name/plugin.ts @@ -0,0 +1,23 @@ +import type { Plugin } from "../../../dist/index.js"; + +const plugin: Plugin = { + meta: { + name: "jsdoc", + }, + rules: { + "no-debugger": { + create(context) { + return { + DebuggerStatement(debuggerStatement) { + context.report({ + message: "Unexpected Debugger Statement", + node: debuggerStatement, + }); + }, + }; + }, + }, + }, +}; + +export default plugin; diff --git a/apps/oxlint/test/fixtures/reserved_name/output.snap.md b/apps/oxlint/test/fixtures/reserved_name/output.snap.md index 4e24546f13dc6..991ebbd412da4 100644 --- a/apps/oxlint/test/fixtures/reserved_name/output.snap.md +++ b/apps/oxlint/test/fixtures/reserved_name/output.snap.md @@ -5,7 +5,22 @@ ``` Failed to parse configuration file. - x Plugin name 'import' is reserved, and cannot be used for JS plugins + x Plugin name 'import' is reserved, and cannot be used for JS plugins. + | + | The 'import' plugin is already implemented natively in Rust within oxlint. + | Using both the native and JS versions would create ambiguity about which rules to use. + | + | To use an external 'import' plugin instead, provide a custom alias: + | + | "jsPlugins": [{ "name": "import-js", "specifier": "eslint-plugin-import" }] + | + | Then reference rules using your alias: + | + | "rules": { + | "import-js/rule-name": "error" + | } + | + | See: https://oxc.rs/docs/guide/usage/linter/js-plugins.html ``` # stderr diff --git a/crates/oxc_linter/src/config/config_builder.rs b/crates/oxc_linter/src/config/config_builder.rs index f192395dade34..9dad97cfed83d 100644 --- a/crates/oxc_linter/src/config/config_builder.rs +++ b/crates/oxc_linter/src/config/config_builder.rs @@ -15,6 +15,7 @@ use crate::{ RuleCategory, RuleEnum, config::{ ESLintRule, OxlintOverrides, OxlintRules, + external_plugins::ExternalPluginEntry, overrides::OxlintOverride, plugins::{LintPlugins, normalize_plugin_name}, }, @@ -150,16 +151,15 @@ impl ConfigStoreBuilder { let (oxlintrc, extended_paths) = resolve_oxlintrc_config(oxlintrc)?; // Collect external plugins from both base config and overrides - let mut external_plugins: FxHashSet<(&PathBuf, &str)> = FxHashSet::default(); + let mut external_plugins: FxHashSet<&ExternalPluginEntry> = FxHashSet::default(); if let Some(base_external_plugins) = &oxlintrc.external_plugins { - external_plugins.extend(base_external_plugins.iter().map(|(k, v)| (k, v.as_str()))); + external_plugins.extend(base_external_plugins.iter()); } for r#override in &oxlintrc.overrides { if let Some(override_external_plugins) = &r#override.external_plugins { - external_plugins - .extend(override_external_plugins.iter().map(|(k, v)| (k, v.as_str()))); + external_plugins.extend(override_external_plugins.iter()); } } @@ -169,9 +169,9 @@ impl ConfigStoreBuilder { if !external_plugins.is_empty() && external_plugin_store.is_enabled() { let Some(external_linter) = external_linter else { #[expect(clippy::missing_panics_doc, reason = "infallible")] - let (_, original_specifier) = external_plugins.iter().next().unwrap(); + let first_plugin = external_plugins.iter().next().unwrap(); return Err(ConfigBuilderError::NoExternalLinterConfigured { - plugin_specifier: (*original_specifier).to_string(), + plugin_specifier: first_plugin.specifier.clone(), }); }; @@ -180,10 +180,11 @@ impl ConfigStoreBuilder { ..Default::default() }); - for (config_path, specifier) in &external_plugins { + for entry in &external_plugins { Self::load_external_plugin( - config_path, - specifier, + &entry.config_dir, + &entry.specifier, + entry.name.as_deref(), external_linter, &resolver, external_plugin_store, @@ -523,6 +524,7 @@ impl ConfigStoreBuilder { fn load_external_plugin( resolve_dir: &Path, plugin_specifier: &str, + alias: Option<&str>, external_linter: &ExternalLinter, resolver: &Resolver, external_plugin_store: &mut ExternalPluginStore, @@ -562,8 +564,13 @@ impl ConfigStoreBuilder { } })?; - // Normalize plugin name (e.g., "eslint-plugin-foo" -> "foo", "@foo/eslint-plugin" -> "@foo") - let plugin_name = normalize_plugin_name(&result.name).into_owned(); + // Use alias if provided, otherwise normalize plugin name + let plugin_name = if let Some(alias_name) = alias { + alias_name.to_string() + } else { + // Normalize plugin name (e.g., "eslint-plugin-foo" -> "foo", "@foo/eslint-plugin" -> "@foo") + normalize_plugin_name(&result.name).into_owned() + }; if LintPlugins::try_from(plugin_name.as_str()).is_err() { external_plugin_store.register_plugin( @@ -652,7 +659,22 @@ impl Display for ConfigBuilderError { ConfigBuilderError::ReservedExternalPluginName { plugin_name } => { write!( f, - "Plugin name '{plugin_name}' is reserved, and cannot be used for JS plugins", + "Plugin name '{plugin_name}' is reserved, and cannot be used for JS plugins.\n\ + \n\ + The '{plugin_name}' plugin is already implemented natively in Rust within oxlint.\n\ + Using both the native and JS versions would create ambiguity about which rules to use.\n\ + \n\ + To use an external '{plugin_name}' plugin instead, provide a custom alias:\n\ + \n\ + \"jsPlugins\": [{{ \"name\": \"{plugin_name}-js\", \"specifier\": \"eslint-plugin-{plugin_name}\" }}]\n\ + \n\ + Then reference rules using your alias:\n\ + \n\ + \"rules\": {{\n\ + \"{plugin_name}-js/rule-name\": \"error\"\n\ + }}\n\ + \n\ + See: https://oxc.rs/docs/guide/usage/linter/js-plugins.html", )?; Ok(()) } diff --git a/crates/oxc_linter/src/config/external_plugins.rs b/crates/oxc_linter/src/config/external_plugins.rs new file mode 100644 index 0000000000000..51b5699dbdf38 --- /dev/null +++ b/crates/oxc_linter/src/config/external_plugins.rs @@ -0,0 +1,372 @@ +use std::path::PathBuf; + +use rustc_hash::FxHashSet; +use schemars::{JsonSchema, SchemaGenerator, schema::Schema}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// External plugin entry containing the plugin specifier and optional custom name +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ExternalPluginEntry { + /// Directory containing the config file that specified this plugin + pub config_dir: PathBuf, + /// Plugin specifier (path, package name, or URL) + pub specifier: String, + /// Optional custom name/alias for the plugin + pub name: Option, +} + +impl JsonSchema for ExternalPluginEntry { + fn schema_name() -> String { + "ExternalPluginEntry".to_string() + } + + fn json_schema(_gen: &mut SchemaGenerator) -> Schema { + use schemars::schema::{ + InstanceType, Metadata, ObjectValidation, SchemaObject, SubschemaValidation, + }; + + // Schema represents: string | { name: string, specifier: string } + let string_schema = SchemaObject { + instance_type: Some(InstanceType::String.into()), + metadata: Some(Box::new(Metadata { + description: Some("Path or package name of the plugin".to_string()), + ..Default::default() + })), + ..Default::default() + }; + + let mut object_properties = schemars::Map::new(); + object_properties.insert( + "name".to_string(), + SchemaObject { + instance_type: Some(InstanceType::String.into()), + metadata: Some(Box::new(Metadata { + description: Some( + "Custom name/alias for the plugin.\n\n\ + Note: The following plugin names are reserved because they are implemented \ + natively in Rust within oxlint and cannot be used for JS plugins:\n\ + - react (includes react-hooks)\n\ + - unicorn\n\ + - typescript\n\ + - oxc\n\ + - import (includes import-x)\n\ + - jsdoc\n\ + - jest\n\ + - vitest\n\ + - jsx-a11y\n\ + - nextjs\n\ + - react-perf\n\ + - promise\n\ + - node\n\ + - regex\n\ + - vue\n\ + - eslint\n\n\ + If you need to use the JavaScript version of any of these plugins, \ + provide a custom alias to avoid conflicts." + .to_string() + ), + ..Default::default() + })), + ..Default::default() + } + .into(), + ); + object_properties.insert( + "specifier".to_string(), + SchemaObject { + instance_type: Some(InstanceType::String.into()), + metadata: Some(Box::new(Metadata { + description: Some("Path or package name of the plugin".to_string()), + ..Default::default() + })), + ..Default::default() + } + .into(), + ); + + let object_schema = SchemaObject { + instance_type: Some(InstanceType::Object.into()), + metadata: Some(Box::new(Metadata { + description: Some("Plugin with custom name/alias".to_string()), + ..Default::default() + })), + object: Some(Box::new(ObjectValidation { + properties: object_properties, + required: vec!["name".to_string(), "specifier".to_string()].into_iter().collect(), + additional_properties: Some(Box::new(Schema::Bool(false))), + ..Default::default() + })), + ..Default::default() + }; + + SchemaObject { + subschemas: Some(Box::new(SubschemaValidation { + any_of: Some(vec![string_schema.into(), object_schema.into()]), + ..Default::default() + })), + ..Default::default() + } + .into() + } +} + +impl<'de> Deserialize<'de> for ExternalPluginEntry { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct PluginObject { + name: String, + specifier: String, + } + + #[derive(Deserialize)] + #[serde(untagged)] + enum PluginEntry { + String(String), + Object(PluginObject), + } + + let entry = PluginEntry::deserialize(deserializer)?; + + Ok(match entry { + PluginEntry::String(specifier) => { + ExternalPluginEntry { config_dir: PathBuf::default(), specifier, name: None } + } + PluginEntry::Object(obj) => ExternalPluginEntry { + config_dir: PathBuf::default(), + specifier: obj.specifier, + name: Some(obj.name), + }, + }) + } +} + +/// Custom deserializer for external plugins +/// Supports: +/// - Array of strings: `["plugin1", "plugin2"]` +/// - Array of objects: `[{ "name": "alias", "specifier": "plugin" }]` +/// - Mixed array: `["plugin1", { "name": "alias", "specifier": "plugin2" }]` +pub fn deserialize_external_plugins<'de, D>( + deserializer: D, +) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + use serde::de::{Error, SeqAccess, Visitor}; + use std::fmt; + + struct PluginSetVisitor; + + impl<'de> Visitor<'de> for PluginSetVisitor { + type Value = Option>; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("null or an array of plugin entries") + } + + fn visit_none(self) -> Result + where + E: Error, + { + Ok(None) + } + + fn visit_unit(self) -> Result + where + E: Error, + { + Ok(None) + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: SeqAccess<'de>, + { + let mut plugins = FxHashSet::default(); + + while let Some(entry) = seq.next_element::()? { + plugins.insert(entry); + } + + Ok(Some(plugins)) + } + } + + deserializer.deserialize_any(PluginSetVisitor) +} + +/// Custom JSON schema generator for external plugins that includes uniqueItems constraint +pub fn external_plugins_schema(generator: &mut SchemaGenerator) -> Schema { + use schemars::schema::{ArrayValidation, InstanceType, SchemaObject, SubschemaValidation}; + + let entry_schema = generator.subschema_for::(); + + let array_schema = SchemaObject { + instance_type: Some(InstanceType::Array.into()), + array: Some(Box::new(ArrayValidation { + items: Some(entry_schema.into()), + unique_items: Some(true), + ..Default::default() + })), + ..Default::default() + }; + + SchemaObject { + subschemas: Some(Box::new(SubschemaValidation { + any_of: Some(vec![ + SchemaObject { + instance_type: Some(InstanceType::Null.into()), + ..Default::default() + } + .into(), + array_schema.into(), + ]), + ..Default::default() + })), + ..Default::default() + } + .into() +} + +impl Serialize for ExternalPluginEntry { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + #[derive(Serialize)] + #[serde(untagged)] + enum PluginEntry<'a> { + String(&'a str), + Object { name: &'a str, specifier: &'a str }, + } + + if let Some(ref alias_name) = self.name { + PluginEntry::Object { name: alias_name.as_str(), specifier: self.specifier.as_str() } + .serialize(serializer) + } else { + PluginEntry::String(&self.specifier).serialize(serializer) + } + } +} + +#[cfg(test)] +mod test { + use rustc_hash::FxHashSet; + + use super::*; + + #[test] + fn test_deserialize() { + #[derive(Deserialize)] + struct TestConfig { + #[serde( + rename = "jsPlugins", + default, + deserialize_with = "deserialize_external_plugins" + )] + plugins: Option>, + } + + let json = serde_json::json!({ + "jsPlugins": [ + "./plugin.ts", + { "name": "custom", "specifier": "./plugin2.ts" } + ] + }); + let config: TestConfig = serde_json::from_value(json).unwrap(); + let plugins = config.plugins.as_ref().unwrap(); + assert_eq!(plugins.len(), 2); + assert_eq!(plugins.iter().filter(|e| e.name.is_some()).count(), 1); + + // Null + let json = serde_json::json!({ "jsPlugins": null }); + let config: TestConfig = serde_json::from_value(json).unwrap(); + assert!(config.plugins.is_none()); + + // Empty array + let json = serde_json::json!({ "jsPlugins": [] }); + let config: TestConfig = serde_json::from_value(json).unwrap(); + assert_eq!(config.plugins.unwrap().len(), 0); + } + + #[test] + fn test_deserialize_mixed_formats() { + #[derive(Deserialize)] + struct TestConfig { + #[serde( + rename = "jsPlugins", + default, + deserialize_with = "deserialize_external_plugins" + )] + plugins: Option>, + } + + // Mix string and object formats + let json = serde_json::json!({ + "jsPlugins": [ + "eslint-plugin-import", + { "name": "custom", "specifier": "./plugin.ts" } + ] + }); + let config: TestConfig = serde_json::from_value(json).unwrap(); + let plugins = config.plugins.as_ref().unwrap(); + assert_eq!(plugins.len(), 2); + assert_eq!(plugins.iter().filter(|e| e.name.is_some()).count(), 1); + } + + #[test] + fn test_deserialize_rejects_invalid() { + #[derive(Deserialize)] + struct TestConfig { + #[expect(dead_code)] + #[serde( + rename = "jsPlugins", + default, + deserialize_with = "deserialize_external_plugins" + )] + plugins: Option>, + } + + // Extra fields should be rejected + let json = serde_json::json!({ + "jsPlugins": [ + { "name": "x", "specifier": "y", "extra": "z" } + ] + }); + assert!(serde_json::from_value::(json).is_err()); + + // Missing required fields should be rejected + let json = serde_json::json!({ "jsPlugins": [{ "name": "x" }] }); + assert!(serde_json::from_value::(json).is_err()); + + // Object with single arbitrary field should be rejected + let json = serde_json::json!({ "jsPlugins": [{ "alias": "plugin" }] }); + assert!(serde_json::from_value::(json).is_err()); + } + + #[test] + fn test_serialize() { + let mut plugins = FxHashSet::default(); + plugins.insert(ExternalPluginEntry { + config_dir: PathBuf::default(), + specifier: "./plugin.ts".to_string(), + name: None, + }); + plugins.insert(ExternalPluginEntry { + config_dir: PathBuf::default(), + specifier: "./plugin2.ts".to_string(), + name: Some("custom".to_string()), + }); + + let json = serde_json::to_value(Some(plugins)).unwrap(); + let arr = json.as_array().unwrap(); + assert_eq!(arr.len(), 2); + + // Null + let json = serde_json::to_value(&None::>).unwrap(); + assert!(json.is_null()); + } +} diff --git a/crates/oxc_linter/src/config/mod.rs b/crates/oxc_linter/src/config/mod.rs index 96aaca0cee844..e1af28af347e2 100644 --- a/crates/oxc_linter/src/config/mod.rs +++ b/crates/oxc_linter/src/config/mod.rs @@ -4,6 +4,7 @@ mod categories; mod config_builder; mod config_store; mod env; +mod external_plugins; mod globals; mod ignore_matcher; mod overrides; diff --git a/crates/oxc_linter/src/config/overrides.rs b/crates/oxc_linter/src/config/overrides.rs index 216caa075b8b0..315bb4278e89f 100644 --- a/crates/oxc_linter/src/config/overrides.rs +++ b/crates/oxc_linter/src/config/overrides.rs @@ -1,15 +1,17 @@ use std::{ borrow::Cow, ops::{Deref, DerefMut}, - path::PathBuf, }; -use rustc_hash::FxHashSet; use schemars::{JsonSchema, r#gen, schema::Schema}; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde::{Deserialize, Deserializer, Serialize}; use crate::{LintPlugins, OxlintEnv, OxlintGlobals, config::OxlintRules}; +use super::external_plugins::{ + ExternalPluginEntry, deserialize_external_plugins, external_plugins_schema, +}; + // nominal wrapper required to add JsonSchema impl #[derive(Debug, Default, Clone, Deserialize, Serialize)] pub struct OxlintOverrides(Vec); @@ -101,15 +103,12 @@ pub struct OxlintOverride { /// They are not supported in language server at present. #[serde( rename = "jsPlugins", - deserialize_with = "deserialize_external_plugins_override", - serialize_with = "serialize_external_plugins_override", default, - skip_serializing_if = "Option::is_none" + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_external_plugins" )] - #[schemars(with = "Option>")] - pub external_plugins: Option< - FxHashSet<(PathBuf /* config file directory */, String /* plugin specifier */)>, - >, + #[schemars(schema_with = "external_plugins_schema")] + pub external_plugins: Option>, #[serde(default)] pub rules: OxlintRules, @@ -150,32 +149,6 @@ impl GlobSet { } } -fn deserialize_external_plugins_override<'de, D>( - deserializer: D, -) -> Result>, D::Error> -where - D: Deserializer<'de>, -{ - let opt_set: Option> = Option::deserialize(deserializer)?; - Ok(opt_set - .map(|set| set.into_iter().map(|specifier| (PathBuf::default(), specifier)).collect())) -} - -#[expect(clippy::ref_option)] -fn serialize_external_plugins_override( - plugins: &Option>, - serializer: S, -) -> Result -where - S: Serializer, -{ - // Serialize as an array of original specifiers (the values in the map) - match plugins { - Some(set) => serializer.collect_seq(set.iter().map(|(_, specifier)| specifier)), - None => serializer.serialize_none(), - } -} - #[cfg(test)] mod test { use crate::config::{globals::GlobalValue, plugins::LintPlugins}; diff --git a/crates/oxc_linter/src/config/oxlintrc.rs b/crates/oxc_linter/src/config/oxlintrc.rs index 9436ab28da5cf..e3625e57eb706 100644 --- a/crates/oxc_linter/src/config/oxlintrc.rs +++ b/crates/oxc_linter/src/config/oxlintrc.rs @@ -3,17 +3,24 @@ use std::{ path::{Path, PathBuf}, }; -use rustc_hash::{FxHashMap, FxHashSet}; +use rustc_hash::FxHashMap; use schemars::{JsonSchema, schema_for}; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde::{Deserialize, Serialize}; use oxc_diagnostics::OxcDiagnostic; use crate::{LintPlugins, utils::read_to_string}; use super::{ - categories::OxlintCategories, env::OxlintEnv, globals::OxlintGlobals, - overrides::OxlintOverrides, rules::OxlintRules, settings::OxlintSettings, + categories::OxlintCategories, + env::OxlintEnv, + external_plugins::{ + ExternalPluginEntry, deserialize_external_plugins, external_plugins_schema, + }, + globals::OxlintGlobals, + overrides::OxlintOverrides, + rules::OxlintRules, + settings::OxlintSettings, }; /// Oxlint Configuration File @@ -76,13 +83,12 @@ pub struct Oxlintrc { /// They are not supported in language server at present. #[serde( rename = "jsPlugins", - deserialize_with = "deserialize_external_plugins", - serialize_with = "serialize_external_plugins", default, - skip_serializing_if = "Option::is_none" + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_external_plugins" )] - #[schemars(with = "Option>")] - pub external_plugins: Option>, + #[schemars(schema_with = "external_plugins_schema")] + pub external_plugins: Option>, pub categories: OxlintCategories, /// Example /// @@ -170,7 +176,10 @@ impl Oxlintrc { if let Some(external_plugins) = &mut config.external_plugins { *external_plugins = std::mem::take(external_plugins) .into_iter() - .map(|(_, specifier)| (config_dir.to_path_buf(), specifier)) + .map(|mut entry| { + entry.config_dir = config_dir.to_path_buf(); + entry + }) .collect(); } @@ -178,7 +187,10 @@ impl Oxlintrc { if let Some(external_plugins) = &mut override_config.external_plugins { *external_plugins = std::mem::take(external_plugins) .into_iter() - .map(|(_, specifier)| (config_dir.to_path_buf(), specifier)) + .map(|mut entry| { + entry.config_dir = config_dir.to_path_buf(); + entry + }) .collect(); } } @@ -315,7 +327,7 @@ impl Oxlintrc { let external_plugins = match (&self.external_plugins, &other.external_plugins) { (Some(self_external), Some(other_external)) => { - Some(self_external.iter().chain(other_external).cloned().collect()) + Some(self_external.iter().chain(other_external.iter()).cloned().collect()) } (Some(self_external), None) => Some(self_external.clone()), (None, Some(other_external)) => Some(other_external.clone()), @@ -342,37 +354,12 @@ fn is_json_ext(ext: &str) -> bool { ext == "json" || ext == "jsonc" } -fn deserialize_external_plugins<'de, D>( - deserializer: D, -) -> Result>, D::Error> -where - D: Deserializer<'de>, -{ - let opt_set: Option> = Option::deserialize(deserializer)?; - Ok(opt_set - .map(|set| set.into_iter().map(|specifier| (PathBuf::default(), specifier)).collect())) -} - -#[expect(clippy::ref_option)] -fn serialize_external_plugins( - plugins: &Option>, - serializer: S, -) -> Result -where - S: Serializer, -{ - // Serialize as an array of original specifiers (the values in the map) - match plugins { - Some(set) => serializer.collect_seq(set.iter().map(|(_, specifier)| specifier)), - None => serializer.serialize_none(), - } -} - #[cfg(test)] mod test { + use rustc_hash::FxHashSet; use serde_json::json; - use crate::config::plugins::LintPlugins; + use crate::config::{external_plugins::ExternalPluginEntry, plugins::LintPlugins}; use super::*; @@ -435,4 +422,70 @@ mod test { let config: Oxlintrc = serde_json::from_str(r#"{"extends": []}"#).unwrap(); assert_eq!(0, config.extends.len()); } + + #[test] + fn test_oxlintrc_js_plugins() { + let config: Oxlintrc = serde_json::from_str( + r#"{"jsPlugins": ["./plugin.ts", { "name": "custom", "specifier": "./plugin2.ts" }]}"#, + ) + .unwrap(); + assert_eq!(config.external_plugins.as_ref().unwrap().len(), 2); + + // None + let config: Oxlintrc = serde_json::from_str(r"{}").unwrap(); + assert!(config.external_plugins.is_none()); + + // Empty array + let config: Oxlintrc = serde_json::from_str(r#"{"jsPlugins": []}"#).unwrap(); + assert_eq!(config.external_plugins.as_ref().unwrap().len(), 0); + } + + #[test] + fn test_oxlintrc_js_plugins_rejects_invalid() { + // Extra fields should be rejected + assert!( + serde_json::from_str::( + r#"{"jsPlugins": [{ "name": "x", "specifier": "y", "extra": "z" }]}"# + ) + .is_err() + ); + + // Missing required fields should be rejected + assert!(serde_json::from_str::(r#"{"jsPlugins": [{ "name": "x" }]}"#).is_err()); + + // Object with arbitrary field should be rejected + assert!( + serde_json::from_str::(r#"{"jsPlugins": [{ "myAlias": "my-plugin" }]}"#) + .is_err() + ); + } + + #[test] + fn test_oxlintrc_js_plugins_roundtrip() { + let mut config = Oxlintrc::default(); + let mut plugins = FxHashSet::default(); + plugins.insert(ExternalPluginEntry { + config_dir: PathBuf::default(), + specifier: "./plugin.ts".to_string(), + name: None, + }); + plugins.insert(ExternalPluginEntry { + config_dir: PathBuf::default(), + specifier: "./plugin2.ts".to_string(), + name: Some("custom".to_string()), + }); + config.external_plugins = Some(plugins); + + let serialized = serde_json::to_string(&config).unwrap(); + let deserialized: Oxlintrc = serde_json::from_str(&serialized).unwrap(); + assert_eq!(config.external_plugins, deserialized.external_plugins); + } + + #[test] + fn test_oxlintrc_js_plugins_merge() { + let config1: Oxlintrc = serde_json::from_str(r#"{"jsPlugins": ["./plugin1.ts"]}"#).unwrap(); + let config2: Oxlintrc = serde_json::from_str(r#"{"jsPlugins": ["./plugin2.ts"]}"#).unwrap(); + let merged = config1.merge(config2); + assert_eq!(merged.external_plugins.unwrap().len(), 2); + } } diff --git a/crates/oxc_linter/src/snapshots/schema_json.snap b/crates/oxc_linter/src/snapshots/schema_json.snap index 5502cd7334a39..758f6bdc81906 100644 --- a/crates/oxc_linter/src/snapshots/schema_json.snap +++ b/crates/oxc_linter/src/snapshots/schema_json.snap @@ -57,14 +57,18 @@ expression: json }, "jsPlugins": { "description": "JS plugins.\n\nNote: JS plugins are experimental and not subject to semver.\nThey are not supported in language server at present.", - "type": [ - "array", - "null" + "anyOf": [ + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/ExternalPluginEntry" + }, + "uniqueItems": true + } ], - "items": { - "type": "string" - }, - "uniqueItems": true, "markdownDescription": "JS plugins.\n\nNote: JS plugins are experimental and not subject to semver.\nThey are not supported in language server at present." }, "overrides": { @@ -226,6 +230,37 @@ expression: json }, "markdownDescription": "See [Oxlint Rules](https://oxc.rs/docs/guide/usage/linter/rules.html)" }, + "ExternalPluginEntry": { + "anyOf": [ + { + "description": "Path or package name of the plugin", + "type": "string", + "markdownDescription": "Path or package name of the plugin" + }, + { + "description": "Plugin with custom name/alias", + "type": "object", + "required": [ + "name", + "specifier" + ], + "properties": { + "name": { + "description": "Custom name/alias for the plugin.\n\nNote: The following plugin names are reserved because they are implemented natively in Rust within oxlint and cannot be used for JS plugins:\n- react (includes react-hooks)\n- unicorn\n- typescript\n- oxc\n- import (includes import-x)\n- jsdoc\n- jest\n- vitest\n- jsx-a11y\n- nextjs\n- react-perf\n- promise\n- node\n- regex\n- vue\n- eslint\n\nIf you need to use the JavaScript version of any of these plugins, provide a custom alias to avoid conflicts.", + "type": "string", + "markdownDescription": "Custom name/alias for the plugin.\n\nNote: The following plugin names are reserved because they are implemented natively in Rust within oxlint and cannot be used for JS plugins:\n- react (includes react-hooks)\n- unicorn\n- typescript\n- oxc\n- import (includes import-x)\n- jsdoc\n- jest\n- vitest\n- jsx-a11y\n- nextjs\n- react-perf\n- promise\n- node\n- regex\n- vue\n- eslint\n\nIf you need to use the JavaScript version of any of these plugins, provide a custom alias to avoid conflicts." + }, + "specifier": { + "description": "Path or package name of the plugin", + "type": "string", + "markdownDescription": "Path or package name of the plugin" + } + }, + "additionalProperties": false, + "markdownDescription": "Plugin with custom name/alias" + } + ] + }, "GlobSet": { "description": "A set of glob patterns.", "type": "array", @@ -484,14 +519,18 @@ expression: json }, "jsPlugins": { "description": "JS plugins for this override.\n\nNote: JS plugins are experimental and not subject to semver.\nThey are not supported in language server at present.", - "type": [ - "array", - "null" + "anyOf": [ + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/ExternalPluginEntry" + }, + "uniqueItems": true + } ], - "items": { - "type": "string" - }, - "uniqueItems": true, "markdownDescription": "JS plugins for this override.\n\nNote: JS plugins are experimental and not subject to semver.\nThey are not supported in language server at present." }, "plugins": { diff --git a/npm/oxlint/configuration_schema.json b/npm/oxlint/configuration_schema.json index 51b506d9c0b1f..d6a43b36e5d86 100644 --- a/npm/oxlint/configuration_schema.json +++ b/npm/oxlint/configuration_schema.json @@ -53,14 +53,18 @@ }, "jsPlugins": { "description": "JS plugins.\n\nNote: JS plugins are experimental and not subject to semver.\nThey are not supported in language server at present.", - "type": [ - "array", - "null" + "anyOf": [ + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/ExternalPluginEntry" + }, + "uniqueItems": true + } ], - "items": { - "type": "string" - }, - "uniqueItems": true, "markdownDescription": "JS plugins.\n\nNote: JS plugins are experimental and not subject to semver.\nThey are not supported in language server at present." }, "overrides": { @@ -222,6 +226,37 @@ }, "markdownDescription": "See [Oxlint Rules](https://oxc.rs/docs/guide/usage/linter/rules.html)" }, + "ExternalPluginEntry": { + "anyOf": [ + { + "description": "Path or package name of the plugin", + "type": "string", + "markdownDescription": "Path or package name of the plugin" + }, + { + "description": "Plugin with custom name/alias", + "type": "object", + "required": [ + "name", + "specifier" + ], + "properties": { + "name": { + "description": "Custom name/alias for the plugin.\n\nNote: The following plugin names are reserved because they are implemented natively in Rust within oxlint and cannot be used for JS plugins:\n- react (includes react-hooks)\n- unicorn\n- typescript\n- oxc\n- import (includes import-x)\n- jsdoc\n- jest\n- vitest\n- jsx-a11y\n- nextjs\n- react-perf\n- promise\n- node\n- regex\n- vue\n- eslint\n\nIf you need to use the JavaScript version of any of these plugins, provide a custom alias to avoid conflicts.", + "type": "string", + "markdownDescription": "Custom name/alias for the plugin.\n\nNote: The following plugin names are reserved because they are implemented natively in Rust within oxlint and cannot be used for JS plugins:\n- react (includes react-hooks)\n- unicorn\n- typescript\n- oxc\n- import (includes import-x)\n- jsdoc\n- jest\n- vitest\n- jsx-a11y\n- nextjs\n- react-perf\n- promise\n- node\n- regex\n- vue\n- eslint\n\nIf you need to use the JavaScript version of any of these plugins, provide a custom alias to avoid conflicts." + }, + "specifier": { + "description": "Path or package name of the plugin", + "type": "string", + "markdownDescription": "Path or package name of the plugin" + } + }, + "additionalProperties": false, + "markdownDescription": "Plugin with custom name/alias" + } + ] + }, "GlobSet": { "description": "A set of glob patterns.", "type": "array", @@ -480,14 +515,18 @@ }, "jsPlugins": { "description": "JS plugins for this override.\n\nNote: JS plugins are experimental and not subject to semver.\nThey are not supported in language server at present.", - "type": [ - "array", - "null" + "anyOf": [ + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/ExternalPluginEntry" + }, + "uniqueItems": true + } ], - "items": { - "type": "string" - }, - "uniqueItems": true, "markdownDescription": "JS plugins for this override.\n\nNote: JS plugins are experimental and not subject to semver.\nThey are not supported in language server at present." }, "plugins": { diff --git a/tasks/website_linter/src/snapshots/schema_markdown.snap b/tasks/website_linter/src/snapshots/schema_markdown.snap index de88649082af5..c8f5671fc84a6 100644 --- a/tasks/website_linter/src/snapshots/schema_markdown.snap +++ b/tasks/website_linter/src/snapshots/schema_markdown.snap @@ -198,7 +198,7 @@ Globs to ignore during linting. These are resolved from the configuration file p ## jsPlugins -type: `string[]` +type: `array | null` JS plugins. @@ -207,6 +207,50 @@ Note: JS plugins are experimental and not subject to semver. They are not supported in language server at present. +### jsPlugins[n] + +type: `object | string` + + + + + +#### jsPlugins[n].name + +type: `string` + + +Custom name/alias for the plugin. + +Note: The following plugin names are reserved because they are implemented natively in Rust within oxlint and cannot be used for JS plugins: +- react (includes react-hooks) +- unicorn +- typescript +- oxc +- import (includes import-x) +- jsdoc +- jest +- vitest +- jsx-a11y +- nextjs +- react-perf +- promise +- node +- regex +- vue +- eslint + +If you need to use the JavaScript version of any of these plugins, provide a custom alias to avoid conflicts. + + +#### jsPlugins[n].specifier + +type: `string` + + +Path or package name of the plugin + + ## overrides type: `array` @@ -249,7 +293,7 @@ Enabled or disabled specific global variables. #### overrides[n].jsPlugins -type: `string[]` +type: `array | null` JS plugins for this override. @@ -258,6 +302,50 @@ Note: JS plugins are experimental and not subject to semver. They are not supported in language server at present. +##### overrides[n].jsPlugins[n] + +type: `object | string` + + + + + +###### overrides[n].jsPlugins[n].name + +type: `string` + + +Custom name/alias for the plugin. + +Note: The following plugin names are reserved because they are implemented natively in Rust within oxlint and cannot be used for JS plugins: +- react (includes react-hooks) +- unicorn +- typescript +- oxc +- import (includes import-x) +- jsdoc +- jest +- vitest +- jsx-a11y +- nextjs +- react-perf +- promise +- node +- regex +- vue +- eslint + +If you need to use the JavaScript version of any of these plugins, provide a custom alias to avoid conflicts. + + +###### overrides[n].jsPlugins[n].specifier + +type: `string` + + +Path or package name of the plugin + + #### overrides[n].plugins type: `array | null` From f11470d569f5ad3aaf24e6fc77c58eebef52269c Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 17 Dec 2025 15:14:52 +0000 Subject: [PATCH 2/6] [autofix.ci] apply automated fixes --- .../custom_plugin_name_alias_reserved_name/.oxlintrc.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/oxlint/test/fixtures/custom_plugin_name_alias_reserved_name/.oxlintrc.json b/apps/oxlint/test/fixtures/custom_plugin_name_alias_reserved_name/.oxlintrc.json index 1f321b286e3fd..29fd536deb3fe 100644 --- a/apps/oxlint/test/fixtures/custom_plugin_name_alias_reserved_name/.oxlintrc.json +++ b/apps/oxlint/test/fixtures/custom_plugin_name_alias_reserved_name/.oxlintrc.json @@ -1,6 +1,5 @@ { "plugins": ["jsdoc"], "jsPlugins": [{ "name": "jsdoc", "specifier": "./plugin.ts" }], - "rules": { - } + "rules": {} } From 673e628a7dddf14b275eac6b5c1fc027336bdd15 Mon Sep 17 00:00:00 2001 From: overlookmotel Date: Wed, 17 Dec 2025 17:43:48 +0000 Subject: [PATCH 3/6] refactor: move imports to top level --- crates/oxc_linter/src/config/external_plugins.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/oxc_linter/src/config/external_plugins.rs b/crates/oxc_linter/src/config/external_plugins.rs index 51b5699dbdf38..dcb0cb2ef1826 100644 --- a/crates/oxc_linter/src/config/external_plugins.rs +++ b/crates/oxc_linter/src/config/external_plugins.rs @@ -1,7 +1,10 @@ use std::path::PathBuf; use rustc_hash::FxHashSet; -use schemars::{JsonSchema, SchemaGenerator, schema::Schema}; +use schemars::{ + JsonSchema, SchemaGenerator, + schema::{InstanceType, Metadata, ObjectValidation, Schema, SchemaObject, SubschemaValidation}, +}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; /// External plugin entry containing the plugin specifier and optional custom name @@ -21,10 +24,6 @@ impl JsonSchema for ExternalPluginEntry { } fn json_schema(_gen: &mut SchemaGenerator) -> Schema { - use schemars::schema::{ - InstanceType, Metadata, ObjectValidation, SchemaObject, SubschemaValidation, - }; - // Schema represents: string | { name: string, specifier: string } let string_schema = SchemaObject { instance_type: Some(InstanceType::String.into()), From 741c829c88cbe17f4e12440d4a49099644973b40 Mon Sep 17 00:00:00 2001 From: overlookmotel Date: Wed, 17 Dec 2025 17:49:21 +0000 Subject: [PATCH 4/6] refactor: reorder code --- .../oxc_linter/src/config/external_plugins.rs | 282 +++++++++--------- 1 file changed, 141 insertions(+), 141 deletions(-) diff --git a/crates/oxc_linter/src/config/external_plugins.rs b/crates/oxc_linter/src/config/external_plugins.rs index dcb0cb2ef1826..1828b9152d572 100644 --- a/crates/oxc_linter/src/config/external_plugins.rs +++ b/crates/oxc_linter/src/config/external_plugins.rs @@ -18,131 +18,6 @@ pub struct ExternalPluginEntry { pub name: Option, } -impl JsonSchema for ExternalPluginEntry { - fn schema_name() -> String { - "ExternalPluginEntry".to_string() - } - - fn json_schema(_gen: &mut SchemaGenerator) -> Schema { - // Schema represents: string | { name: string, specifier: string } - let string_schema = SchemaObject { - instance_type: Some(InstanceType::String.into()), - metadata: Some(Box::new(Metadata { - description: Some("Path or package name of the plugin".to_string()), - ..Default::default() - })), - ..Default::default() - }; - - let mut object_properties = schemars::Map::new(); - object_properties.insert( - "name".to_string(), - SchemaObject { - instance_type: Some(InstanceType::String.into()), - metadata: Some(Box::new(Metadata { - description: Some( - "Custom name/alias for the plugin.\n\n\ - Note: The following plugin names are reserved because they are implemented \ - natively in Rust within oxlint and cannot be used for JS plugins:\n\ - - react (includes react-hooks)\n\ - - unicorn\n\ - - typescript\n\ - - oxc\n\ - - import (includes import-x)\n\ - - jsdoc\n\ - - jest\n\ - - vitest\n\ - - jsx-a11y\n\ - - nextjs\n\ - - react-perf\n\ - - promise\n\ - - node\n\ - - regex\n\ - - vue\n\ - - eslint\n\n\ - If you need to use the JavaScript version of any of these plugins, \ - provide a custom alias to avoid conflicts." - .to_string() - ), - ..Default::default() - })), - ..Default::default() - } - .into(), - ); - object_properties.insert( - "specifier".to_string(), - SchemaObject { - instance_type: Some(InstanceType::String.into()), - metadata: Some(Box::new(Metadata { - description: Some("Path or package name of the plugin".to_string()), - ..Default::default() - })), - ..Default::default() - } - .into(), - ); - - let object_schema = SchemaObject { - instance_type: Some(InstanceType::Object.into()), - metadata: Some(Box::new(Metadata { - description: Some("Plugin with custom name/alias".to_string()), - ..Default::default() - })), - object: Some(Box::new(ObjectValidation { - properties: object_properties, - required: vec!["name".to_string(), "specifier".to_string()].into_iter().collect(), - additional_properties: Some(Box::new(Schema::Bool(false))), - ..Default::default() - })), - ..Default::default() - }; - - SchemaObject { - subschemas: Some(Box::new(SubschemaValidation { - any_of: Some(vec![string_schema.into(), object_schema.into()]), - ..Default::default() - })), - ..Default::default() - } - .into() - } -} - -impl<'de> Deserialize<'de> for ExternalPluginEntry { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - #[derive(Deserialize)] - #[serde(deny_unknown_fields)] - struct PluginObject { - name: String, - specifier: String, - } - - #[derive(Deserialize)] - #[serde(untagged)] - enum PluginEntry { - String(String), - Object(PluginObject), - } - - let entry = PluginEntry::deserialize(deserializer)?; - - Ok(match entry { - PluginEntry::String(specifier) => { - ExternalPluginEntry { config_dir: PathBuf::default(), specifier, name: None } - } - PluginEntry::Object(obj) => ExternalPluginEntry { - config_dir: PathBuf::default(), - specifier: obj.specifier, - name: Some(obj.name), - }, - }) - } -} - /// Custom deserializer for external plugins /// Supports: /// - Array of strings: `["plugin1", "plugin2"]` @@ -197,6 +72,61 @@ where deserializer.deserialize_any(PluginSetVisitor) } +impl<'de> Deserialize<'de> for ExternalPluginEntry { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct PluginObject { + name: String, + specifier: String, + } + + #[derive(Deserialize)] + #[serde(untagged)] + enum PluginEntry { + String(String), + Object(PluginObject), + } + + let entry = PluginEntry::deserialize(deserializer)?; + + Ok(match entry { + PluginEntry::String(specifier) => { + ExternalPluginEntry { config_dir: PathBuf::default(), specifier, name: None } + } + PluginEntry::Object(obj) => ExternalPluginEntry { + config_dir: PathBuf::default(), + specifier: obj.specifier, + name: Some(obj.name), + }, + }) + } +} + +impl Serialize for ExternalPluginEntry { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + #[derive(Serialize)] + #[serde(untagged)] + enum PluginEntry<'a> { + String(&'a str), + Object { name: &'a str, specifier: &'a str }, + } + + if let Some(ref alias_name) = self.name { + PluginEntry::Object { name: alias_name.as_str(), specifier: self.specifier.as_str() } + .serialize(serializer) + } else { + PluginEntry::String(&self.specifier).serialize(serializer) + } + } +} + /// Custom JSON schema generator for external plugins that includes uniqueItems constraint pub fn external_plugins_schema(generator: &mut SchemaGenerator) -> Schema { use schemars::schema::{ArrayValidation, InstanceType, SchemaObject, SubschemaValidation}; @@ -230,24 +160,94 @@ pub fn external_plugins_schema(generator: &mut SchemaGenerator) -> Schema { .into() } -impl Serialize for ExternalPluginEntry { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - #[derive(Serialize)] - #[serde(untagged)] - enum PluginEntry<'a> { - String(&'a str), - Object { name: &'a str, specifier: &'a str }, - } +impl JsonSchema for ExternalPluginEntry { + fn schema_name() -> String { + "ExternalPluginEntry".to_string() + } - if let Some(ref alias_name) = self.name { - PluginEntry::Object { name: alias_name.as_str(), specifier: self.specifier.as_str() } - .serialize(serializer) - } else { - PluginEntry::String(&self.specifier).serialize(serializer) + fn json_schema(_gen: &mut SchemaGenerator) -> Schema { + // Schema represents: string | { name: string, specifier: string } + let string_schema = SchemaObject { + instance_type: Some(InstanceType::String.into()), + metadata: Some(Box::new(Metadata { + description: Some("Path or package name of the plugin".to_string()), + ..Default::default() + })), + ..Default::default() + }; + + let mut object_properties = schemars::Map::new(); + object_properties.insert( + "name".to_string(), + SchemaObject { + instance_type: Some(InstanceType::String.into()), + metadata: Some(Box::new(Metadata { + description: Some( + "Custom name/alias for the plugin.\n\n\ + Note: The following plugin names are reserved because they are implemented \ + natively in Rust within oxlint and cannot be used for JS plugins:\n\ + - react (includes react-hooks)\n\ + - unicorn\n\ + - typescript\n\ + - oxc\n\ + - import (includes import-x)\n\ + - jsdoc\n\ + - jest\n\ + - vitest\n\ + - jsx-a11y\n\ + - nextjs\n\ + - react-perf\n\ + - promise\n\ + - node\n\ + - regex\n\ + - vue\n\ + - eslint\n\n\ + If you need to use the JavaScript version of any of these plugins, \ + provide a custom alias to avoid conflicts." + .to_string() + ), + ..Default::default() + })), + ..Default::default() + } + .into(), + ); + object_properties.insert( + "specifier".to_string(), + SchemaObject { + instance_type: Some(InstanceType::String.into()), + metadata: Some(Box::new(Metadata { + description: Some("Path or package name of the plugin".to_string()), + ..Default::default() + })), + ..Default::default() + } + .into(), + ); + + let object_schema = SchemaObject { + instance_type: Some(InstanceType::Object.into()), + metadata: Some(Box::new(Metadata { + description: Some("Plugin with custom name/alias".to_string()), + ..Default::default() + })), + object: Some(Box::new(ObjectValidation { + properties: object_properties, + required: vec!["name".to_string(), "specifier".to_string()].into_iter().collect(), + additional_properties: Some(Box::new(Schema::Bool(false))), + ..Default::default() + })), + ..Default::default() + }; + + SchemaObject { + subschemas: Some(Box::new(SubschemaValidation { + any_of: Some(vec![string_schema.into(), object_schema.into()]), + ..Default::default() + })), + ..Default::default() } + .into() } } From 088e544d3bd9de264afc23ff1d0240958ce1c6c2 Mon Sep 17 00:00:00 2001 From: overlookmotel Date: Wed, 17 Dec 2025 18:00:32 +0000 Subject: [PATCH 5/6] refactor: move imports to top level --- .../oxc_linter/src/config/external_plugins.rs | 18 ++++++++++-------- crates/oxc_linter/src/config/overrides.rs | 3 ++- crates/oxc_linter/src/config/oxlintrc.rs | 4 ++-- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/crates/oxc_linter/src/config/external_plugins.rs b/crates/oxc_linter/src/config/external_plugins.rs index 1828b9152d572..08522f49d295e 100644 --- a/crates/oxc_linter/src/config/external_plugins.rs +++ b/crates/oxc_linter/src/config/external_plugins.rs @@ -1,11 +1,18 @@ -use std::path::PathBuf; +use std::{fmt, path::PathBuf}; use rustc_hash::FxHashSet; use schemars::{ JsonSchema, SchemaGenerator, - schema::{InstanceType, Metadata, ObjectValidation, Schema, SchemaObject, SubschemaValidation}, + schema::{ + ArrayValidation, InstanceType, Metadata, ObjectValidation, Schema, SchemaObject, + SubschemaValidation, + }, +}; + +use serde::{ + Deserialize, Deserializer, Serialize, Serializer, + de::{Error, SeqAccess, Visitor}, }; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; /// External plugin entry containing the plugin specifier and optional custom name #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -29,9 +36,6 @@ pub fn deserialize_external_plugins<'de, D>( where D: Deserializer<'de>, { - use serde::de::{Error, SeqAccess, Visitor}; - use std::fmt; - struct PluginSetVisitor; impl<'de> Visitor<'de> for PluginSetVisitor { @@ -129,8 +133,6 @@ impl Serialize for ExternalPluginEntry { /// Custom JSON schema generator for external plugins that includes uniqueItems constraint pub fn external_plugins_schema(generator: &mut SchemaGenerator) -> Schema { - use schemars::schema::{ArrayValidation, InstanceType, SchemaObject, SubschemaValidation}; - let entry_schema = generator.subschema_for::(); let array_schema = SchemaObject { diff --git a/crates/oxc_linter/src/config/overrides.rs b/crates/oxc_linter/src/config/overrides.rs index 315bb4278e89f..4bc510c9d5893 100644 --- a/crates/oxc_linter/src/config/overrides.rs +++ b/crates/oxc_linter/src/config/overrides.rs @@ -3,6 +3,7 @@ use std::{ ops::{Deref, DerefMut}, }; +use rustc_hash::FxHashSet; use schemars::{JsonSchema, r#gen, schema::Schema}; use serde::{Deserialize, Deserializer, Serialize}; @@ -108,7 +109,7 @@ pub struct OxlintOverride { deserialize_with = "deserialize_external_plugins" )] #[schemars(schema_with = "external_plugins_schema")] - pub external_plugins: Option>, + pub external_plugins: Option>, #[serde(default)] pub rules: OxlintRules, diff --git a/crates/oxc_linter/src/config/oxlintrc.rs b/crates/oxc_linter/src/config/oxlintrc.rs index e3625e57eb706..c5fd66e932a95 100644 --- a/crates/oxc_linter/src/config/oxlintrc.rs +++ b/crates/oxc_linter/src/config/oxlintrc.rs @@ -3,7 +3,7 @@ use std::{ path::{Path, PathBuf}, }; -use rustc_hash::FxHashMap; +use rustc_hash::{FxHashMap, FxHashSet}; use schemars::{JsonSchema, schema_for}; use serde::{Deserialize, Serialize}; @@ -88,7 +88,7 @@ pub struct Oxlintrc { deserialize_with = "deserialize_external_plugins" )] #[schemars(schema_with = "external_plugins_schema")] - pub external_plugins: Option>, + pub external_plugins: Option>, pub categories: OxlintCategories, /// Example /// From 16e2e62157f8eaa7cd0b2a69ad585d0e568c36d7 Mon Sep 17 00:00:00 2001 From: overlookmotel Date: Wed, 17 Dec 2025 18:07:38 +0000 Subject: [PATCH 6/6] avoid `ref` --- crates/oxc_linter/src/config/external_plugins.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/oxc_linter/src/config/external_plugins.rs b/crates/oxc_linter/src/config/external_plugins.rs index 08522f49d295e..fd16877c2a45c 100644 --- a/crates/oxc_linter/src/config/external_plugins.rs +++ b/crates/oxc_linter/src/config/external_plugins.rs @@ -122,7 +122,7 @@ impl Serialize for ExternalPluginEntry { Object { name: &'a str, specifier: &'a str }, } - if let Some(ref alias_name) = self.name { + if let Some(alias_name) = &self.name { PluginEntry::Object { name: alias_name.as_str(), specifier: self.specifier.as_str() } .serialize(serializer) } else {