1use std::collections::{HashMap, HashSet};
7
8pub const REFLOW_NON_WS_TOLERANCE: usize = 8;
10pub const NEAREST_MISS_MAX_FILE_BYTES: usize = 2 * 1024 * 1024;
12
13const NEAREST_MISS_ANCHOR_COUNT: usize = 3;
14const NEAREST_MISS_MAX_CANDIDATES: usize = 512;
15const NEAREST_MISS_MAX_POSITIONS_PER_ANCHOR: usize = 192;
16const NEAREST_MISS_MAX_LINE_COMPARISONS: usize = 100_000;
17const NEAREST_MISS_MAX_FUZZY_CHARS: usize = 256;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub struct NearestMiss {
21 pub start: usize,
23 pub end: usize,
25 pub matched_lines: usize,
26 pub first_divergence: usize,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum NearestMissSearch {
32 Found(NearestMiss),
33 NoSimilarRegion,
34 SkippedLargeFile,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum MatchTier {
39 Exact,
40 Rstrip,
41 Trim,
42 Indent,
43 Unicode,
44 Reflow,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub struct SequenceMatch {
49 pub found: usize,
50 pub tier: MatchTier,
51 pub line_count: usize,
52}
53
54pub fn normalize_unicode(input: &str) -> String {
56 let mut normalized = String::with_capacity(input.len());
57 for ch in input.chars() {
58 match ch {
59 '\u{2018}' | '\u{2019}' | '\u{201A}' | '\u{201B}' => normalized.push('\''),
60 '\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}' => normalized.push('"'),
61 '\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}' | '\u{2015}' => {
62 normalized.push('-');
63 }
64 '\u{2026}' => normalized.push_str("..."),
65 '\u{00A0}' => normalized.push(' '),
66 _ => normalized.push(ch),
67 }
68 }
69 normalized
70}
71
72pub fn normalize_indent(input: &str) -> String {
74 let mut leading_chars = 0;
75 let mut leading_bytes = 0;
76
77 for ch in input.chars() {
78 if ch != '\t' && ch != ' ' {
79 break;
80 }
81 leading_chars += 1;
82 leading_bytes += ch.len_utf8();
83 }
84
85 if leading_chars == 0 {
86 return input.to_owned();
87 }
88
89 let mut normalized = String::with_capacity(input.len());
90 normalized.push_str(&" ".repeat(leading_chars));
91 normalized.push_str(&input[leading_bytes..]);
92 normalized
93}
94
95pub fn normalize_reflow_whitespace(input: &str) -> String {
97 let mut collapsed = String::with_capacity(input.len());
98 let mut in_whitespace = false;
99
100 for ch in input.chars() {
101 if ch.is_whitespace() {
102 if !in_whitespace {
103 collapsed.push(' ');
104 in_whitespace = true;
105 }
106 } else {
107 collapsed.push(ch);
108 in_whitespace = false;
109 }
110 }
111
112 collapsed.trim().to_owned()
113}
114
115pub fn strip_reflow_whitespace(input: &str) -> String {
117 input.chars().filter(|ch| !ch.is_whitespace()).collect()
118}
119
120pub fn has_reflow_content(input: &str) -> bool {
122 input.chars().any(|ch| !ch.is_whitespace())
123}
124
125fn matches_at<F>(lines: &[&str], pattern: &[&str], start: usize, compare: &F) -> bool
126where
127 F: Fn(&str, &str) -> bool,
128{
129 pattern
130 .iter()
131 .enumerate()
132 .all(|(offset, expected)| compare(lines[start + offset], expected))
133}
134
135pub fn try_match<F>(
137 lines: &[&str],
138 pattern: &[&str],
139 start_index: usize,
140 compare: F,
141 eof: bool,
142) -> Option<usize>
143where
144 F: Fn(&str, &str) -> bool,
145{
146 if pattern.is_empty() || pattern.len() > lines.len() {
147 return None;
148 }
149
150 if eof {
151 let from_end = lines.len() - pattern.len();
152 if from_end >= start_index && matches_at(lines, pattern, from_end, &compare) {
153 return Some(from_end);
154 }
155 return None;
156 }
157
158 let last_start = lines.len() - pattern.len();
159 if start_index > last_start {
160 return None;
161 }
162
163 (start_index..=last_start).find(|&start| matches_at(lines, pattern, start, &compare))
164}
165
166fn non_whitespace_unit_count(input: &str) -> usize {
167 strip_reflow_whitespace(input).chars().count()
171}
172
173pub fn find_reflow_match(
175 lines: &[&str],
176 pattern: &[&str],
177 start_index: usize,
178) -> Option<(usize, usize)> {
179 let needle_text = pattern.join("\n");
180 let normalized_needle = normalize_reflow_whitespace(&needle_text);
181 let needle_non_whitespace = strip_reflow_whitespace(&needle_text);
182 if normalized_needle.is_empty() || needle_non_whitespace.is_empty() {
183 return None;
184 }
185
186 let needle_non_whitespace_len = needle_non_whitespace.chars().count();
187 let min_non_whitespace = needle_non_whitespace_len.saturating_sub(REFLOW_NON_WS_TOLERANCE);
188 let max_non_whitespace = needle_non_whitespace_len + REFLOW_NON_WS_TOLERANCE;
189 let mut matches = Vec::new();
190 let mut seen = HashSet::new();
191
192 for start in start_index..lines.len() {
193 if !has_reflow_content(lines[start]) {
194 continue;
195 }
196
197 let mut window_non_whitespace_len = 0;
198 for end in (start + 1)..=lines.len() {
199 let line = lines[end - 1];
200 window_non_whitespace_len += non_whitespace_unit_count(line);
201
202 if window_non_whitespace_len > max_non_whitespace {
203 break;
204 }
205 if window_non_whitespace_len < min_non_whitespace {
206 continue;
207 }
208 if !has_reflow_content(line) {
209 continue;
210 }
211
212 let window_text = lines[start..end].join("\n");
213 let window_non_whitespace = strip_reflow_whitespace(&window_text);
214 if window_non_whitespace != needle_non_whitespace {
215 continue;
216 }
217 if normalize_reflow_whitespace(&window_text) != normalized_needle {
218 continue;
219 }
220
221 if seen.insert((start, end)) {
222 matches.push((start, end - start));
223 }
224 }
225 }
226
227 if matches.len() == 1 {
228 Some(matches[0])
229 } else {
230 None
231 }
232}
233
234pub fn seek_sequence_tiered(
236 lines: &[&str],
237 pattern: &[&str],
238 start_index: usize,
239 eof: bool,
240) -> Option<SequenceMatch> {
241 if pattern.is_empty() {
242 return None;
243 }
244
245 if let Some(found) = try_match(lines, pattern, start_index, |a, b| a == b, eof) {
246 return Some(SequenceMatch {
247 found,
248 tier: MatchTier::Exact,
249 line_count: pattern.len(),
250 });
251 }
252
253 if let Some(found) = try_match(
254 lines,
255 pattern,
256 start_index,
257 |a, b| a.trim_end() == b.trim_end(),
258 eof,
259 ) {
260 return Some(SequenceMatch {
261 found,
262 tier: MatchTier::Rstrip,
263 line_count: pattern.len(),
264 });
265 }
266
267 if let Some(found) = try_match(
268 lines,
269 pattern,
270 start_index,
271 |a, b| a.trim() == b.trim(),
272 eof,
273 ) {
274 return Some(SequenceMatch {
275 found,
276 tier: MatchTier::Trim,
277 line_count: pattern.len(),
278 });
279 }
280
281 if let Some(found) = try_match(
282 lines,
283 pattern,
284 start_index,
285 |a, b| normalize_indent(a).trim_end() == normalize_indent(b).trim_end(),
286 eof,
287 ) {
288 return Some(SequenceMatch {
289 found,
290 tier: MatchTier::Indent,
291 line_count: pattern.len(),
292 });
293 }
294
295 if let Some(found) = try_match(
296 lines,
297 pattern,
298 start_index,
299 |a, b| normalize_unicode(a.trim()) == normalize_unicode(b.trim()),
300 eof,
301 ) {
302 return Some(SequenceMatch {
303 found,
304 tier: MatchTier::Unicode,
305 line_count: pattern.len(),
306 });
307 }
308
309 if eof {
310 return None;
311 }
312
313 find_reflow_match(lines, pattern, start_index).map(|(found, line_count)| SequenceMatch {
314 found,
315 tier: MatchTier::Reflow,
316 line_count,
317 })
318}
319
320fn add_sampled_candidates(
321 candidates: &mut HashSet<usize>,
322 positions: &[usize],
323 wanted_offset: usize,
324 candidate_limit: usize,
325) {
326 let remaining = candidate_limit.saturating_sub(candidates.len());
327 let sample_count = positions
328 .len()
329 .min(NEAREST_MISS_MAX_POSITIONS_PER_ANCHOR)
330 .min(remaining);
331 if sample_count == 0 {
332 return;
333 }
334
335 for sample in 0..sample_count {
336 let position_index = if sample_count == 1 {
337 0
338 } else {
339 sample * (positions.len() - 1) / (sample_count - 1)
340 };
341 let file_position = positions[position_index];
342 if let Some(start) = file_position.checked_sub(wanted_offset) {
343 candidates.insert(start);
344 }
345 }
346}
347
348fn score_nearest_miss(lines: &[&str], pattern: &[&str], start: usize) -> NearestMiss {
349 let end = (start + pattern.len()).min(lines.len());
350 let matched_lines = pattern
351 .iter()
352 .enumerate()
353 .filter(|(offset, expected)| {
354 lines
355 .get(start + offset)
356 .is_some_and(|actual| actual.trim() == expected.trim())
357 })
358 .count();
359 let first_divergence = pattern
360 .iter()
361 .enumerate()
362 .find(|(offset, expected)| {
363 lines
364 .get(start + offset)
365 .is_none_or(|actual| actual.trim() != expected.trim())
366 })
367 .map_or(pattern.len(), |(offset, _)| offset);
368
369 NearestMiss {
370 start,
371 end,
372 matched_lines,
373 first_divergence,
374 }
375}
376
377fn is_better_nearest_miss(candidate: NearestMiss, current: NearestMiss) -> bool {
378 candidate.matched_lines > current.matched_lines
379 || (candidate.matched_lines == current.matched_lines
380 && (candidate.first_divergence > current.first_divergence
381 || (candidate.first_divergence == current.first_divergence
382 && candidate.start < current.start)))
383}
384
385fn best_scored_candidate(
386 lines: &[&str],
387 pattern: &[&str],
388 candidates: HashSet<usize>,
389) -> Option<NearestMiss> {
390 candidates
391 .into_iter()
392 .filter(|start| *start < lines.len())
393 .map(|start| score_nearest_miss(lines, pattern, start))
394 .fold(None, |best, candidate| match best {
395 Some(current) if !is_better_nearest_miss(candidate, current) => Some(current),
396 _ => Some(candidate),
397 })
398}
399
400fn normalize_fuzzy_line(line: &str) -> String {
401 normalize_unicode(&normalize_reflow_whitespace(line))
402}
403
404fn normalized_prefix_score(normalized_wanted: &str, actual: &str) -> Option<usize> {
405 let actual = normalize_fuzzy_line(actual);
406 let wanted_len = normalized_wanted
407 .chars()
408 .take(NEAREST_MISS_MAX_FUZZY_CHARS)
409 .count();
410 if wanted_len < 4 {
411 return None;
412 }
413
414 let common = normalized_wanted
415 .chars()
416 .zip(actual.chars())
417 .take(NEAREST_MISS_MAX_FUZZY_CHARS)
418 .take_while(|(wanted_char, actual_char)| wanted_char == actual_char)
419 .count();
420 (common >= 4 && common * 2 >= wanted_len).then_some(common)
421}
422
423fn rarest_wanted_line<'a>(pattern: &'a [&'a str]) -> Option<(usize, &'a str)> {
424 let mut frequencies: HashMap<&str, usize> = HashMap::new();
425 for line in pattern
426 .iter()
427 .map(|line| line.trim())
428 .filter(|line| !line.is_empty())
429 {
430 *frequencies.entry(line).or_default() += 1;
431 }
432
433 pattern
434 .iter()
435 .enumerate()
436 .map(|(offset, line)| (offset, line.trim()))
437 .filter(|(_, line)| !line.is_empty())
438 .min_by_key(|(offset, line)| {
439 (
440 frequencies.get(line).copied().unwrap_or(usize::MAX),
441 std::cmp::Reverse(line.chars().count()),
442 *offset,
443 )
444 })
445}
446
447pub fn find_nearest_miss(
449 lines: &[&str],
450 pattern: &[&str],
451 file_size_bytes: usize,
452) -> NearestMissSearch {
453 if file_size_bytes > NEAREST_MISS_MAX_FILE_BYTES {
454 return NearestMissSearch::SkippedLargeFile;
455 }
456 if lines.is_empty() || pattern.is_empty() {
457 return NearestMissSearch::NoSimilarRegion;
458 }
459
460 let mut line_index: HashMap<&str, Vec<usize>> = HashMap::new();
461 for (position, line) in lines.iter().enumerate() {
462 let trimmed = line.trim();
463 if !trimmed.is_empty() {
464 line_index.entry(trimmed).or_default().push(position);
465 }
466 }
467
468 let candidate_limit = NEAREST_MISS_MAX_CANDIDATES
469 .min((NEAREST_MISS_MAX_LINE_COMPARISONS / pattern.len().max(1)).max(1));
470 let mut anchor_positions: Vec<(usize, &[usize])> = pattern
471 .iter()
472 .enumerate()
473 .filter(|(_, line)| !line.trim().is_empty())
474 .take(NEAREST_MISS_ANCHOR_COUNT)
475 .filter_map(|(offset, line)| {
476 line_index
477 .get(line.trim())
478 .map(|positions| (offset, positions.as_slice()))
479 })
480 .collect();
481 anchor_positions.sort_by_key(|(offset, positions)| (positions.len(), *offset));
482
483 let mut candidates = HashSet::new();
484 for (wanted_offset, positions) in anchor_positions {
485 add_sampled_candidates(&mut candidates, positions, wanted_offset, candidate_limit);
486 if candidates.len() >= candidate_limit {
487 break;
488 }
489 }
490 if let Some(best) = best_scored_candidate(lines, pattern, candidates) {
491 return NearestMissSearch::Found(best);
492 }
493
494 let Some((wanted_offset, wanted_line)) = rarest_wanted_line(pattern) else {
495 return NearestMissSearch::NoSimilarRegion;
496 };
497 let normalized_wanted = normalize_fuzzy_line(wanted_line);
498 let mut best_prefix = 0;
499 let mut fuzzy_candidates = HashSet::new();
500 for (file_position, actual_line) in lines.iter().enumerate() {
501 let Some(start) = file_position.checked_sub(wanted_offset) else {
502 continue;
503 };
504 let Some(prefix_score) = normalized_prefix_score(&normalized_wanted, actual_line) else {
505 continue;
506 };
507 if prefix_score > best_prefix {
508 best_prefix = prefix_score;
509 fuzzy_candidates.clear();
510 }
511 if prefix_score == best_prefix && fuzzy_candidates.len() < candidate_limit {
512 fuzzy_candidates.insert(start);
513 }
514 }
515
516 best_scored_candidate(lines, pattern, fuzzy_candidates)
517 .map_or(NearestMissSearch::NoSimilarRegion, NearestMissSearch::Found)
518}
519
520#[cfg(test)]
521mod tests {
522 use super::*;
523
524 fn assert_match(
525 actual: Option<SequenceMatch>,
526 found: usize,
527 tier: MatchTier,
528 line_count: usize,
529 ) {
530 assert_eq!(
531 actual,
532 Some(SequenceMatch {
533 found,
534 tier,
535 line_count,
536 })
537 );
538 }
539
540 #[test]
541 fn normalization_helpers_match_patch_parser_sources() {
542 assert_eq!(
543 normalize_unicode("‘’‚‛“”„‟‐‑‒–—―…\u{00A0}"),
544 "''''\"\"\"\"------... "
545 );
546 assert_eq!(normalize_indent("\t alpha\t beta "), " alpha\t beta ");
547 assert_eq!(normalize_indent(""), "");
548 assert_eq!(
549 normalize_reflow_whitespace(" \talpha\n\u{00A0} beta "),
550 "alpha beta"
551 );
552 assert_eq!(
553 strip_reflow_whitespace(" \talpha\n\u{00A0} beta "),
554 "alphabeta"
555 );
556 assert!(has_reflow_content("\u{00A0}x"));
557 assert!(!has_reflow_content(" \t\n"));
558 }
559
560 #[test]
561 fn exact_tier_wins_without_upgrading_to_later_tiers() {
562 assert_match(
563 seek_sequence_tiered(&["alpha", "beta"], &["beta"], 0, false),
564 1,
565 MatchTier::Exact,
566 1,
567 );
568 }
569
570 #[test]
571 fn rstrip_tier_wins_before_trim() {
572 assert_match(
573 seek_sequence_tiered(&["alpha "], &["alpha"], 0, false),
574 0,
575 MatchTier::Rstrip,
576 1,
577 );
578 }
579
580 #[test]
581 fn trim_tier_wins_before_indent_and_unicode() {
582 assert_match(
583 seek_sequence_tiered(&[" alpha "], &["alpha"], 0, false),
584 0,
585 MatchTier::Trim,
586 1,
587 );
588 }
589
590 #[test]
591 fn indent_normalization_matches_tab_space_drift_but_trim_shadows_the_tier() {
592 assert_eq!(normalize_indent("\treturn 42;"), " return 42;");
593 assert_eq!(normalize_indent(" return 42;"), " return 42;");
594 assert_eq!(
595 try_match(
596 &["\treturn 42;"],
597 &[" return 42;"],
598 0,
599 |a, b| normalize_indent(a).trim_end() == normalize_indent(b).trim_end(),
600 false,
601 ),
602 Some(0)
603 );
604 assert_match(
607 seek_sequence_tiered(&["\treturn 42;"], &[" return 42;"], 0, false),
608 0,
609 MatchTier::Trim,
610 1,
611 );
612 }
613
614 #[test]
615 fn unicode_tier_normalizes_smart_punctuation_after_stricter_tiers_fail() {
616 assert_match(
617 seek_sequence_tiered(
618 &["const label = “alpha”—beta…;"],
619 &["const label = \"alpha\"-beta...;"],
620 0,
621 false,
622 ),
623 0,
624 MatchTier::Unicode,
625 1,
626 );
627 }
628
629 #[test]
630 fn reflow_tier_matches_one_line_hunk_against_three_line_formatter_split() {
631 let lines = [
632 "function demo() {",
633 " const value = alpha +",
634 " beta +",
635 " gamma;",
636 " return value;",
637 "}",
638 ];
639 let pattern = [" const value = alpha + beta + gamma;"];
640
641 assert_match(
642 seek_sequence_tiered(&lines, &pattern, 0, false),
643 1,
644 MatchTier::Reflow,
645 3,
646 );
647 }
648
649 #[test]
650 fn rejects_ambiguous_reflow_matches_instead_of_choosing_a_window() {
651 let lines = [
653 "const value = alpha +",
654 " beta +",
655 " gamma;",
656 "",
657 "const value = alpha +",
658 " beta +",
659 " gamma;",
660 ];
661 let pattern = ["const value = alpha + beta + gamma;"];
662
663 assert_eq!(find_reflow_match(&lines, &pattern, 0), None);
664 assert_eq!(seek_sequence_tiered(&lines, &pattern, 0, false), None);
665 }
666
667 #[test]
668 fn uses_line_contiguous_match_before_considering_reflow_candidate() {
669 let lines = [
671 "const value = alpha +",
672 " beta +",
673 " gamma;",
674 "const value = alpha + beta + gamma;",
675 ];
676 let pattern = ["const value = alpha + beta + gamma;"];
677
678 assert_match(
679 seek_sequence_tiered(&lines, &pattern, 0, false),
680 3,
681 MatchTier::Exact,
682 1,
683 );
684 }
685
686 #[test]
687 fn eof_hunk_only_matches_the_tail_and_never_forward_scans() {
688 let pattern = ["marker", "old"];
690
691 assert_match(
692 seek_sequence_tiered(
693 &["header", "marker", "old", "middle", "marker", "old"],
694 &pattern,
695 0,
696 true,
697 ),
698 4,
699 MatchTier::Exact,
700 2,
701 );
702 assert_eq!(
703 seek_sequence_tiered(
704 &["header", "marker", "old", "middle", "marker", "changed"],
705 &pattern,
706 0,
707 true,
708 ),
709 None
710 );
711 }
712
713 #[test]
714 fn eof_hunk_skips_reflow_even_when_the_tail_would_reflow_match() {
715 let lines = ["header", "const value = alpha +", " beta +", " gamma;"];
716 let pattern = ["const value = alpha + beta + gamma;"];
717
718 assert_eq!(find_reflow_match(&lines, &pattern, 0), Some((1, 3)));
719 assert_eq!(seek_sequence_tiered(&lines, &pattern, 0, true), None);
720 }
721
722 #[test]
723 fn try_match_honors_start_index_for_forward_scans_and_eof_anchor() {
724 assert_eq!(
725 try_match(&["a", "b", "a", "b"], &["a", "b"], 1, |a, b| a == b, false),
726 Some(2)
727 );
728 assert_eq!(
729 try_match(&["a", "b", "a", "b"], &["a", "b"], 3, |a, b| a == b, false),
730 None
731 );
732 assert_eq!(
733 try_match(&["a", "b", "a", "b"], &["a", "b"], 3, |a, b| a == b, true),
734 None
735 );
736 }
737
738 #[test]
739 fn nearest_miss_scores_matching_lines_across_the_candidate_window() {
740 let lines = [
741 "header",
742 " const first = 1;",
743 " const actual = 2;",
744 " return first;",
745 "separator",
746 " const first = 1;",
747 " unrelated",
748 " unrelated",
749 ];
750 let pattern = [
751 " const first = 1;",
752 " const expected = 2;",
753 " return first;",
754 ];
755
756 assert_eq!(
757 find_nearest_miss(&lines, &pattern, 128),
758 NearestMissSearch::Found(NearestMiss {
759 start: 1,
760 end: 4,
761 matched_lines: 2,
762 first_divergence: 1,
763 })
764 );
765 }
766
767 #[test]
768 fn nearest_miss_uses_a_strong_prefix_when_no_anchor_matches_exactly() {
769 assert_eq!(
770 find_nearest_miss(
771 &["header", "const expected_value = 2;", "footer"],
772 &["const expected_value = 1;"],
773 42,
774 ),
775 NearestMissSearch::Found(NearestMiss {
776 start: 1,
777 end: 2,
778 matched_lines: 0,
779 first_divergence: 0,
780 })
781 );
782 }
783
784 #[test]
785 fn nearest_miss_reports_no_region_when_anchors_and_prefixes_are_absent() {
786 assert_eq!(
787 find_nearest_miss(
788 &["alpha", "beta", "gamma"],
789 &["completely unrelated line"],
790 17,
791 ),
792 NearestMissSearch::NoSimilarRegion
793 );
794 }
795
796 #[test]
797 fn nearest_miss_skips_files_larger_than_the_diagnostic_limit() {
798 let synthetic_file = "x".repeat(NEAREST_MISS_MAX_FILE_BYTES + 1);
799 assert_eq!(
800 find_nearest_miss(
801 &[synthetic_file.as_str()],
802 &["wanted content"],
803 synthetic_file.len(),
804 ),
805 NearestMissSearch::SkippedLargeFile
806 );
807 }
808}