fallow_config/
levenshtein.rs1#[must_use]
14pub fn levenshtein(a: &str, b: &str) -> usize {
15 let a_bytes = a.as_bytes();
16 let b_bytes = b.as_bytes();
17 let (a_len, b_len) = (a_bytes.len(), b_bytes.len());
18
19 if a_len == 0 {
20 return b_len;
21 }
22 if b_len == 0 {
23 return a_len;
24 }
25
26 let mut prev: Vec<usize> = (0..=b_len).collect();
27 let mut curr: Vec<usize> = vec![0; b_len + 1];
28
29 for i in 1..=a_len {
30 curr[0] = i;
31 for j in 1..=b_len {
32 let cost = usize::from(a_bytes[i - 1] != b_bytes[j - 1]);
33 curr[j] = (prev[j] + 1) .min(curr[j - 1] + 1) .min(prev[j - 1] + cost); }
37 std::mem::swap(&mut prev, &mut curr);
38 }
39
40 prev[b_len]
41}
42
43pub fn closest_match<'a, I>(input: &str, candidates: I) -> Option<&'a str>
54where
55 I: IntoIterator<Item = &'a str>,
56{
57 let input_lower = input.to_ascii_lowercase();
58 let mut best: Option<(&'a str, usize)> = None;
59
60 for candidate in candidates {
61 let d = levenshtein(&input_lower, &candidate.to_ascii_lowercase());
62 if best.is_none_or(|(_, b_dist)| d < b_dist) {
63 best = Some((candidate, d));
64 }
65 }
66
67 best.filter(|&(_, d)| d > 0 && d <= 2 && input_lower.len() / 2 > d)
68 .map(|(name, _)| name)
69}
70
71#[cfg(test)]
72mod tests {
73 use super::*;
74
75 #[test]
76 fn levenshtein_identical() {
77 assert_eq!(levenshtein("abc", "abc"), 0);
78 }
79
80 #[test]
81 fn levenshtein_one_insertion() {
82 assert_eq!(levenshtein("abc", "abcd"), 1);
83 }
84
85 #[test]
86 fn levenshtein_empty_pair() {
87 assert_eq!(levenshtein("", ""), 0);
88 assert_eq!(levenshtein("abc", ""), 3);
89 assert_eq!(levenshtein("", "abc"), 3);
90 }
91
92 #[test]
93 fn closest_match_finds_typo() {
94 let candidates = ["@vue/core", "react", "svelte"];
95 assert_eq!(
96 closest_match("@vue/cor", candidates.iter().copied()),
97 Some("@vue/core")
98 );
99 }
100
101 #[test]
102 fn closest_match_returns_none_for_novel_input() {
103 let candidates = ["react", "vue"];
104 assert_eq!(
105 closest_match("acme-magic", candidates.iter().copied()),
106 None
107 );
108 }
109
110 #[test]
111 fn closest_match_returns_none_when_input_too_short() {
112 let candidates = ["react"];
113 assert_eq!(closest_match("rea", candidates.iter().copied()), None);
114 }
115
116 #[test]
117 fn closest_match_skips_exact_match() {
118 let candidates = ["react"];
119 assert_eq!(closest_match("react", candidates.iter().copied()), None);
120 }
121
122 #[test]
123 fn closest_match_is_case_insensitive() {
124 let candidates = ["@vue/core"];
125 assert_eq!(
126 closest_match("@VUE/CORE", candidates.iter().copied()),
127 None,
128 "exact match (ignoring case) should not produce a suggestion"
129 );
130 assert_eq!(
131 closest_match("@VUE/CORX", candidates.iter().copied()),
132 Some("@vue/core")
133 );
134 }
135
136 #[test]
137 fn closest_match_empty_candidates() {
138 let candidates: [&str; 0] = [];
139 assert_eq!(closest_match("anything", candidates.iter().copied()), None);
140 }
141}