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..29fd536deb3fe --- /dev/null +++ b/apps/oxlint/test/fixtures/custom_plugin_name_alias_reserved_name/.oxlintrc.json @@ -0,0 +1,5 @@ +{ + "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..fd16877c2a45c --- /dev/null +++ b/crates/oxc_linter/src/config/external_plugins.rs @@ -0,0 +1,373 @@ +use std::{fmt, path::PathBuf}; + +use rustc_hash::FxHashSet; +use schemars::{ + JsonSchema, SchemaGenerator, + schema::{ + ArrayValidation, InstanceType, Metadata, ObjectValidation, Schema, SchemaObject, + SubschemaValidation, + }, +}; + +use serde::{ + Deserialize, Deserializer, Serialize, Serializer, + de::{Error, SeqAccess, Visitor}, +}; + +/// 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, +} + +/// 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>, +{ + 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) +} + +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(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 { + 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 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() + } +} + +#[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..4bc510c9d5893 100644 --- a/crates/oxc_linter/src/config/overrides.rs +++ b/crates/oxc_linter/src/config/overrides.rs @@ -1,15 +1,18 @@ 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 +104,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 +150,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..c5fd66e932a95 100644 --- a/crates/oxc_linter/src/config/oxlintrc.rs +++ b/crates/oxc_linter/src/config/oxlintrc.rs @@ -5,15 +5,22 @@ use std::{ use rustc_hash::{FxHashMap, FxHashSet}; 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`