1use std::path::Path;
11
12use mos_core::{Diagnostic, SourceSpan, Suggestion, codes, resolve_relative};
13
14pub(crate) fn edit_distance(a: &str, b: &str) -> usize {
29 let a = a.as_bytes();
30 let b = b.as_bytes();
31 let mut prev2: Vec<usize> = vec![0; b.len() + 1];
32 let mut prev: Vec<usize> = (0..=b.len()).collect();
33 let mut curr: Vec<usize> = vec![0; b.len() + 1];
34 for (i, &ai) in a.iter().enumerate() {
35 curr[0] = i + 1;
36 for (j, &bj) in b.iter().enumerate() {
37 let cost = usize::from(ai != bj);
38 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 {
42 best = best.min(prev2[j - 1] + 1); }
44 curr[j + 1] = best;
45 }
46 std::mem::swap(&mut prev2, &mut prev);
48 std::mem::swap(&mut prev, &mut curr);
49 }
50 prev[b.len()]
51}
52
53pub(crate) fn nearest_match<'a, I>(unknown: &str, candidates: I) -> Option<&'a str>
70where
71 I: IntoIterator<Item = &'a str>,
72{
73 if unknown.len() < 3 {
74 return None;
75 }
76 let max_distance = unknown.len() / 3;
77 let mut best: Option<(usize, &'a str)> = None;
78 let mut tied = false;
79 for candidate in candidates {
80 let distance = edit_distance(unknown, candidate);
81 if distance > max_distance {
82 continue;
83 }
84 match best {
85 None => best = Some((distance, candidate)),
86 Some((best_distance, _)) if distance < best_distance => {
87 best = Some((distance, candidate));
88 tied = false;
89 }
90 Some((best_distance, _)) if distance == best_distance => tied = true,
91 Some(_) => {}
92 }
93 }
94 let (_, candidate) = best?;
95 (!tied).then_some(candidate)
96}
97
98pub(crate) fn unknown_key_diagnostic(
105 message: String,
106 key: &str,
107 key_span: &SourceSpan,
108 candidates: &[&str],
109) -> Diagnostic {
110 let mut diagnostic =
111 Diagnostic::simple(&codes::MOS0015, None, message).with_span(key_span.clone());
112 if let Some(candidate) = nearest_match(key, candidates.iter().copied()) {
113 diagnostic = diagnostic.with_suggestion(Suggestion::new(key_span.clone(), candidate));
114 }
115 diagnostic
116}
117
118pub(crate) fn unsafe_path_diagnostic(
123 message: String,
124 path: &str,
125 span: &SourceSpan,
126 value_span: &SourceSpan,
127) -> Diagnostic {
128 let mut diagnostic = Diagnostic::simple(&codes::MOS0049, None, message).with_span(span.clone());
129 if let Some(fixed) = portable_path_fix(path) {
130 diagnostic = diagnostic.with_suggestion(Suggestion::new(
131 crate::string_content_span(value_span),
132 escape_string_content(&fixed),
133 ));
134 }
135 diagnostic
136}
137
138fn portable_path_fix(path: &str) -> Option<String> {
143 if !path.contains('\\') {
144 return None;
145 }
146 let candidate = path.replace('\\', "/");
147 let as_path = Path::new(&candidate);
148 if as_path.is_absolute() || as_path.has_root() {
149 return None;
150 }
151 resolve_relative(Path::new(""), &candidate)
152 .ok()
153 .map(|_| candidate)
154}
155
156fn escape_string_content(text: &str) -> String {
160 let mut out = String::with_capacity(text.len());
161 for ch in text.chars() {
162 match ch {
163 '\\' => out.push_str("\\\\"),
164 '"' => out.push_str("\\\""),
165 '\n' => out.push_str("\\n"),
166 '\t' => out.push_str("\\t"),
167 '\r' => out.push_str("\\r"),
168 other => out.push(other),
169 }
170 }
171 out
172}
173
174#[cfg(test)]
175mod tests {
176 use super::{edit_distance, escape_string_content, nearest_match, portable_path_fix};
177
178 #[test]
179 fn portable_path_fix_swaps_backslashes_in_relative_paths() {
180 assert_eq!(
181 portable_path_fix("assets\\logo.png"),
182 Some("assets/logo.png".to_owned())
183 );
184 assert_eq!(
185 portable_path_fix("a\\b/c\\d.png"),
186 Some("a/b/c/d.png".to_owned())
187 );
188 assert_eq!(
189 portable_path_fix("..\\shared\\x.bib"),
190 Some("../shared/x.bib".to_owned())
191 );
192 assert_eq!(portable_path_fix("assets\\"), Some("assets/".to_owned()));
193 }
194
195 #[test]
196 fn portable_path_fix_refuses_rooted_drive_and_unc_forms() {
197 assert_eq!(portable_path_fix("C:\\x.png"), None);
198 assert_eq!(portable_path_fix("c:\\x.png"), None);
199 assert_eq!(portable_path_fix("a\\C:\\b.png"), None);
200 assert_eq!(portable_path_fix("\\x.png"), None);
201 assert_eq!(portable_path_fix("\\\\server\\share\\x.png"), None);
202 }
203
204 #[test]
205 fn portable_path_fix_refuses_drive_relative_forms() {
206 assert_eq!(portable_path_fix("C:foo\\bar.png"), None);
207 assert_eq!(portable_path_fix("c:foo\\bar.png"), None);
208 assert_eq!(portable_path_fix("a\\C:foo"), None);
209 }
210
211 #[test]
212 fn portable_path_fix_has_nothing_to_offer_for_already_portable_paths() {
213 assert_eq!(portable_path_fix("assets/logo.png"), None);
214 assert_eq!(portable_path_fix("/abs/x.png"), None);
215 assert_eq!(portable_path_fix(""), None);
216 }
217
218 #[test]
219 fn escape_string_content_round_trips_parser_escapes() {
220 assert_eq!(escape_string_content("assets/logo.png"), "assets/logo.png");
221 assert_eq!(escape_string_content("a\"b"), "a\\\"b");
222 assert_eq!(escape_string_content("a\\b"), "a\\\\b");
223 assert_eq!(escape_string_content("a\nb\tc\rd"), "a\\nb\\tc\\rd");
224 }
225
226 #[test]
227 fn edit_distance_counts_inserts_deletes_substitutions() {
228 assert_eq!(edit_distance("", ""), 0);
229 assert_eq!(edit_distance("abc", "abc"), 0);
230 assert_eq!(edit_distance("abc", ""), 3);
231 assert_eq!(edit_distance("", "abc"), 3);
232 assert_eq!(edit_distance("tex", "text"), 1);
233 assert_eq!(edit_distance("margn", "margin"), 1);
234 assert_eq!(edit_distance("kitten", "sitting"), 3);
235 }
236
237 #[test]
238 fn edit_distance_counts_adjacent_transposition_as_one_edit() {
239 assert_eq!(edit_distance("wdith", "width"), 1);
240 assert_eq!(edit_distance("hieght", "height"), 1);
241 }
242
243 #[test]
244 fn edit_distance_is_case_sensitive() {
245 assert_eq!(edit_distance("Width", "width"), 1);
246 }
247
248 #[test]
249 fn close_typo_matches_nearest_candidate() {
250 assert_eq!(
251 nearest_match("margn", ["paper", "margin", "numbering"]),
252 Some("margin")
253 );
254 assert_eq!(
255 nearest_match("tex", ["page", "text", "document", "image"]),
256 Some("text")
257 );
258 assert_eq!(
259 nearest_match("wdith", ["src", "path", "alt", "width", "height", "label"]),
260 Some("width")
261 );
262 }
263
264 #[test]
265 fn exact_candidate_wins_over_near_misses() {
266 assert_eq!(nearest_match("width", ["width", "height"]), Some("width"));
267 }
268
269 #[test]
270 fn far_off_name_matches_nothing() {
271 assert_eq!(
272 nearest_match("banana", ["paper", "margin", "numbering"]),
273 None
274 );
275 }
276
277 #[test]
278 fn tied_candidates_match_nothing() {
279 assert_eq!(nearest_match("abc", ["abx", "aby"]), None);
282 }
283
284 #[test]
285 fn tie_is_reset_when_a_strictly_closer_candidate_appears() {
286 assert_eq!(nearest_match("abc", ["abx", "aby", "abc"]), Some("abc"));
289 }
290
291 #[test]
292 fn short_name_matches_nothing() {
293 assert_eq!(nearest_match("ab", ["ax"]), None);
294 }
295}