const MAX_SUGGESTIONS: usize = 3;
const MAX_EDIT_DISTANCE: usize = 2;
const MIN_PREFIX_LEN: usize = 3;
fn edit_distance_with_cutoff(a: &str, b: &str, cutoff: usize) -> usize {
let (long, short) = if a.len() >= b.len() {
(a.as_bytes(), b.as_bytes())
} else {
(b.as_bytes(), a.as_bytes())
};
if long.len() - short.len() > cutoff {
return cutoff + 1;
}
let mut prev: Vec<usize> = (0..=short.len()).collect();
let mut curr: Vec<usize> = vec![0; short.len() + 1];
for (i, &lc) in long.iter().enumerate() {
curr[0] = i + 1;
let mut row_min = curr[0];
for (j, &sc) in short.iter().enumerate() {
let cost = usize::from(lc != sc);
curr[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(curr[j] + 1);
row_min = row_min.min(curr[j + 1]);
}
if row_min > cutoff {
return cutoff + 1;
}
std::mem::swap(&mut prev, &mut curr);
}
prev[short.len()]
}
pub(crate) fn closest_names<'a>(input: &str, candidates: &[&'a str]) -> Vec<&'a str> {
let mut scored: Vec<((u8, usize), &'a str)> = candidates
.iter()
.filter_map(|&name| {
let cutoff = MAX_EDIT_DISTANCE.min(input.len().max(name.len()) / 3);
if cutoff > 0 {
let d = edit_distance_with_cutoff(input, name, cutoff);
if d <= cutoff {
return Some(((0, d), name));
}
}
if input.len() < MIN_PREFIX_LEN {
return None;
}
let shared = input
.bytes()
.zip(name.bytes())
.take_while(|(x, y)| x == y)
.count();
if shared < MIN_PREFIX_LEN {
return None;
}
let rank = u8::from(!name.starts_with(input)) + 1;
Some(((rank, usize::MAX - shared), name))
})
.collect();
scored.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(b.1)));
let best = scored.first().map(|(s, _)| *s);
scored
.into_iter()
.take_while(|(s, _)| Some(*s) == best)
.take(MAX_SUGGESTIONS)
.map(|(_, name)| name)
.collect()
}
pub(crate) fn render_suggestion(suggestions: &[&str]) -> String {
match suggestions {
[] => String::new(),
[one] => format!("; did you mean `{one}`?"),
many => {
let quoted: Vec<String> = many.iter().map(|n| format!("`{n}`")).collect();
format!("; did you mean one of {}?", quoted.join(", "))
}
}
}
#[must_use]
pub fn format_suggestion(input: &str, candidates: &[&str]) -> String {
render_suggestion(&closest_names(input, candidates))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::metric_catalog::METRICS;
fn metric_ids() -> Vec<&'static str> {
METRICS.iter().map(|m| m.id).collect()
}
#[test]
fn closest_names_suggests_single_typo() {
assert_eq!(
closest_names("cyclomatc", &metric_ids()),
vec!["cyclomatic"]
);
}
#[test]
fn closest_names_suggests_dotted_typo() {
assert_eq!(
closest_names("halstead.efort", &metric_ids()),
vec!["halstead.effort"]
);
}
#[test]
fn closest_names_suggests_truncation() {
let suggestions = closest_names("cyclic", &metric_ids());
assert!(
suggestions.contains(&"cyclomatic"),
"expected `cyclomatic` in {suggestions:?}"
);
}
#[test]
fn closest_names_returns_empty_for_unrelated_input() {
let suggestions = closest_names("xyznonexistent", &metric_ids());
assert!(suggestions.is_empty(), "{suggestions:?}");
}
#[test]
fn closest_names_returns_empty_for_very_short_input() {
assert_eq!(closest_names("z", &metric_ids()), [] as [&str; 0]);
}
#[test]
fn edit_distance_with_cutoff_short_circuits_far_apart() {
let d = edit_distance_with_cutoff("ab", "abcdefghij", 2);
assert!(d > 2, "{d}");
}
#[test]
fn edit_distance_with_cutoff_handles_equal_strings() {
assert_eq!(edit_distance_with_cutoff("cyclomatic", "cyclomatic", 2), 0);
}
#[test]
fn render_suggestion_covers_none_one_and_many() {
assert_eq!(render_suggestion(&[]), "");
assert_eq!(render_suggestion(&["dloc"]), "; did you mean `dloc`?");
assert_eq!(
render_suggestion(&["npa", "npm"]),
"; did you mean one of `npa`, `npm`?"
);
}
#[test]
fn format_suggestion_renders_the_closest_names() {
assert_eq!(
format_suggestion("cyclomatc", &metric_ids()),
"; did you mean `cyclomatic`?"
);
assert_eq!(format_suggestion("xyznonexistent", &metric_ids()), "");
}
}