opendev_tools_impl/edit_replacers/mod.rs
1//! 9-pass fuzzy matching chain for the edit tool.
2//!
3//! LLMs frequently produce slightly different whitespace, indentation, or escaping
4//! in `old_content`. This module implements a chain of increasingly flexible
5//! matching strategies, tried in order until one succeeds.
6//!
7//! Pass order (strictest to most flexible):
8//! 1. Simple — exact string match
9//! 2. LineTrimmed — trim leading/trailing whitespace per line
10//! 3. BlockAnchor — match by first/last lines as anchors, similarity for middle
11//! 4. WhitespaceNormalized — collapse all whitespace to single space
12//! 5. IndentationFlexible — strip indentation, match stripped content
13//! 6. EscapeNormalized — normalize escape sequences
14//! 7. TrimmedBoundary — trim first/last lines of old_content
15//! 8. ContextAware — use surrounding context lines to locate position
16//! 9. MultiOccurrence — trimmed line-by-line match as last resort
17
18mod diff;
19mod passes;
20
21pub use diff::unified_diff;
22
23use passes::*;
24
25/// Result of a successful fuzzy match: the actual substring found in the original.
26#[derive(Debug, Clone)]
27pub struct MatchResult {
28 /// The actual content from the original file that matched.
29 pub actual: String,
30 /// Which replacer pass found the match (for logging).
31 pub pass_name: &'static str,
32}
33
34/// Normalize line endings to `\n`.
35pub fn normalize_line_endings(s: &str) -> String {
36 s.replace("\r\n", "\n").replace('\r', "\n")
37}
38
39/// Run the 9-pass replacer chain. Returns the actual substring in `original`
40/// that matches `old_content`, or `None` if no pass succeeds.
41pub fn find_match(original: &str, old_content: &str) -> Option<MatchResult> {
42 let original = normalize_line_endings(original);
43 let old_content = normalize_line_endings(old_content);
44
45 #[allow(clippy::type_complexity)]
46 let passes: &[(&str, fn(&str, &str) -> Option<String>)] = &[
47 ("simple", simple_find),
48 ("line_trimmed", line_trimmed_find),
49 ("block_anchor", block_anchor_find),
50 ("whitespace_normalized", whitespace_normalized_find),
51 ("indentation_flexible", indentation_flexible_find),
52 ("escape_normalized", escape_normalized_find),
53 ("trimmed_boundary", trimmed_boundary_find),
54 ("context_aware", context_aware_find),
55 ("multi_occurrence", multi_occurrence_find),
56 ];
57
58 for &(name, finder) in passes {
59 if let Some(actual) = finder(&original, &old_content) {
60 return Some(MatchResult {
61 actual,
62 pass_name: name,
63 });
64 }
65 }
66
67 None
68}
69
70/// Find line numbers (1-indexed) of all occurrences of `needle` in `haystack`.
71pub fn find_occurrence_positions(haystack: &str, needle: &str) -> Vec<usize> {
72 let mut positions = Vec::new();
73 let mut search_pos = 0;
74 while let Some(slice) = haystack.get(search_pos..) {
75 if let Some(pos) = slice.find(needle) {
76 let abs_pos = search_pos + pos;
77 let line_num = haystack[..abs_pos].matches('\n').count() + 1;
78 positions.push(line_num);
79 search_pos = abs_pos + 1;
80 // Snap to next valid UTF-8 char boundary
81 while search_pos < haystack.len() && !haystack.is_char_boundary(search_pos) {
82 search_pos += 1;
83 }
84 } else {
85 break;
86 }
87 }
88 positions
89}
90
91// ===========================================================================
92// Tests
93// ===========================================================================
94
95#[cfg(test)]
96mod tests;