Skip to main content

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;