use crate::{Edit, Fix, Spanned};
#[allow(clippy::needless_range_loop)]
pub fn distance(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
let mut d = vec![vec![0usize; b.len() + 1]; a.len() + 1];
for (i, row) in d.iter_mut().enumerate() {
row[0] = i;
}
for (j, cell) in d[0].iter_mut().enumerate() {
*cell = j;
}
for i in 1..=a.len() {
for j in 1..=b.len() {
let cost = usize::from(a[i - 1] != b[j - 1]);
let mut best = (d[i - 1][j] + 1)
.min(d[i][j - 1] + 1)
.min(d[i - 1][j - 1] + cost);
if i > 1 && j > 1 && a[i - 1] == b[j - 2] && a[i - 2] == b[j - 1] {
best = best.min(d[i - 2][j - 2] + 1);
}
d[i][j] = best;
}
}
d[a.len()][b.len()]
}
pub fn closest<'c>(name: &str, candidates: impl IntoIterator<Item = &'c str>) -> Option<&'c str> {
let len = name.chars().count();
let bound = len.max(3) / 3;
let mut best: Option<(bool, usize, &'c str)> = None;
for c in candidates {
if c == name {
continue;
}
let key = if c.eq_ignore_ascii_case(name) {
(false, 0, c)
} else {
if c.chars().count().abs_diff(len) > bound {
continue;
}
let d = distance(name, c);
if d > bound {
continue;
}
(true, d, c)
};
if best.map_or(true, |b| key < b) {
best = Some(key);
}
}
best.map(|(_, _, c)| c)
}
pub fn change_to<'c>(
name: &Spanned<String>,
candidates: impl IntoIterator<Item = &'c str>,
) -> Option<Fix> {
let to = closest(&name.value, candidates)?;
Some(Fix::new(
format!("change `{}` to `{to}`", name.value),
vec![Edit::replace(name.span, to)],
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{FileId, Span};
#[test]
fn a_swap_is_one_edit() {
assert_eq!(distance("cuont", "count"), 1);
assert_eq!(distance("kitten", "sitting"), 3);
assert_eq!(distance("", "ab"), 2);
}
#[test]
fn the_nearest_within_the_bound_wins_then_byte_order() {
assert_eq!(closest("cuont", ["mount", "count"]), Some("count"));
assert_eq!(closest("ab", ["ac", "aa"]), Some("aa"));
assert_eq!(closest("count", ["count"]), None);
}
#[test]
fn the_bound_is_a_third_of_the_length() {
assert_eq!(closest("abcde", ["abxde"]), Some("abxde"));
assert_eq!(closest("abcde", ["axxde"]), None);
assert_eq!(closest("abcdef", ["axxdef"]), Some("axxdef"));
}
#[test]
fn a_name_equal_ignoring_case_is_offered_at_any_distance() {
assert_eq!(
closest("HTTPCLIENT", ["HTTPCLIENTS", "HttpClient"]),
Some("HttpClient")
);
}
#[test]
fn closest_counts_characters_not_bytes() {
assert_eq!(closest("ราคน", ["ราคา"]), Some("ราคา"));
assert_eq!(closest("ราคน", ["รากก"]), None);
}
#[test]
fn change_to_replaces_the_name() {
let name = Spanned::new("cuont".to_string(), Span::new(4, 9, FileId::DUMMY));
let fix = change_to(&name, ["count"]).unwrap();
assert_eq!(fix.title, "change `cuont` to `count`");
assert_eq!(fix.edits, vec![Edit::replace(name.span, "count")]);
}
#[test]
fn a_candidate_too_long_or_short_to_qualify_is_skipped() {
assert_eq!(closest("count", ["countdown", "cnt", "coun"]), Some("coun"));
assert_eq!(closest("count", ["countdowns", "c"]), None);
assert_eq!(closest("count", ["COUNT"]), Some("COUNT"));
}
}