use crate::linter::diagnostic::{Diagnostic, Fix, ViolationData};
use crate::linter::rules::{Example, Rule, RuleContext, all_rule_ids, is_known_rule};
use crate::linter::suppression::RuleRef;
pub struct MisnamedSuppression;
const EXAMPLES: &[Example] = &[
Example {
caption: "The rule ID is misspelled, so the directive suppresses nothing:",
source: "# arity-ignore unusd-binding: leftover from a refactor\nx <- 1\n",
},
Example {
caption: "A comma-separated list is not supported — write one directive per rule:",
source: "# arity-ignore browser, repeat: debugging\nx <- 1\n",
},
];
impl Rule for MisnamedSuppression {
fn id(&self) -> &'static str {
"misnamed-suppression"
}
fn description(&self) -> &'static str {
"Flags a `# arity-ignore` directive whose rule ID is not a rule arity \
ships. Such a directive suppresses nothing, and does so silently — the failure \
mode of a suppression is that no output appears, which is also what success \
looks like. When exactly one shipped rule ID is an unambiguous near-match, the \
fix rewrites the ID and leaves the reason text alone; otherwise the finding is \
report-only. Note that `syntax-error` is not a lint rule: parse errors are \
reported before any rule runs and cannot be suppressed."
}
fn examples(&self) -> &'static [Example] {
EXAMPLES
}
fn check_file(&self, ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
for directive in ctx.suppressions.directives() {
let Some(rule) = &directive.rule else {
continue; };
if is_known_rule(&rule.id) {
continue;
}
sink.push(report(rule));
}
}
}
fn report(rule: &RuleRef) -> Diagnostic {
let id = &rule.id;
let mut message = ViolationData::new(
"misnamed-suppression",
format!("`{id}` is not an arity lint rule, so this directive suppresses nothing"),
);
let is_list = id.contains(',');
let suggestion = if is_list {
Some("a directive names one rule; write a separate `# arity-ignore` per rule".to_string())
} else {
closest_match(id).map(|best| format!("did you mean `{best}`?"))
};
if let Some(suggestion) = suggestion {
message = message.with_suggestion(suggestion);
}
Diagnostic {
rule: "misnamed-suppression",
severity: Default::default(),
path: Default::default(),
range: rule.range,
message,
fix: (!is_list).then(|| closest_match(id)).flatten().map(|best| {
Fix::safe(
rule.range.start().into(),
rule.range.end().into(),
best,
format!("Replace `{id}` with `{best}`"),
)
}),
}
}
fn closest_match(typo: &str) -> Option<&'static str> {
let max = (typo.chars().count() / 3).clamp(1, 2);
let mut best: Option<(usize, &'static str)> = None;
let mut ties = 0usize;
for id in all_rule_ids() {
let distance = levenshtein(typo, id, max);
if distance > max {
continue;
}
match best {
Some((best_distance, _)) if distance > best_distance => {}
Some((best_distance, _)) if distance == best_distance => ties += 1,
_ => {
best = Some((distance, id));
ties = 0;
}
}
}
best.filter(|_| ties == 0).map(|(_, id)| id)
}
fn levenshtein(a: &str, b: &str, max: usize) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
if a.len().abs_diff(b.len()) > max {
return max + 1;
}
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut cur = vec![0usize; b.len() + 1];
for (i, ca) in a.iter().enumerate() {
cur[0] = i + 1;
let mut row_min = cur[0];
for (j, cb) in b.iter().enumerate() {
let substitute = prev[j] + usize::from(ca != cb);
cur[j + 1] = substitute.min(prev[j + 1] + 1).min(cur[j] + 1);
row_min = row_min.min(cur[j + 1]);
}
if row_min > max {
return max + 1;
}
std::mem::swap(&mut prev, &mut cur);
}
prev[b.len()]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn levenshtein_counts_edits() {
assert_eq!(levenshtein("browser", "browser", 2), 0);
assert_eq!(levenshtein("browsr", "browser", 2), 1);
assert_eq!(levenshtein("brwsr", "browser", 2), 2);
}
#[test]
fn levenshtein_gives_up_past_the_bound() {
assert!(levenshtein("zzzzzzzzzz", "browser", 2) > 2);
assert!(levenshtein("a", "browser", 2) > 2);
}
#[test]
fn closest_match_finds_a_single_near_miss() {
assert_eq!(closest_match("unusd-binding"), Some("unused-binding"));
assert_eq!(closest_match("browsr"), Some("browser"));
}
#[test]
fn closest_match_declines_when_nothing_is_close() {
assert_eq!(closest_match("zzzzzzzzzz"), None);
assert_eq!(closest_match(""), None);
}
#[test]
fn closest_match_declines_on_a_tie() {
let candidates: Vec<&str> = all_rule_ids()
.into_iter()
.filter(|id| levenshtein("sorts", id, 2) <= 2)
.collect();
if candidates.len() > 1 {
assert_eq!(closest_match("sorts"), None, "ambiguous: {candidates:?}");
}
}
}