1use crate::patch::matcher::{find_nearest_miss, NearestMissSearch};
11
12pub(crate) const NEAREST_MISS_SIMILARITY_FLOOR: f32 = 0.5;
15pub(crate) const NEAREST_MISS_EXCERPT_LINES: usize = 10;
17pub(crate) const AMBIGUOUS_OCCURRENCE_LIST_LIMIT: usize = 8;
19pub(crate) const NEAREST_MISS_LINE_CHARS: usize = 240;
21
22#[derive(Debug, Clone)]
24pub struct FuzzyMatch {
25 pub byte_start: usize,
26 pub byte_len: usize,
27 pub pass: u8,
29}
30
31pub fn find_all_fuzzy(haystack: &str, needle: &str) -> Vec<FuzzyMatch> {
34 let exact: Vec<FuzzyMatch> = haystack
36 .match_indices(needle)
37 .map(|(idx, _)| FuzzyMatch {
38 byte_start: idx,
39 byte_len: needle.len(),
40 pass: 1,
41 })
42 .collect();
43
44 if !exact.is_empty() {
45 return exact;
46 }
47
48 let needle_lines: Vec<&str> = needle.lines().collect();
50 if needle_lines.is_empty() {
51 return vec![];
52 }
53
54 let haystack_lines: Vec<&str> = haystack.lines().collect();
55 let line_byte_offsets = compute_line_offsets(haystack);
56
57 let rstrip_matches = find_line_matches(
59 &haystack_lines,
60 &needle_lines,
61 &line_byte_offsets,
62 haystack,
63 |a, b| a.trim_end() == b.trim_end(),
64 2,
65 );
66 if !rstrip_matches.is_empty() {
67 return rstrip_matches;
68 }
69
70 let trim_matches = find_line_matches(
72 &haystack_lines,
73 &needle_lines,
74 &line_byte_offsets,
75 haystack,
76 |a, b| a.trim() == b.trim(),
77 3,
78 );
79 if !trim_matches.is_empty() {
80 return trim_matches;
81 }
82
83 let normalized_haystack_lines: Vec<String> = haystack_lines
86 .iter()
87 .map(|line| normalize_unicode(line.trim()))
88 .collect();
89 let normalized_needle_lines: Vec<String> = needle_lines
90 .iter()
91 .map(|line| normalize_unicode(line.trim()))
92 .collect();
93 let normalized_haystack_refs: Vec<&str> = normalized_haystack_lines
94 .iter()
95 .map(String::as_str)
96 .collect();
97 let normalized_needle_refs: Vec<&str> =
98 normalized_needle_lines.iter().map(String::as_str).collect();
99 let normalized_matches = find_line_matches(
100 &normalized_haystack_refs,
101 &normalized_needle_refs,
102 &line_byte_offsets,
103 haystack,
104 |a, b| a == b,
105 4,
106 );
107 if !normalized_matches.is_empty() {
108 return normalized_matches;
109 }
110
111 find_reflow_matches(&haystack_lines, &needle_lines, &line_byte_offsets, haystack)
115}
116
117pub(crate) fn render_nearest_miss_detail(source: &str, needle: &str) -> String {
118 let lines: Vec<&str> = source.lines().collect();
119 let pattern: Vec<&str> = needle.lines().collect();
120 let fallback = format!(" (file has {} lines)", lines.len());
121
122 let nearest = match find_nearest_miss(&lines, &pattern, source.len()) {
123 NearestMissSearch::Found(nearest) => nearest,
124 NearestMissSearch::NoSimilarRegion | NearestMissSearch::SkippedLargeFile => {
125 return fallback;
126 }
127 };
128 let similarity = nearest.matched_lines as f32 / pattern.len().max(1) as f32;
129 if similarity < NEAREST_MISS_SIMILARITY_FLOOR {
130 return fallback;
131 }
132
133 let start_line = nearest.start + 1;
134 let end_line = nearest.end;
135 let mut detail = format!("\nNearest candidate at lines {start_line}-{end_line}:");
136 let available_lines = nearest.end.saturating_sub(nearest.start);
137 let line_number_width = end_line.to_string().len();
138
139 if available_lines <= NEAREST_MISS_EXCERPT_LINES {
140 for (offset, line) in lines[nearest.start..nearest.end].iter().enumerate() {
141 append_diagnostic_line(&mut detail, nearest.start, offset, line, line_number_width);
142 }
143 } else {
144 let head_lines = NEAREST_MISS_EXCERPT_LINES / 2;
145 let tail_lines = NEAREST_MISS_EXCERPT_LINES - head_lines;
146 for (offset, line) in lines[nearest.start..nearest.start + head_lines]
147 .iter()
148 .enumerate()
149 {
150 append_diagnostic_line(&mut detail, nearest.start, offset, line, line_number_width);
151 }
152 detail.push_str("\n ... (middle candidate lines truncated)");
153 for (offset, line) in lines[nearest.end - tail_lines..nearest.end]
154 .iter()
155 .enumerate()
156 {
157 append_diagnostic_line(
158 &mut detail,
159 nearest.start,
160 available_lines - tail_lines + offset,
161 line,
162 line_number_width,
163 );
164 }
165 }
166
167 let divergence = nearest.first_divergence;
168 let expected = pattern.get(divergence).copied().unwrap_or("<EOF>");
169 let actual = lines
170 .get(nearest.start + divergence)
171 .copied()
172 .unwrap_or("<EOF>");
173 detail.push_str(&format!(
174 "\nFirst divergence:\n- expected: {}\n+ actual: {}",
175 shorten_diagnostic_line(expected),
176 shorten_diagnostic_line(actual),
177 ));
178 detail
179}
180
181fn append_diagnostic_line(
182 detail: &mut String,
183 start: usize,
184 offset: usize,
185 line: &str,
186 line_number_width: usize,
187) {
188 let line_number = start + offset + 1;
189 detail.push_str(&format!(
190 "\n {line_number:>line_number_width$} | {}",
191 shorten_diagnostic_line(line),
192 line_number_width = line_number_width,
193 ));
194}
195
196fn shorten_diagnostic_line(line: &str) -> String {
197 let chars = line.chars().collect::<Vec<_>>();
198 if chars.len() <= NEAREST_MISS_LINE_CHARS {
199 return line.to_string();
200 }
201
202 let head = NEAREST_MISS_LINE_CHARS / 2;
203 let tail = NEAREST_MISS_LINE_CHARS - head;
204 let mut shortened = chars[..head].iter().collect::<String>();
205 shortened.push('…');
206 shortened.extend(chars[chars.len() - tail..].iter());
207 shortened
208}
209
210pub(crate) fn render_occurrence_listing(source: &str, positions: &[usize]) -> String {
212 let listed = positions
213 .iter()
214 .take(AMBIGUOUS_OCCURRENCE_LIST_LIMIT)
215 .enumerate()
216 .map(|(index, position)| {
217 let line = source[0..*position].matches('\n').count() + 1;
218 format!("#{} at line {}", index + 1, line)
219 })
220 .collect::<Vec<_>>();
221 let mut detail = format!(" {} occurrences: {}", positions.len(), listed.join(", "));
222 if positions.len() > AMBIGUOUS_OCCURRENCE_LIST_LIMIT {
223 detail.push_str(&format!(
224 ", … and {} more",
225 positions.len() - AMBIGUOUS_OCCURRENCE_LIST_LIMIT
226 ));
227 }
228 detail
229}
230
231fn compute_line_offsets(source: &str) -> Vec<usize> {
233 let mut offsets = vec![0];
234 for (i, c) in source.char_indices() {
235 if c == '\n' && i + 1 <= source.len() {
236 offsets.push(i + 1);
237 }
238 }
239 offsets
240}
241
242fn find_line_matches<F>(
244 haystack_lines: &[&str],
245 needle_lines: &[&str],
246 line_offsets: &[usize],
247 haystack: &str,
248 compare: F,
249 pass: u8,
250) -> Vec<FuzzyMatch>
251where
252 F: Fn(&str, &str) -> bool,
253{
254 let mut matches = Vec::new();
255 if needle_lines.len() > haystack_lines.len() {
256 return matches;
257 }
258
259 'outer: for i in 0..=(haystack_lines.len() - needle_lines.len()) {
260 for j in 0..needle_lines.len() {
261 if !compare(haystack_lines[i + j], needle_lines[j]) {
262 continue 'outer;
263 }
264 }
265 let byte_start = line_offsets[i];
267 let end_line = i + needle_lines.len();
268 let byte_end = if end_line < line_offsets.len() {
269 line_offsets[end_line]
271 } else {
272 haystack.len()
273 };
274 matches.push(FuzzyMatch {
275 byte_start,
276 byte_len: byte_end - byte_start,
277 pass,
278 });
279 }
280
281 matches
282}
283
284const REFLOW_NON_WS_TOLERANCE: usize = 8;
285
286fn find_reflow_matches(
287 haystack_lines: &[&str],
288 needle_lines: &[&str],
289 line_offsets: &[usize],
290 haystack: &str,
291) -> Vec<FuzzyMatch> {
292 let needle_text = needle_lines.join("\n");
293 let normalized_needle = normalize_reflow_whitespace(&needle_text);
294 let needle_non_whitespace = strip_reflow_whitespace(&needle_text);
295 if normalized_needle.is_empty() || needle_non_whitespace.is_empty() {
296 return Vec::new();
297 }
298
299 let min_non_whitespace = needle_non_whitespace
300 .len()
301 .saturating_sub(REFLOW_NON_WS_TOLERANCE);
302 let max_non_whitespace = needle_non_whitespace.len() + REFLOW_NON_WS_TOLERANCE;
303 let line_non_whitespace_lens = reflow_non_whitespace_lens(haystack_lines);
306 let mut matches = Vec::new();
307
308 for start in 0..haystack_lines.len() {
309 if !has_reflow_content(haystack_lines[start]) {
310 continue;
311 }
312
313 let mut window_non_whitespace_len = 0usize;
314 for end in (start + 1)..=haystack_lines.len() {
315 let line = haystack_lines[end - 1];
316 window_non_whitespace_len += line_non_whitespace_lens[end - 1];
317
318 if window_non_whitespace_len > max_non_whitespace {
319 break;
320 }
321 if window_non_whitespace_len < min_non_whitespace {
322 continue;
323 }
324 if !has_reflow_content(line) {
325 continue;
326 }
327
328 let window_text = haystack_lines[start..end].join("\n");
329 let window_non_whitespace = strip_reflow_whitespace(&window_text);
330 if window_non_whitespace != needle_non_whitespace {
331 continue;
332 }
333 if normalize_reflow_whitespace(&window_text) != normalized_needle {
334 continue;
335 }
336
337 let byte_start = line_offsets[start];
338 let byte_end = if end < line_offsets.len() {
339 line_offsets[end]
340 } else {
341 haystack.len()
342 };
343 matches.push(FuzzyMatch {
344 byte_start,
345 byte_len: byte_end - byte_start,
346 pass: 5,
347 });
348 }
349 }
350
351 matches
352}
353
354fn normalize_reflow_whitespace(s: &str) -> String {
355 let mut normalized = String::new();
356 let mut in_whitespace = false;
357
358 for c in s.trim().chars() {
359 if c.is_whitespace() {
360 in_whitespace = true;
361 } else {
362 if in_whitespace && !normalized.is_empty() {
363 normalized.push(' ');
364 }
365 normalized.push(c);
366 in_whitespace = false;
367 }
368 }
369
370 normalized
371}
372
373fn reflow_non_whitespace_lens(lines: &[&str]) -> Vec<usize> {
374 lines
375 .iter()
376 .map(|line| {
377 line.chars()
378 .filter(|character| !character.is_whitespace())
379 .map(char::len_utf8)
380 .sum()
381 })
382 .collect()
383}
384
385fn strip_reflow_whitespace(s: &str) -> String {
386 s.chars().filter(|c| !c.is_whitespace()).collect()
387}
388
389fn has_reflow_content(s: &str) -> bool {
390 s.chars().any(|c| !c.is_whitespace())
391}
392
393fn normalize_unicode(s: &str) -> String {
395 s.chars()
396 .map(|c| match c {
397 '\u{2018}' | '\u{2019}' | '\u{201A}' | '\u{201B}' => '\'',
398 '\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}' => '"',
399 '\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}' | '\u{2015}' => '-',
400 '\u{00A0}' => ' ',
401 _ => c,
402 })
403 .collect::<String>()
404 .replace('\u{2026}', "...")
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410
411 #[test]
412 fn test_exact_match() {
413 let matches = find_all_fuzzy("hello world", "world");
414 assert_eq!(matches.len(), 1);
415 assert_eq!(matches[0].byte_start, 6);
416 assert_eq!(matches[0].pass, 1);
417 }
418
419 #[test]
420 fn test_exact_match_multiple() {
421 let matches = find_all_fuzzy("foo bar foo baz foo", "foo");
422 assert_eq!(matches.len(), 3);
423 assert_eq!(matches[0].byte_start, 0);
424 assert_eq!(matches[1].byte_start, 8);
425 assert_eq!(matches[2].byte_start, 16);
426 }
427
428 #[test]
429 fn test_rstrip_match() {
430 let source = " hello \n world \n";
431 let needle = " hello\n world";
432 let matches = find_all_fuzzy(source, needle);
433 assert_eq!(matches.len(), 1);
434 assert_eq!(matches[0].pass, 2); }
436
437 #[test]
438 fn test_trim_match() {
439 let source = " function foo() {\n return 1;\n }\n";
440 let needle = "function foo() {\n return 1;\n}";
441 let matches = find_all_fuzzy(source, needle);
442 assert_eq!(matches.len(), 1);
443 assert_eq!(matches[0].pass, 3); }
445
446 #[test]
447 fn test_unicode_normalize() {
448 let source = "let msg = \u{201C}hello\u{201D}\n";
449 let needle = "let msg = \"hello\"";
450 let matches = find_all_fuzzy(source, needle);
451 assert_eq!(matches.len(), 1);
452 assert_eq!(matches[0].pass, 4); }
454
455 #[test]
456 fn test_unicode_normalize_multiline_variants() {
457 let source = "alpha\n let title = \u{201C}hello\u{201D}\u{2026}\n let slug = foo\u{2014}bar\u{00A0}baz\nomega\n";
458 let needle = "let title = \"hello\"...\nlet slug = foo-bar baz";
459 let matches = find_all_fuzzy(source, needle);
460
461 assert_eq!(matches.len(), 1);
462 assert_eq!(matches[0].pass, 4);
463 assert_eq!(matches[0].byte_start, source.find(" let title").unwrap());
464 }
465
466 #[test]
467 fn test_no_match() {
468 let matches = find_all_fuzzy("hello world", "xyz");
469 assert!(matches.is_empty());
470 }
471
472 #[test]
473 fn test_multiline_exact() {
474 let source = "line1\nline2\nline3\nline4\n";
475 let needle = "line2\nline3";
476 let matches = find_all_fuzzy(source, needle);
477 assert_eq!(matches.len(), 1);
478 assert_eq!(matches[0].byte_start, 6);
479 assert_eq!(matches[0].pass, 1);
480 }
481
482 #[test]
483 fn test_reflow_one_line_needle_matches_three_line_split() {
484 let source = "before\nlet total = alpha +\n beta +\n gamma;\nafter\n";
485 let needle = "let total = alpha + beta + gamma;";
486 let matches = find_all_fuzzy(source, needle);
487
488 assert_eq!(matches.len(), 1);
489 assert_eq!(matches[0].pass, 5);
490 assert_eq!(matches[0].byte_start, source.find("let total").unwrap());
491 assert_eq!(
492 &source[matches[0].byte_start..matches[0].byte_start + matches[0].byte_len],
493 "let total = alpha +\n beta +\n gamma;\n"
494 );
495 }
496
497 #[test]
498 fn test_reflow_three_line_needle_matches_one_line_join() {
499 let source = "before\nlet total = alpha + beta + gamma;\nafter\n";
500 let needle = "let total = alpha +\n beta +\n gamma;";
501 let matches = find_all_fuzzy(source, needle);
502
503 assert_eq!(matches.len(), 1);
504 assert_eq!(matches[0].pass, 5);
505 assert_eq!(matches[0].byte_start, source.find("let total").unwrap());
506 assert_eq!(
507 &source[matches[0].byte_start..matches[0].byte_start + matches[0].byte_len],
508 "let total = alpha + beta + gamma;\n"
509 );
510 }
511
512 #[test]
513 fn test_reflow_reports_all_ambiguous_windows() {
514 let source =
515 "let total = alpha +\n beta +\n gamma;\n\nlet total = alpha +\n beta +\n gamma;\n";
516 let needle = "let total = alpha + beta + gamma;";
517 let matches = find_all_fuzzy(source, needle);
518
519 assert_eq!(matches.len(), 2);
520 assert!(matches.iter().all(|m| m.pass == 5));
521 }
522
523 #[test]
524 fn test_reflow_near_miss_does_not_match() {
525 let source = "let total = alpha +\n beta +\n gamma;\n";
526 let needle = "let total = alpha + beta + delta;";
527 let matches = find_all_fuzzy(source, needle);
528
529 assert!(matches.is_empty());
530 }
531
532 #[test]
533 fn test_reflow_does_not_preempt_exact_match() {
534 let source = "let total = alpha +\n beta +\n gamma;\nlet total = alpha + beta + gamma;\n";
535 let needle = "let total = alpha + beta + gamma;";
536 let matches = find_all_fuzzy(source, needle);
537
538 assert_eq!(matches.len(), 1);
539 assert_eq!(matches[0].pass, 1);
540 assert_eq!(matches[0].byte_start, source.rfind("let total").unwrap());
541 }
542
543 #[test]
544 fn reflow_length_precomputation_allocates_only_the_result_vector() {
545 let line =
546 "export const generated = café\u{2003}+\t中 + padding_padding_padding_padding;\n";
547 let source = line.repeat(256);
548 let lines: Vec<&str> = source.lines().collect();
549 let expected: Vec<usize> = lines
550 .iter()
551 .map(|line| strip_reflow_whitespace(line).len())
552 .collect();
553
554 let (actual, allocations) =
555 crate::test_allocations::count(|| reflow_non_whitespace_lens(&lines));
556
557 assert_eq!(actual, expected);
558 assert_eq!(allocations, 1, "length precomputation allocated per line");
559 }
560
561 #[test]
562 fn nearest_miss_renders_candidate_and_first_divergence() {
563 let source =
564 "fn calculate() {\n let first = 1;\n let actual = 2;\n let last = 3;\n}\n";
565 let needle =
566 "fn calculate() {\n let first = 1;\n let expected = 2;\n let last = 3;\n}";
567 let detail = render_nearest_miss_detail(source, needle);
568
569 assert!(detail.contains("Nearest candidate at lines 1-5"));
570 assert!(detail.contains("- expected: let expected = 2;"));
571 assert!(detail.contains("+ actual: let actual = 2;"));
572 }
573
574 #[test]
575 fn nearest_miss_below_floor_reports_only_line_count() {
576 let source = "function totallyDifferent() {\n return 42;\n}\n";
577 let needle = "function target() {\n return expected;\n}";
578 let detail = render_nearest_miss_detail(source, needle);
579
580 assert_eq!(detail, " (file has 3 lines)");
581 }
582
583 #[test]
584 fn nearest_miss_excerpt_middle_truncates_after_ten_lines() {
585 let source = (1..=12)
586 .map(|line| format!("line {line}"))
587 .collect::<Vec<_>>()
588 .join("\n");
589 let needle = (1..=12)
590 .map(|line| {
591 if line == 6 {
592 "different line".to_string()
593 } else {
594 format!("line {line}")
595 }
596 })
597 .collect::<Vec<_>>()
598 .join("\n");
599 let detail = render_nearest_miss_detail(&source, &needle);
600
601 assert!(detail.contains("middle candidate lines truncated"));
602 assert!(detail.contains("line 1"));
603 assert!(detail.contains("line 12"));
604 assert!(!detail.contains("line 6 |"));
605 }
606
607 #[test]
608 fn occurrence_listing_is_one_based_and_capped() {
609 let source = "same\n".repeat(10);
610 let positions = source
611 .match_indices("same")
612 .map(|(offset, _)| offset)
613 .collect::<Vec<_>>();
614 let detail = render_occurrence_listing(&source, &positions);
615
616 assert!(detail.starts_with(" 10 occurrences: #1 at line 1, #2 at line 2"));
617 assert!(detail.contains("#8 at line 8"));
618 assert!(detail.contains("… and 2 more"));
619 assert!(!detail.contains("#9 at line 9"));
620 }
621}