use alloc::{format, string::String, vec, vec::Vec};
pub(crate) const BUDGET: u64 = 1 << 24;
const MAX_LEN: usize = 128;
pub(crate) fn suggest_with<'a>(
name: &str,
candidates: impl Iterator<Item = &'a str>,
limit: usize,
budget: &mut u64,
) -> Option<String> {
let a: Vec<char> = name.chars().collect();
if a.len() > MAX_LEN {
return None;
}
let mut best: Option<(usize, &str)> = None;
let mut row = Vec::new();
for candidate in candidates {
if *budget == 0 {
break;
}
let len = candidate.chars().count();
if len.abs_diff(a.len()) > limit || len > MAX_LEN {
continue;
}
let bound = best.map_or(limit, |(d, _)| d.saturating_sub(1).min(limit));
if let Some(d) = banded(&a, candidate, bound, &mut row, budget) {
if best.is_none_or(|(b, _)| d < b) {
best = Some((d, candidate));
if d == 0 {
break;
}
}
}
}
best.map(|(_, c)| format!("did you mean `{c}`?"))
}
pub(crate) fn suggest<'a>(
name: &str,
candidates: impl Iterator<Item = &'a str>,
limit: usize,
) -> Option<String> {
let mut budget = BUDGET;
suggest_with(name, candidates, limit, &mut budget)
}
fn banded(
a: &[char],
b: &str,
limit: usize,
row: &mut Vec<usize>,
budget: &mut u64,
) -> Option<usize> {
const FAR: usize = usize::MAX / 2;
let b: Vec<char> = b.chars().collect();
let (n, m) = (a.len(), b.len());
if n.abs_diff(m) > limit {
return None;
}
row.clear();
row.extend(0..=m);
let mut prev = core::mem::take(row);
let mut cur = vec![FAR; m + 1];
for i in 1..=n {
let lo = i.saturating_sub(limit).max(1);
let hi = (i + limit).min(m);
cur[lo - 1] = if lo == 1 { i } else { FAR };
let mut row_min = cur[lo - 1];
for j in lo..=hi {
let cost = usize::from(a[i - 1] != b[j - 1]);
let value = (prev[j - 1] + cost).min(prev[j] + 1).min(cur[j - 1] + 1);
cur[j] = value;
row_min = row_min.min(value);
}
if hi < m {
cur[hi + 1] = FAR;
}
*budget = budget.saturating_sub((hi + 1 - lo) as u64);
if row_min > limit {
*row = prev;
return None;
}
core::mem::swap(&mut prev, &mut cur);
}
let d = prev[m];
*row = prev;
(d <= limit).then_some(d)
}
#[cfg(test)]
fn edit_distance(a: &str, b: &str) -> usize {
let mut budget = u64::MAX;
let a: Vec<char> = a.chars().collect();
banded(&a, b, usize::MAX / 4, &mut Vec::new(), &mut budget).unwrap_or(usize::MAX)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_edit_distance() {
assert_eq!(edit_distance("expr", "expr"), 0);
assert_eq!(edit_distance("exprr", "expr"), 1);
assert_eq!(edit_distance("stmt", "smt"), 1);
assert_eq!(edit_distance("", "abc"), 3);
assert_eq!(edit_distance("kitten", "sitting"), 3);
}
#[test]
fn test_suggest_picks_closest_within_limit() {
let names = ["expr", "stmt", "program"];
assert_eq!(
suggest("exprr", names.iter().copied(), 1).as_deref(),
Some("did you mean `expr`?")
);
assert_eq!(suggest("zzz", names.iter().copied(), 1), None);
assert_eq!(
suggest("ab", ["ax", "ay"].iter().copied(), 1).as_deref(),
Some("did you mean `ax`?")
);
assert_eq!(
suggest("idents", ["identifiers", "newlines"].iter().copied(), 2),
None
);
}
#[test]
fn test_suggest_budget_bounds_work() {
let names: Vec<String> = (0..200_000).map(|i| format!("rule{i:06}")).collect();
let mut budget = 10_000;
let started = std::time::Instant::now();
for _ in 0..1000 {
let _ = suggest_with(
"rulex00000",
names.iter().map(String::as_str),
3,
&mut budget,
);
}
assert_eq!(budget, 0);
assert!(started.elapsed() < std::time::Duration::from_secs(5));
}
}