use crate::linter::diagnostic::{Applicability, Diagnostic, Fix};
use crate::linter::rules::{Example, Rule, RuleContext, all_rule_ids, is_shipped_rule};
const MAX_DISTANCE: usize = 2;
pub struct MisnamedSuppression;
impl Rule for MisnamedSuppression {
fn id(&self) -> &'static str {
"misnamed-suppression"
}
fn description(&self) -> &'static str {
"Flag a `# fatou-ignore` directive that names a rule the linter does not \
ship. Suppression matches a rule by its exact ID, so a misspelled, \
renamed, or foreign ID silences nothing while reading as though it \
does. When exactly one shipped rule is a near match, the finding \
carries a safe fix that rewrites the ID and leaves the reason \
untouched."
}
fn examples(&self) -> &'static [Example] {
&[Example {
caption: "A directive naming a rule that does not exist:",
source: "# fatou-ignore unused-bindings: set up by the C library\nfunction f()\n handle = open_device()\n 1\nend\n",
}]
}
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_shipped_rule(&rule.id) {
continue;
}
let listed = rule.id.contains(',');
let near = (!listed).then(|| near_match(&rule.id)).flatten();
let mut diag = Diagnostic::new(
self.id(),
rule.range,
format!("`{}` is not a fatou rule; this suppresses nothing", rule.id),
);
if near.is_none() {
diag.message = diag.message.with_suggestion(if listed {
"a directive names one rule; repeat the comment for a second"
} else {
"check the rule reference for the shipped rule IDs"
});
}
if let Some(id) = near {
diag.fixes.push(Fix {
description: format!("Replace with `{id}`"),
content: id.to_string(),
start: rule.range.start().into(),
end: rule.range.end().into(),
applicability: Applicability::Safe,
});
}
sink.push(diag);
}
}
}
fn near_match(written: &str) -> Option<&'static str> {
nearest(&normalize(written), &all_rule_ids())
}
fn nearest(needle: &str, candidates: &[&'static str]) -> Option<&'static str> {
let mut best: Option<(usize, &'static str)> = None;
let mut tied = false;
for id in candidates.iter().copied() {
let distance = edit_distance(needle, id);
match best {
Some((best_distance, _)) if distance > best_distance => continue,
Some((best_distance, _)) if distance == best_distance => tied = true,
_ => {
best = Some((distance, id));
tied = false;
}
}
}
match best {
Some((distance, id)) if !tied && distance <= MAX_DISTANCE => Some(id),
_ => None,
}
}
fn normalize(written: &str) -> String {
written
.chars()
.map(|c| {
if c == '_' {
'-'
} else {
c.to_ascii_lowercase()
}
})
.collect()
}
fn edit_distance(a: &str, b: &str) -> usize {
let b: Vec<char> = b.chars().collect();
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut current = vec![0usize; b.len() + 1];
for (i, ac) in a.chars().enumerate() {
current[0] = i + 1;
for (j, bc) in b.iter().enumerate() {
let substitution = prev[j] + usize::from(ac != *bc);
current[j + 1] = substitution.min(prev[j + 1] + 1).min(current[j] + 1);
}
std::mem::swap(&mut prev, &mut current);
}
prev[b.len()]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn edit_distance_counts_single_edits() {
assert_eq!(edit_distance("abc", "abc"), 0);
assert_eq!(edit_distance("abc", "abd"), 1);
assert_eq!(edit_distance("abc", "ab"), 1);
assert_eq!(edit_distance("abc", "abcd"), 1);
assert_eq!(edit_distance("", "abc"), 3);
}
#[test]
fn near_match_finds_the_obvious_typo() {
assert_eq!(near_match("unused-bindings"), Some("unused-binding"));
assert_eq!(near_match("unused_binding"), Some("unused-binding"));
assert_eq!(near_match("Unused-Binding"), Some("unused-binding"));
}
#[test]
fn near_match_declines_a_distant_name() {
assert_eq!(near_match("banana"), None);
assert_eq!(near_match("no-such-rule-at-all"), None);
}
#[test]
fn nearest_declines_a_tie() {
assert_eq!(nearest("ab", &["abc", "abd"]), None);
assert_eq!(nearest("ab", &["abc", "zzzz"]), Some("abc"));
}
}