1use mos_core::{Diagnostic, SourceSpan, Suggestion, codes};
11
12pub(crate) fn edit_distance(a: &str, b: &str) -> usize {
27 let a = a.as_bytes();
28 let b = b.as_bytes();
29 let mut prev2: Vec<usize> = vec![0; b.len() + 1];
30 let mut prev: Vec<usize> = (0..=b.len()).collect();
31 let mut curr: Vec<usize> = vec![0; b.len() + 1];
32 for (i, &ai) in a.iter().enumerate() {
33 curr[0] = i + 1;
34 for (j, &bj) in b.iter().enumerate() {
35 let cost = usize::from(ai != bj);
36 let mut best = (prev[j] + cost) .min(prev[j + 1] + 1) .min(curr[j] + 1); if i > 0 && j > 0 && ai == b[j - 1] && a[i - 1] == bj {
40 best = best.min(prev2[j - 1] + 1); }
42 curr[j + 1] = best;
43 }
44 std::mem::swap(&mut prev2, &mut prev);
46 std::mem::swap(&mut prev, &mut curr);
47 }
48 prev[b.len()]
49}
50
51pub(crate) fn nearest_match<'a, I>(unknown: &str, candidates: I) -> Option<&'a str>
68where
69 I: IntoIterator<Item = &'a str>,
70{
71 if unknown.len() < 3 {
72 return None;
73 }
74 let max_distance = unknown.len() / 3;
75 let mut best: Option<(usize, &'a str)> = None;
76 let mut tied = false;
77 for candidate in candidates {
78 let distance = edit_distance(unknown, candidate);
79 if distance > max_distance {
80 continue;
81 }
82 match best {
83 None => best = Some((distance, candidate)),
84 Some((best_distance, _)) if distance < best_distance => {
85 best = Some((distance, candidate));
86 tied = false;
87 }
88 Some((best_distance, _)) if distance == best_distance => tied = true,
89 Some(_) => {}
90 }
91 }
92 let (_, candidate) = best?;
93 (!tied).then_some(candidate)
94}
95
96pub(crate) fn unknown_key_diagnostic(
103 message: String,
104 key: &str,
105 key_span: &SourceSpan,
106 candidates: &[&str],
107) -> Diagnostic {
108 let mut diagnostic =
109 Diagnostic::simple(&codes::MOS0015, None, message).with_span(key_span.clone());
110 if let Some(candidate) = nearest_match(key, candidates.iter().copied()) {
111 diagnostic = diagnostic.with_suggestion(Suggestion::new(key_span.clone(), candidate));
112 }
113 diagnostic
114}
115
116#[cfg(test)]
117mod tests {
118 use super::{edit_distance, nearest_match};
119
120 #[test]
121 fn edit_distance_counts_inserts_deletes_substitutions() {
122 assert_eq!(edit_distance("", ""), 0);
123 assert_eq!(edit_distance("abc", "abc"), 0);
124 assert_eq!(edit_distance("abc", ""), 3);
125 assert_eq!(edit_distance("", "abc"), 3);
126 assert_eq!(edit_distance("tex", "text"), 1);
127 assert_eq!(edit_distance("margn", "margin"), 1);
128 assert_eq!(edit_distance("kitten", "sitting"), 3);
129 }
130
131 #[test]
132 fn edit_distance_counts_adjacent_transposition_as_one_edit() {
133 assert_eq!(edit_distance("wdith", "width"), 1);
134 assert_eq!(edit_distance("hieght", "height"), 1);
135 }
136
137 #[test]
138 fn edit_distance_is_case_sensitive() {
139 assert_eq!(edit_distance("Width", "width"), 1);
140 }
141
142 #[test]
143 fn close_typo_matches_nearest_candidate() {
144 assert_eq!(
145 nearest_match("margn", ["paper", "margin", "numbering"]),
146 Some("margin")
147 );
148 assert_eq!(
149 nearest_match("tex", ["page", "text", "document", "image"]),
150 Some("text")
151 );
152 assert_eq!(
153 nearest_match("wdith", ["src", "path", "alt", "width", "height", "label"]),
154 Some("width")
155 );
156 }
157
158 #[test]
159 fn exact_candidate_wins_over_near_misses() {
160 assert_eq!(nearest_match("width", ["width", "height"]), Some("width"));
161 }
162
163 #[test]
164 fn far_off_name_matches_nothing() {
165 assert_eq!(
166 nearest_match("banana", ["paper", "margin", "numbering"]),
167 None
168 );
169 }
170
171 #[test]
172 fn tied_candidates_match_nothing() {
173 assert_eq!(nearest_match("abc", ["abx", "aby"]), None);
176 }
177
178 #[test]
179 fn tie_is_reset_when_a_strictly_closer_candidate_appears() {
180 assert_eq!(nearest_match("abc", ["abx", "aby", "abc"]), Some("abc"));
183 }
184
185 #[test]
186 fn short_name_matches_nothing() {
187 assert_eq!(nearest_match("ab", ["ax"]), None);
188 }
189}