From a2150e2bddf9f19427e61497fc44dcc489c0a9c7 Mon Sep 17 00:00:00 2001 From: hippietrail Date: Sun, 26 Apr 2026 02:17:30 +0800 Subject: [PATCH 1/6] fix: find clashing linter names when part of merged linter Closes #3241 --- harper-core/src/linting/lint_group/mod.rs | 30 +++++++++++++++++------ harper-core/src/linting/merge_linters.rs | 4 +++ harper-core/src/linting/mod.rs | 7 ++++++ 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/harper-core/src/linting/lint_group/mod.rs b/harper-core/src/linting/lint_group/mod.rs index 0611a8be38..7a7b102228 100644 --- a/harper-core/src/linting/lint_group/mod.rs +++ b/harper-core/src/linting/lint_group/mod.rs @@ -316,18 +316,34 @@ impl LintGroup { /// Add a [`Linter`] to the group, returning whether the operation was successful. /// If it returns `false`, it is because a linter with that key already existed in the group. pub fn add(&mut self, name: impl AsRef, linter: impl Linter + 'static) -> bool { + // Check if the main linter name clashes if self.contains_key(&name) { + let clash = name.as_ref().to_string(); if self.clashing_linter_names.is_none() { - self.clashing_linter_names = Some(vec![name.as_ref().to_string()]); + self.clashing_linter_names = Some(vec![clash]); } else if let Some(clashing_names) = &mut self.clashing_linter_names { - clashing_names.push(name.as_ref().to_string()); + clashing_names.push(clash); } - false - } else { - self.linters - .insert(name.as_ref().to_string(), Box::new(linter)); - true + return false; } + + // Check if any child linter names clash with existing linters + for child_name in linter.merged_linter_child_names() { + if self.contains_key(child_name) { + let clash = format!("{} (part of: {})", child_name, name.as_ref()); + if self.clashing_linter_names.is_none() { + self.clashing_linter_names = Some(vec![clash]); + } else if let Some(clashing_names) = &mut self.clashing_linter_names { + clashing_names.push(clash); + } + return false; + } + } + + // No clashes found, add the linter + self.linters + .insert(name.as_ref().to_string(), Box::new(linter)); + true } /// Add a chunk-based [`ExprLinter`] to the group, returning whether the operation was successful. diff --git a/harper-core/src/linting/merge_linters.rs b/harper-core/src/linting/merge_linters.rs index c34a47e797..653a2b85d4 100644 --- a/harper-core/src/linting/merge_linters.rs +++ b/harper-core/src/linting/merge_linters.rs @@ -34,6 +34,10 @@ macro_rules! merge_linters { fn description(&self) -> &'static str { $desc } + + fn merged_linter_child_names(&self) -> &'static [&'static str] { + &[$(stringify!($linter)),*] + } } } } diff --git a/harper-core/src/linting/mod.rs b/harper-core/src/linting/mod.rs index 79a7524d3f..57c4c160b9 100644 --- a/harper-core/src/linting/mod.rs +++ b/harper-core/src/linting/mod.rs @@ -294,6 +294,13 @@ pub trait Linter: LSend { /// A user-facing description of what kinds of grammatical errors this rule looks for. /// It is usually shown in settings menus. fn description(&self) -> &str; + + /// Get the names of child linters for clash detection. + /// Non-merged linters should return an empty array. + /// This is used by LintGroup to detect name conflicts. + fn merged_linter_child_names(&self) -> &'static [&'static str] { + &[] + } } /// A blanket-implemented trait that renders the Markdown description field of a linter to HTML. From 05ec55396023eb7056825c667dc94753cae00b23 Mon Sep 17 00:00:00 2001 From: hippietrail Date: Thu, 30 Apr 2026 14:00:50 +0800 Subject: [PATCH 2/6] feat: extend linter name clash detection to parts of merged linters --- harper-core/src/linting/expr_linter.rs | 4 + harper-core/src/linting/lint_group/mod.rs | 89 +++++++++++++++-------- harper-core/src/linting/merge_linters.rs | 20 ++++- harper-core/src/linting/mod.rs | 7 +- 4 files changed, 84 insertions(+), 36 deletions(-) diff --git a/harper-core/src/linting/expr_linter.rs b/harper-core/src/linting/expr_linter.rs index 675231e3f8..d0fb0eeba4 100644 --- a/harper-core/src/linting/expr_linter.rs +++ b/harper-core/src/linting/expr_linter.rs @@ -113,6 +113,10 @@ where fn description(&self) -> &str { self.description() } + + fn merged_linter_child_names(&self) -> Vec<&'static str> { + vec![] + } } pub fn run_on_chunk<'a>( diff --git a/harper-core/src/linting/lint_group/mod.rs b/harper-core/src/linting/lint_group/mod.rs index 7a7b102228..7f2400a439 100644 --- a/harper-core/src/linting/lint_group/mod.rs +++ b/harper-core/src/linting/lint_group/mod.rs @@ -313,36 +313,63 @@ impl LintGroup { || self.chunk_expr_linters.contains_key(name.as_ref()) } + /// Check for clashes between a new linter and existing linters + fn check_clashes(&self, name: &str, child_names: &[&str]) -> Option> { + use std::collections::HashSet; + let mut clashes = HashSet::new(); + + // Check direct name clash with existing `Linter`s or `ExprLinter`s + if self.contains_key(name) { + clashes.insert(name.to_string()); + } + if self.chunk_expr_linters.contains_key(name) { + clashes.insert(name.to_string()); + } + + // Check if new linter's name clashes with child names of existing linters + clashes.extend( + self.linters + .iter() + .filter(|(_, existing_linter)| { + existing_linter.merged_linter_child_names().contains(&name) + }) + .map(|_| name.to_string()), + ); + + // Check if child names clash with existing [`Linter`]s + for child_name in child_names { + if self.contains_key(child_name) { + clashes.insert(child_name.to_string()); + } + if self.chunk_expr_linters.contains_key(*child_name) { + clashes.insert(child_name.to_string()); + } + } + + if clashes.is_empty() { + None + } else { + Some(clashes.into_iter().collect()) + } + } + /// Add a [`Linter`] to the group, returning whether the operation was successful. /// If it returns `false`, it is because a linter with that key already existed in the group. pub fn add(&mut self, name: impl AsRef, linter: impl Linter + 'static) -> bool { - // Check if the main linter name clashes - if self.contains_key(&name) { - let clash = name.as_ref().to_string(); + let name_str = name.as_ref(); + let child_names = linter.merged_linter_child_names(); + + if let Some(clashes) = self.check_clashes(name_str, &child_names) { if self.clashing_linter_names.is_none() { - self.clashing_linter_names = Some(vec![clash]); + self.clashing_linter_names = Some(clashes); } else if let Some(clashing_names) = &mut self.clashing_linter_names { - clashing_names.push(clash); + clashing_names.extend(clashes); } return false; } - // Check if any child linter names clash with existing linters - for child_name in linter.merged_linter_child_names() { - if self.contains_key(child_name) { - let clash = format!("{} (part of: {})", child_name, name.as_ref()); - if self.clashing_linter_names.is_none() { - self.clashing_linter_names = Some(vec![clash]); - } else if let Some(clashing_names) = &mut self.clashing_linter_names { - clashing_names.push(clash); - } - return false; - } - } - // No clashes found, add the linter - self.linters - .insert(name.as_ref().to_string(), Box::new(linter)); + self.linters.insert(name_str.to_string(), Box::new(linter)); true } @@ -356,18 +383,22 @@ impl LintGroup { name: impl AsRef, linter: impl ExprLinter + 'static, ) -> bool { - if self.contains_key(&name) { + let name_str = name.as_ref(); + let child_names = linter.merged_linter_child_names(); + + if let Some(clashes) = self.check_clashes(name_str, &child_names) { if self.clashing_linter_names.is_none() { - self.clashing_linter_names = Some(vec![name.as_ref().to_string()]); + self.clashing_linter_names = Some(clashes); } else if let Some(clashing_names) = &mut self.clashing_linter_names { - clashing_names.push(name.as_ref().to_string()); + clashing_names.extend(clashes); } - false - } else { - self.chunk_expr_linters - .insert(name.as_ref().to_string(), Box::new(linter) as _); - true + return false; } + + // No clashes found, add the linter + self.chunk_expr_linters + .insert(name_str.to_string(), Box::new(linter) as _); + true } /// Merge the contents of another [`LintGroup`] into this one. @@ -1040,7 +1071,7 @@ mod tests { if let Some(names) = &group.clashing_linter_names { if !names.is_empty() { panic!( - "⚠️ Found {} clashing linter names: {}", + "⚠️ Found {} linter name clashes: {}", names.len(), names.join(", ") ); diff --git a/harper-core/src/linting/merge_linters.rs b/harper-core/src/linting/merge_linters.rs index 653a2b85d4..14e7699fd9 100644 --- a/harper-core/src/linting/merge_linters.rs +++ b/harper-core/src/linting/merge_linters.rs @@ -14,7 +14,7 @@ macro_rules! merge_linters { #[derive(Default)] pub struct $name { $( - [< $linter:snake >]: $linter, + [< $linter:snake >]: ($linter, &'static str), )* } @@ -23,7 +23,7 @@ macro_rules! merge_linters { let mut lints = Vec::new(); $( - lints.extend(self.[< $linter:snake >].lint(document)); + lints.extend(self.[< $linter:snake >].0.lint(document)); )* remove_overlaps(&mut lints); @@ -35,8 +35,20 @@ macro_rules! merge_linters { $desc } - fn merged_linter_child_names(&self) -> &'static [&'static str] { - &[$(stringify!($linter)),*] + fn merged_linter_child_names(&self) -> Vec<&'static str> { + let mut all_names = Vec::new(); + + // Add immediate children from tuple names + $( + all_names.push(stringify!($linter)); + )* + + // Recursively collect from each child linter instance + $( + all_names.extend(self.[< $linter:snake >].0.merged_linter_child_names()); + )* + + all_names } } } diff --git a/harper-core/src/linting/mod.rs b/harper-core/src/linting/mod.rs index 57c4c160b9..976d5413e6 100644 --- a/harper-core/src/linting/mod.rs +++ b/harper-core/src/linting/mod.rs @@ -295,11 +295,12 @@ pub trait Linter: LSend { /// It is usually shown in settings menus. fn description(&self) -> &str; - /// Get the names of child linters for clash detection. + /// Get the names of all component linters. + /// For merged linters, this includes children, grandchildren, etc. /// Non-merged linters should return an empty array. /// This is used by LintGroup to detect name conflicts. - fn merged_linter_child_names(&self) -> &'static [&'static str] { - &[] + fn merged_linter_child_names(&self) -> Vec<&'static str> { + vec![] } } From 549b8d989d3f6c24506eafbe1d16dfc8ce250c4e Mon Sep 17 00:00:00 2001 From: hippietrail Date: Thu, 30 Apr 2026 14:10:17 +0800 Subject: [PATCH 3/6] fix: problems clippy on GitHub found --- harper-core/src/linting/lint_group/mod.rs | 2 +- harper-core/src/linting/merge_linters.rs | 7 +------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/harper-core/src/linting/lint_group/mod.rs b/harper-core/src/linting/lint_group/mod.rs index 7f2400a439..9891533ab8 100644 --- a/harper-core/src/linting/lint_group/mod.rs +++ b/harper-core/src/linting/lint_group/mod.rs @@ -315,7 +315,7 @@ impl LintGroup { /// Check for clashes between a new linter and existing linters fn check_clashes(&self, name: &str, child_names: &[&str]) -> Option> { - use std::collections::HashSet; + use hashbrown::HashSet; let mut clashes = HashSet::new(); // Check direct name clash with existing `Linter`s or `ExprLinter`s diff --git a/harper-core/src/linting/merge_linters.rs b/harper-core/src/linting/merge_linters.rs index 14e7699fd9..f01be70a87 100644 --- a/harper-core/src/linting/merge_linters.rs +++ b/harper-core/src/linting/merge_linters.rs @@ -36,12 +36,7 @@ macro_rules! merge_linters { } fn merged_linter_child_names(&self) -> Vec<&'static str> { - let mut all_names = Vec::new(); - - // Add immediate children from tuple names - $( - all_names.push(stringify!($linter)); - )* + let mut all_names = vec![$(stringify!($linter)),*]; // Recursively collect from each child linter instance $( From 2fc6481137dbcd00210e088f95bdda2adfb136e7 Mon Sep 17 00:00:00 2001 From: hippietrail Date: Thu, 30 Apr 2026 16:12:50 +0800 Subject: [PATCH 4/6] fix: better clash info --- harper-core/src/linting/lint_group/mod.rs | 41 +++++++++++------------ 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/harper-core/src/linting/lint_group/mod.rs b/harper-core/src/linting/lint_group/mod.rs index 9891533ab8..59f5101bad 100644 --- a/harper-core/src/linting/lint_group/mod.rs +++ b/harper-core/src/linting/lint_group/mod.rs @@ -314,42 +314,39 @@ impl LintGroup { } /// Check for clashes between a new linter and existing linters + /// Returns detailed clash information with counts for debugging fn check_clashes(&self, name: &str, child_names: &[&str]) -> Option> { - use hashbrown::HashSet; - let mut clashes = HashSet::new(); + use hashbrown::HashMap; + let mut clash_counts = HashMap::new(); // Check direct name clash with existing `Linter`s or `ExprLinter`s if self.contains_key(name) { - clashes.insert(name.to_string()); - } - if self.chunk_expr_linters.contains_key(name) { - clashes.insert(name.to_string()); + *clash_counts.entry(name.to_string()).or_insert(0) += 1; } // Check if new linter's name clashes with child names of existing linters - clashes.extend( - self.linters - .iter() - .filter(|(_, existing_linter)| { - existing_linter.merged_linter_child_names().contains(&name) - }) - .map(|_| name.to_string()), - ); + for existing_linter in self.linters.values() { + if existing_linter.merged_linter_child_names().contains(&name) { + *clash_counts.entry(name.to_string()).or_insert(0) += 1; + } + } - // Check if child names clash with existing [`Linter`]s - for child_name in child_names { + // Check if child names clash with existing linters + for &child_name in child_names { if self.contains_key(child_name) { - clashes.insert(child_name.to_string()); - } - if self.chunk_expr_linters.contains_key(*child_name) { - clashes.insert(child_name.to_string()); + *clash_counts.entry(child_name.to_string()).or_insert(0) += 1; } } - if clashes.is_empty() { + if clash_counts.is_empty() { None } else { - Some(clashes.into_iter().collect()) + Some( + clash_counts + .into_iter() + .map(|(name, count)| format!("{name} (used {} times)", count + 1)) + .collect(), + ) } } From f9b7600d7b60889fe9bb7d013f77c3de82b07d4f Mon Sep 17 00:00:00 2001 From: hippietrail Date: Tue, 5 May 2026 01:42:32 +0800 Subject: [PATCH 5/6] fix: detect linter name clashes in sub-sub-linters --- harper-core/src/linting/lint_group/mod.rs | 3 ++- harper-core/src/linting/merge_linters.rs | 12 ++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/harper-core/src/linting/lint_group/mod.rs b/harper-core/src/linting/lint_group/mod.rs index 59f5101bad..19ba2773d3 100644 --- a/harper-core/src/linting/lint_group/mod.rs +++ b/harper-core/src/linting/lint_group/mod.rs @@ -1068,8 +1068,9 @@ mod tests { if let Some(names) = &group.clashing_linter_names { if !names.is_empty() { panic!( - "⚠️ Found {} linter name clashes: {}", + "⚠️ Found {} linter name clash{}: {}", names.len(), + if names.len() == 1 { "" } else { "es" }, names.join(", ") ); } diff --git a/harper-core/src/linting/merge_linters.rs b/harper-core/src/linting/merge_linters.rs index f01be70a87..6517a246de 100644 --- a/harper-core/src/linting/merge_linters.rs +++ b/harper-core/src/linting/merge_linters.rs @@ -14,7 +14,7 @@ macro_rules! merge_linters { #[derive(Default)] pub struct $name { $( - [< $linter:snake >]: ($linter, &'static str), + [< $linter:snake >]: $linter, )* } @@ -23,7 +23,7 @@ macro_rules! merge_linters { let mut lints = Vec::new(); $( - lints.extend(self.[< $linter:snake >].0.lint(document)); + lints.extend(self.[< $linter:snake >].lint(document)); )* remove_overlaps(&mut lints); @@ -36,14 +36,14 @@ macro_rules! merge_linters { } fn merged_linter_child_names(&self) -> Vec<&'static str> { - let mut all_names = vec![$(stringify!($linter)),*]; + let mut names = Vec::new(); - // Recursively collect from each child linter instance $( - all_names.extend(self.[< $linter:snake >].0.merged_linter_child_names()); + names.push(stringify!($linter)); + names.extend(self.[< $linter:snake >].merged_linter_child_names()); )* - all_names + names } } } From dbd0ab2fb74e29985447cca71b88e43dfc97b71c Mon Sep 17 00:00:00 2001 From: hippietrail Date: Tue, 5 May 2026 02:06:18 +0800 Subject: [PATCH 6/6] fix: `curated_default_config_lists_every_registered_rule` fails suggest linter name clash --- harper-core/src/linting/lint_group/structured_config/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/harper-core/src/linting/lint_group/structured_config/mod.rs b/harper-core/src/linting/lint_group/structured_config/mod.rs index 5ebfbf7562..f3acf26c49 100644 --- a/harper-core/src/linting/lint_group/structured_config/mod.rs +++ b/harper-core/src/linting/lint_group/structured_config/mod.rs @@ -331,7 +331,7 @@ mod tests { assert!( missing_from_default_config.is_empty() && extra_in_default_config.is_empty(), - "default_config.json drifted from the registered rule set\nmissing from default_config.json: {:?}\nextra in default_config.json: {:?}\nDid you forget to add the missing rule(s) to default_config.json?", + "⚠️ default_config.json drifted from the registered rule set\nmissing from default_config.json: {:?}\nextra in default_config.json: {:?}\nDid you forget to add the missing rule(s) to default_config.json?\nOr are there any clashing linter names?", missing_from_default_config, extra_in_default_config, );