Skip to content

Commit ee68089

Browse files
fix(linter): normalize JS plugin names (#15010)
This resolves some situations where JS plugins would be incorrectly marked as not available. This especially affected namespaced plugins. --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 597340e commit ee68089

File tree

5 files changed

+154
-4
lines changed

5 files changed

+154
-4
lines changed

crates/oxc_linter/src/config/config_builder.rs

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -553,11 +553,22 @@ impl ConfigStoreBuilder {
553553

554554
match result {
555555
PluginLoadResult::Success { name, offset, rule_names } => {
556-
if LintPlugins::try_from(name.as_str()).is_err() {
557-
external_plugin_store.register_plugin(plugin_path, name, offset, rule_names);
556+
// Normalize plugin name (e.g., "eslint-plugin-foo" -> "foo", "@foo/eslint-plugin" -> "@foo")
557+
use crate::config::plugins::normalize_plugin_name;
558+
let normalized_name = normalize_plugin_name(&name).into_owned();
559+
560+
if LintPlugins::try_from(normalized_name.as_str()).is_err() {
561+
external_plugin_store.register_plugin(
562+
plugin_path,
563+
normalized_name,
564+
offset,
565+
rule_names,
566+
);
558567
Ok(())
559568
} else {
560-
Err(ConfigBuilderError::ReservedExternalPluginName { plugin_name: name })
569+
Err(ConfigBuilderError::ReservedExternalPluginName {
570+
plugin_name: normalized_name,
571+
})
561572
}
562573
}
563574
PluginLoadResult::Failure(e) => Err(ConfigBuilderError::PluginLoadFailed {

crates/oxc_linter/src/config/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ mod globals;
88
mod ignore_matcher;
99
mod overrides;
1010
mod oxlintrc;
11-
mod plugins;
11+
pub mod plugins;
1212
mod rules;
1313
mod settings;
1414
pub use config_builder::{ConfigBuilderError, ConfigStoreBuilder};

crates/oxc_linter/src/config/plugins.rs

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,51 @@
1+
use std::borrow::Cow;
2+
13
use bitflags::bitflags;
24
use schemars::{JsonSchema, r#gen::SchemaGenerator, schema::Schema};
35
use serde::{Deserialize, Serialize, de::Deserializer, ser::Serializer};
46

7+
/// Normalizes plugin names by stripping common ESLint plugin prefixes and suffixes.
8+
///
9+
/// This handles the various naming conventions used in the ESLint ecosystem:
10+
/// - `eslint-plugin-foo` → `foo`
11+
/// - `@scope/eslint-plugin` → `@scope`
12+
/// - `@scope/eslint-plugin-foo` → `@scope/foo`
13+
///
14+
/// # Examples
15+
///
16+
/// ```
17+
/// use oxc_linter::normalize_plugin_name;
18+
///
19+
/// assert_eq!(normalize_plugin_name("eslint-plugin-react"), "react");
20+
/// assert_eq!(normalize_plugin_name("@typescript-eslint/eslint-plugin"), "@typescript-eslint");
21+
/// assert_eq!(normalize_plugin_name("@foo/eslint-plugin-bar"), "@foo/bar");
22+
/// ```
23+
pub fn normalize_plugin_name(plugin_name: &str) -> Cow<'_, str> {
24+
// Handle scoped packages (@scope/...)
25+
if let Some(scope_end) = plugin_name.find('/') {
26+
let scope = &plugin_name[..scope_end]; // e.g., "@foo"
27+
let rest = &plugin_name[scope_end + 1..]; // e.g., "eslint-plugin" or "eslint-plugin-bar"
28+
29+
// Check if it's @scope/eslint-plugin or @scope/eslint-plugin-something
30+
if rest == "eslint-plugin" {
31+
// @foo/eslint-plugin -> @foo
32+
return Cow::Borrowed(scope);
33+
} else if let Some(suffix) = rest.strip_prefix("eslint-plugin-") {
34+
// @foo/eslint-plugin-bar -> @foo/bar
35+
return Cow::Owned(format!("{scope}/{suffix}"));
36+
}
37+
}
38+
39+
// Handle non-scoped packages
40+
if let Some(suffix) = plugin_name.strip_prefix("eslint-plugin-") {
41+
// eslint-plugin-foo -> foo
42+
return Cow::Borrowed(suffix);
43+
}
44+
45+
// No normalization needed
46+
Cow::Borrowed(plugin_name)
47+
}
48+
549
bitflags! {
650
// NOTE: may be increased to a u32 if needed
751
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -79,6 +123,10 @@ impl TryFrom<&str> for LintPlugins {
79123
type Error = ();
80124

81125
fn try_from(value: &str) -> Result<Self, Self::Error> {
126+
// Normalize plugin name first to handle eslint-plugin-* naming
127+
let normalized = normalize_plugin_name(value);
128+
let value = normalized.as_ref();
129+
82130
match value {
83131
"react" | "react-hooks" | "react_hooks" => Ok(LintPlugins::REACT),
84132
"unicorn" => Ok(LintPlugins::UNICORN),
@@ -290,4 +338,44 @@ mod tests {
290338
let error = result.unwrap_err().to_string();
291339
assert_eq!(error, "Unknown plugin: 'not-a-real-plugin'.");
292340
}
341+
342+
#[test]
343+
fn test_plugin_normalization() {
344+
// Test eslint-plugin- prefix normalization
345+
assert_eq!(LintPlugins::try_from("eslint-plugin-react"), Ok(LintPlugins::REACT));
346+
assert_eq!(LintPlugins::try_from("eslint-plugin-unicorn"), Ok(LintPlugins::UNICORN));
347+
assert_eq!(LintPlugins::try_from("eslint-plugin-import"), Ok(LintPlugins::IMPORT));
348+
assert_eq!(LintPlugins::try_from("eslint-plugin-jest"), Ok(LintPlugins::JEST));
349+
350+
// Test @scope/eslint-plugin normalization
351+
assert_eq!(
352+
LintPlugins::try_from("@typescript-eslint/eslint-plugin"),
353+
Ok(LintPlugins::TYPESCRIPT)
354+
);
355+
356+
// Verify existing plugin names still work
357+
assert_eq!(LintPlugins::try_from("react"), Ok(LintPlugins::REACT));
358+
assert_eq!(LintPlugins::try_from("unicorn"), Ok(LintPlugins::UNICORN));
359+
assert_eq!(LintPlugins::try_from("@typescript-eslint"), Ok(LintPlugins::TYPESCRIPT));
360+
}
361+
362+
#[test]
363+
fn test_normalize_plugin_name() {
364+
use super::normalize_plugin_name;
365+
366+
// Test eslint-plugin- prefix stripping
367+
assert_eq!(normalize_plugin_name("eslint-plugin-foo"), "foo");
368+
assert_eq!(normalize_plugin_name("eslint-plugin-react"), "react");
369+
370+
// Test @scope/eslint-plugin suffix stripping
371+
assert_eq!(normalize_plugin_name("@foo/eslint-plugin"), "@foo");
372+
assert_eq!(normalize_plugin_name("@bar/eslint-plugin"), "@bar");
373+
374+
// Test @scope/eslint-plugin-name normalization
375+
assert_eq!(normalize_plugin_name("@foo/eslint-plugin-bar"), "@foo/bar");
376+
377+
// Test no change for already normalized names
378+
assert_eq!(normalize_plugin_name("react"), "react");
379+
assert_eq!(normalize_plugin_name("@typescript-eslint"), "@typescript-eslint");
380+
}
293381
}

crates/oxc_linter/src/config/rules.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,10 @@ fn parse_rule_key(name: &str) -> (String, String) {
244244
}
245245

246246
pub(super) fn unalias_plugin_name(plugin_name: &str, rule_name: &str) -> (String, String) {
247+
// First normalize the plugin name by stripping eslint-plugin- prefix/suffix
248+
let normalized = super::plugins::normalize_plugin_name(plugin_name);
249+
let plugin_name = normalized.as_ref();
250+
247251
let (oxlint_plugin_name, rule_name) = match plugin_name {
248252
"@typescript-eslint" => ("typescript", rule_name),
249253
// import-x has the same rules but better performance
@@ -467,4 +471,50 @@ mod test {
467471
assert_eq!(severity, &AllowWarnDeny::Deny, "{config:?}");
468472
}
469473
}
474+
475+
#[test]
476+
fn test_normalize_plugin_name_in_rules() {
477+
use super::super::plugins::normalize_plugin_name;
478+
479+
// Test eslint-plugin- prefix stripping
480+
assert_eq!(normalize_plugin_name("eslint-plugin-foo"), "foo");
481+
assert_eq!(normalize_plugin_name("eslint-plugin-react"), "react");
482+
assert_eq!(normalize_plugin_name("eslint-plugin-import"), "import");
483+
484+
// Test @scope/eslint-plugin suffix stripping
485+
assert_eq!(normalize_plugin_name("@foo/eslint-plugin"), "@foo");
486+
assert_eq!(normalize_plugin_name("@bar/eslint-plugin"), "@bar");
487+
488+
// Test @scope/eslint-plugin-name normalization
489+
assert_eq!(normalize_plugin_name("@foo/eslint-plugin-bar"), "@foo/bar");
490+
assert_eq!(normalize_plugin_name("@typescript-eslint/eslint-plugin"), "@typescript-eslint");
491+
492+
// Test no change for already normalized names
493+
assert_eq!(normalize_plugin_name("react"), "react");
494+
assert_eq!(normalize_plugin_name("unicorn"), "unicorn");
495+
assert_eq!(normalize_plugin_name("@typescript-eslint"), "@typescript-eslint");
496+
assert_eq!(normalize_plugin_name("jsx-a11y"), "jsx-a11y");
497+
}
498+
499+
#[test]
500+
fn test_parse_rules_with_eslint_plugin_prefix() {
501+
// Test that eslint-plugin- prefix is properly normalized in various formats
502+
let rules = OxlintRules::deserialize(&json!({
503+
"eslint-plugin-react/jsx-uses-vars": "error",
504+
"eslint-plugin-unicorn/no-null": "warn",
505+
}))
506+
.unwrap();
507+
508+
let mut rules_iter = rules.rules.iter();
509+
510+
let r1 = rules_iter.next().unwrap();
511+
assert_eq!(r1.rule_name, "jsx-uses-vars");
512+
assert_eq!(r1.plugin_name, "react");
513+
assert!(r1.severity.is_warn_deny());
514+
515+
let r2 = rules_iter.next().unwrap();
516+
assert_eq!(r2.rule_name, "no-null");
517+
assert_eq!(r2.plugin_name, "unicorn");
518+
assert!(r2.severity.is_warn_deny());
519+
}
470520
}

crates/oxc_linter/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ mod tester;
4848

4949
mod lint_runner;
5050

51+
pub use crate::config::plugins::normalize_plugin_name;
5152
pub use crate::disable_directives::{
5253
DisableDirectives, DisableRuleComment, RuleCommentRule, RuleCommentType,
5354
create_unused_directives_diagnostics,

0 commit comments

Comments
 (0)