1pub const DEFAULT_SUGGESTION_DISTANCE: usize = 2;
15
16pub fn nearest<'a, I, S>(input: &str, candidates: I) -> Option<&'a str>
28where
29 I: IntoIterator<Item = S>,
30 S: Into<&'a str>,
31{
32 nearest_within(input, candidates, DEFAULT_SUGGESTION_DISTANCE)
33}
34
35pub fn nearest_within<'a, I, S>(input: &str, candidates: I, max_distance: usize) -> Option<&'a str>
56where
57 I: IntoIterator<Item = S>,
58 S: Into<&'a str>,
59{
60 let lower_input = input.to_lowercase();
61 let mut best: Option<(&'a str, usize)> = None;
62 for candidate in candidates {
63 let candidate: &'a str = candidate.into();
64 let lower_candidate = candidate.to_lowercase();
65 let Some(score) = match_score(&lower_input, &lower_candidate, max_distance) else {
66 continue;
67 };
68 if let Some((best_candidate, best_score)) = best {
69 if score < best_score
70 || (score == best_score && prefers(candidate, best_candidate, &lower_input))
71 {
72 best = Some((candidate, score));
73 }
74 } else {
75 best = Some((candidate, score));
76 }
77 }
78 best.map(|(candidate, _)| candidate)
79}
80
81fn match_score(input: &str, candidate: &str, max_distance: usize) -> Option<usize> {
89 let input_len = input.chars().count();
90 let candidate_len = candidate.chars().count();
91 if input_len.abs_diff(candidate_len) <= max_distance {
94 let distance = osa_distance(input, candidate);
95 if distance <= max_distance {
96 return Some(distance);
97 }
98 }
99 if input_len >= 2
100 && candidate_len <= input_len.saturating_mul(4)
101 && is_subsequence(input, candidate)
102 {
103 return Some(max_distance);
104 }
105 None
106}
107
108fn is_subsequence(needle: &str, haystack: &str) -> bool {
111 let mut chars = haystack.chars();
112 needle
113 .chars()
114 .all(|target| chars.by_ref().any(|c| c == target))
115}
116
117fn prefers(candidate: &str, incumbent: &str, lower_input: &str) -> bool {
121 let leading = lower_input.chars().next();
122 let candidate_prefix = leading.is_some_and(|c| leading_char_matches(candidate, c));
123 let incumbent_prefix = leading.is_some_and(|c| leading_char_matches(incumbent, c));
124 match (candidate_prefix, incumbent_prefix) {
125 (true, false) => true,
126 (false, true) => false,
127 _ => candidate < incumbent,
128 }
129}
130
131fn leading_char_matches(s: &str, leading: char) -> bool {
134 s.chars()
135 .next()
136 .is_some_and(|first| first.to_lowercase().eq(leading.to_lowercase()))
137}
138
139fn osa_distance(a: &str, b: &str) -> usize {
142 let a: Vec<char> = a.chars().collect();
143 let b: Vec<char> = b.chars().collect();
144 if a.is_empty() {
145 return b.len();
146 }
147 if b.is_empty() {
148 return a.len();
149 }
150 let cols = b.len() + 1;
151 let mut two_prev = vec![0_usize; cols];
153 let mut prev: Vec<usize> = (0..cols).collect();
154 let mut current = vec![0_usize; cols];
155 for i in 1..=a.len() {
156 current[0] = i;
157 for j in 1..=b.len() {
158 let cost = usize::from(a[i - 1] != b[j - 1]);
159 let mut value = (prev[j] + 1)
160 .min(current[j - 1] + 1)
161 .min(prev[j - 1] + cost);
162 if i > 1 && j > 1 && a[i - 1] == b[j - 2] && a[i - 2] == b[j - 1] {
163 value = value.min(two_prev[j - 2] + 1);
164 }
165 current[j] = value;
166 }
167 std::mem::swap(&mut two_prev, &mut prev);
168 std::mem::swap(&mut prev, &mut current);
169 }
170 prev[b.len()]
171}
172
173#[cfg(test)]
174mod tests {
175 use super::{DEFAULT_SUGGESTION_DISTANCE, nearest, nearest_within, osa_distance};
176
177 #[test]
178 fn suggests_the_nearest_single_typo() {
179 assert_eq!(nearest("fmt", ["format", "test", "lint"]), Some("format"));
180 assert_eq!(nearest("buld", ["build", "check"]), Some("build"));
181 assert_eq!(nearest("tset", ["test", "reset"]), Some("test"));
182 }
183
184 #[test]
185 fn is_case_insensitive() {
186 assert_eq!(nearest("FMT", ["format"]), Some("format"));
187 assert_eq!(nearest("Buld", ["build"]), Some("build"));
188 }
189
190 #[test]
191 fn far_off_tokens_suggest_nothing() {
192 assert_eq!(nearest("zzzzzz", ["format", "build", "test"]), None);
193 assert_eq!(nearest_within("xyz", ["build"], 2), None);
194 }
195
196 #[test]
197 fn transposition_counts_as_one_edit() {
198 assert_eq!(osa_distance("teh", "the"), 1);
199 assert_eq!(nearest_within("teh", ["the", "then"], 1), Some("the"));
200 }
201
202 #[test]
203 fn exact_match_is_distance_zero() {
204 assert_eq!(osa_distance("build", "build"), 0);
205 assert_eq!(nearest("build", ["build", "check"]), Some("build"));
206 }
207
208 #[test]
209 fn prefix_shared_with_input_breaks_ties() {
210 assert_eq!(nearest_within("car", ["bar", "cat"], 2), Some("cat"));
213 }
214
215 #[test]
216 fn empty_input_tie_break_is_deterministic() {
217 assert_eq!(nearest_within("", ["bb", "aa"], 2), Some("aa"));
220 assert_eq!(nearest_within("", ["aa", "bb"], 2), Some("aa"));
221 }
222
223 #[test]
224 fn abbreviation_resolves_short_hand() {
225 assert_eq!(nearest("cfg", ["config", "check"]), Some("config"));
226 }
227
228 #[test]
229 fn empty_candidate_set_is_none() {
230 let empty: [&str; 0] = [];
231 assert_eq!(nearest("build", empty), None);
232 }
233
234 #[test]
235 fn default_distance_is_two() {
236 assert_eq!(DEFAULT_SUGGESTION_DISTANCE, 2);
237 }
238}