type Match = (usize, usize);
fn unicode_normalize(s: &str) -> String {
s.replace(['\u{201C}', '\u{201D}'], "\"") .replace(['\u{2018}', '\u{2019}'], "'") .replace('\u{2014}', "--") .replace('\u{2013}', "-") .replace('\u{2026}', "...") .replace('\u{00A0}', " ") }
fn similarity_ratio(a: &str, b: &str) -> f64 {
if a == b {
return 1.0;
}
let total = a.chars().count() + b.chars().count();
if total == 0 {
return 1.0;
}
let lcs = lcs_char_len(a, b);
2.0 * lcs as f64 / total as f64
}
fn lcs_char_len(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
if a.is_empty() || b.is_empty() {
return 0;
}
let mut prev = vec![0usize; b.len() + 1];
for &ca in &a {
let mut curr = vec![0usize; b.len() + 1];
for (j, &cb) in b.iter().enumerate() {
curr[j + 1] = if ca == cb {
prev[j] + 1
} else {
curr[j].max(prev[j + 1])
};
}
prev = curr;
}
prev[b.len()]
}
fn line_range_to_bytes(content_lines: &[&str], start_line: usize, end_line: usize) -> Match {
let start_pos: usize = content_lines[..start_line]
.iter()
.map(|l| l.len() + 1)
.sum();
let end_pos: usize = content_lines[..end_line]
.iter()
.map(|l| l.len() + 1)
.sum::<usize>()
.saturating_sub(1);
(start_pos, end_pos)
}
fn find_line_matches(
content_lines: &[&str],
content_norm_lines: &[String],
pattern_norm_lines: &[String],
) -> Vec<Match> {
let pc = pattern_norm_lines.len();
if pc == 0 || pc > content_norm_lines.len() {
return Vec::new();
}
let mut matches = Vec::new();
let norm_pat: Vec<&str> = pattern_norm_lines.iter().map(|s| s.as_str()).collect();
for i in 0..=(content_norm_lines.len() - pc) {
let window: Vec<&str> = content_norm_lines[i..i + pc]
.iter()
.map(|s| s.as_str())
.collect();
if window == norm_pat {
matches.push(line_range_to_bytes(content_lines, i, i + pc));
}
}
matches
}
fn apply_replacements(content: &str, matches: &[Match], new_string: &str) -> String {
let mut sorted: Vec<Match> = matches.to_vec();
sorted.sort_by_key(|item| std::cmp::Reverse(item.0));
let mut result = content.to_string();
for (start, end) in sorted {
let end = end.min(result.len());
result.replace_range(start..end, new_string);
}
result
}
fn strategy_exact(content: &str, old: &str) -> Vec<Match> {
let mut matches = Vec::new();
let mut start = 0;
while let Some(rel) = content[start..].find(old) {
let pos = start + rel;
matches.push((pos, pos + old.len()));
start = pos + 1;
}
matches
}
fn strategy_line_trimmed(content: &str, old: &str) -> Vec<Match> {
let content_lines: Vec<&str> = content.split('\n').collect();
let norm_content: Vec<String> = content_lines.iter().map(|l| l.trim().to_string()).collect();
let norm_pattern: Vec<String> = old.split('\n').map(|l| l.trim().to_string()).collect();
find_line_matches(&content_lines, &norm_content, &norm_pattern)
}
fn collapse_spaces(line: &str) -> String {
let mut result = String::with_capacity(line.len());
let mut prev_space = false;
for c in line.chars() {
if c == ' ' || c == '\t' {
if !prev_space {
result.push(' ');
}
prev_space = true;
} else {
result.push(c);
prev_space = false;
}
}
result
}
fn strategy_whitespace_normalized(content: &str, old: &str) -> Vec<Match> {
let content_lines: Vec<&str> = content.split('\n').collect();
let norm_content: Vec<String> = content_lines.iter().map(|l| collapse_spaces(l)).collect();
let norm_pattern: Vec<String> = old.split('\n').map(collapse_spaces).collect();
find_line_matches(&content_lines, &norm_content, &norm_pattern)
}
fn strategy_indentation_flexible(content: &str, old: &str) -> Vec<Match> {
let content_lines: Vec<&str> = content.split('\n').collect();
let norm_content: Vec<String> = content_lines
.iter()
.map(|l| l.trim_start().to_string())
.collect();
let norm_pattern: Vec<String> = old
.split('\n')
.map(|l| l.trim_start().to_string())
.collect();
find_line_matches(&content_lines, &norm_content, &norm_pattern)
}
fn strategy_escape_normalized(content: &str, old: &str) -> Vec<Match> {
let unescaped = old
.replace("\\n", "\n")
.replace("\\t", "\t")
.replace("\\r", "\r");
if unescaped == old {
return Vec::new(); }
strategy_exact(content, &unescaped)
}
fn strategy_trimmed_boundary(content: &str, old: &str) -> Vec<Match> {
let old_lines: Vec<&str> = old.split('\n').collect();
if old_lines.is_empty() {
return Vec::new();
}
let mut norm_pattern: Vec<String> = old_lines.iter().map(|l| l.to_string()).collect();
norm_pattern[0] = norm_pattern[0].trim().to_string();
let last = norm_pattern.len() - 1;
if last > 0 {
norm_pattern[last] = norm_pattern[last].trim().to_string();
}
let content_lines: Vec<&str> = content.split('\n').collect();
let pc = norm_pattern.len();
let mut matches = Vec::new();
for i in 0..=content_lines.len().saturating_sub(pc) {
let mut block: Vec<String> = content_lines[i..i + pc]
.iter()
.map(|l| l.to_string())
.collect();
block[0] = block[0].trim().to_string();
let last_b = block.len() - 1;
if last_b > 0 {
block[last_b] = block[last_b].trim().to_string();
}
if block == norm_pattern {
matches.push(line_range_to_bytes(&content_lines, i, i + pc));
}
}
matches
}
fn strategy_block_anchor(content: &str, old: &str) -> Vec<Match> {
let old_norm = unicode_normalize(old);
let content_norm = unicode_normalize(content);
let pattern_lines: Vec<&str> = old_norm.split('\n').collect();
if pattern_lines.len() < 2 {
return Vec::new();
}
let first = pattern_lines[0].trim();
let last_line = pattern_lines[pattern_lines.len() - 1].trim();
let pc = pattern_lines.len();
let norm_content_lines: Vec<&str> = content_norm.split('\n').collect();
let orig_content_lines: Vec<&str> = content.split('\n').collect();
let potentials: Vec<usize> = (0..=norm_content_lines.len().saturating_sub(pc))
.filter(|&i| {
norm_content_lines[i].trim() == first
&& norm_content_lines[i + pc - 1].trim() == last_line
})
.collect();
let threshold = if potentials.len() == 1 { 0.10 } else { 0.30 };
let mut matches = Vec::new();
for i in potentials {
let similarity = if pc <= 2 {
1.0
} else {
let content_middle = norm_content_lines[i + 1..i + pc - 1].join("\n");
let pattern_middle = pattern_lines[1..pc - 1].join("\n");
similarity_ratio(&content_middle, &pattern_middle)
};
if similarity >= threshold {
matches.push(line_range_to_bytes(&orig_content_lines, i, i + pc));
}
}
matches
}
fn strategy_context_aware(content: &str, old: &str) -> Vec<Match> {
let pattern_lines: Vec<&str> = old.split('\n').collect();
let content_lines: Vec<&str> = content.split('\n').collect();
let pc = pattern_lines.len();
if pc == 0 || pc > content_lines.len() {
return Vec::new();
}
let mut matches = Vec::new();
for i in 0..=(content_lines.len() - pc) {
let high_sim = content_lines[i..i + pc]
.iter()
.zip(pattern_lines.iter())
.filter(|(cl, pl)| similarity_ratio(cl.trim(), pl.trim()) >= 0.80)
.count();
if high_sim * 2 >= pc {
matches.push(line_range_to_bytes(&content_lines, i, i + pc));
}
}
matches
}
pub fn fuzzy_find_and_replace(
content: &str,
old_string: &str,
new_string: &str,
replace_all: bool,
) -> Result<(String, usize), String> {
if old_string.is_empty() {
return Err("old_string cannot be empty".into());
}
if old_string == new_string {
return Err("old_string and new_string are identical — no change needed".into());
}
type MatchStrategy = fn(&str, &str) -> Vec<Match>;
let strategies: &[(&str, MatchStrategy)] = &[
("exact", strategy_exact),
("line_trimmed", strategy_line_trimmed),
("whitespace_normalized", strategy_whitespace_normalized),
("indentation_flexible", strategy_indentation_flexible),
("escape_normalized", strategy_escape_normalized),
("trimmed_boundary", strategy_trimmed_boundary),
("block_anchor", strategy_block_anchor),
("context_aware", strategy_context_aware),
];
for &(name, strategy) in strategies {
let matches = strategy(content, old_string);
if matches.is_empty() {
continue;
}
if matches.len() > 1 && !replace_all {
return Err(format!(
"Found {} occurrences of old_string (strategy: {}). \
Include more surrounding context to make the match unique, \
or set replace_all=true to replace all occurrences.",
matches.len(),
name
));
}
let count = matches.len();
let new_content = apply_replacements(content, &matches, new_string);
return Ok((new_content, count));
}
Err(
"Could not find old_string in the file after trying 8 matching strategies. \
Use read_file to verify the current content before retrying."
.into(),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exact_single_match() {
let (out, n) = fuzzy_find_and_replace("hello world", "world", "rust", false).expect("ok");
assert_eq!(out, "hello rust");
assert_eq!(n, 1);
}
#[test]
fn exact_replace_all() {
let (out, n) = fuzzy_find_and_replace("aa bb aa", "aa", "xx", true).expect("ok");
assert_eq!(out, "xx bb xx");
assert_eq!(n, 2);
}
#[test]
fn exact_multiple_without_replace_all_is_error() {
let err = fuzzy_find_and_replace("aa bb aa", "aa", "xx", false).unwrap_err();
assert!(err.contains("2 occurrences"), "got: {err}");
}
#[test]
fn identical_strings_error() {
let err = fuzzy_find_and_replace("content", "same", "same", false).unwrap_err();
assert!(err.contains("identical"));
}
#[test]
fn line_trimmed_trailing_spaces() {
let content = "fn foo() { \n let x = 1;\n}";
let old = "fn foo() {\n let x = 1;\n}";
let (out, _) = fuzzy_find_and_replace(content, old, "fn foo() { }", false).expect("ok");
assert!(out.contains("fn foo() { }"));
}
#[test]
fn whitespace_normalized_extra_spaces() {
let content = "let x = 1;";
let old = "let x = 1;";
let (out, _) = fuzzy_find_and_replace(content, old, "let x = 2;", false).expect("ok");
assert!(out.contains("let x = 2;"));
}
#[test]
fn indentation_flexible_different_indent() {
let content = "def foo():\n pass\n return 1";
let old = "def foo():\n pass\n return 1";
let (out, _) =
fuzzy_find_and_replace(content, old, "def foo():\n return 0", false).expect("ok");
assert!(out.contains("return 0"));
}
#[test]
fn escape_normalized_converts_backslash_n() {
let content = "line1\nline2";
let old = "line1\\nline2"; let (out, _) = fuzzy_find_and_replace(content, old, "replaced", false).expect("ok");
assert_eq!(out, "replaced");
}
#[test]
fn trimmed_boundary_first_last_line() {
let content = " fn foo() {\n body\n } ";
let old = "fn foo() {\n body\n}";
let (out, _) =
fuzzy_find_and_replace(content, old, "fn foo() { /* replaced */ }", false).expect("ok");
assert!(out.contains("replaced"), "got: {out}");
}
#[test]
fn block_anchor_unicode_smart_quotes() {
let content = "fn foo() {\n let x = \u{201C}hello\u{201D};\n}";
let old = "fn foo() {\n let x = \"hello\";\n}";
let (out, _) =
fuzzy_find_and_replace(content, old, "fn foo() { /* */ }", false).expect("ok");
assert!(
out.contains("replaced") || out.contains("/* */"),
"got: {out}"
);
}
#[test]
fn context_aware_minor_typo() {
let content = "def foo():\n x = compute()\n return x";
let old = "def foo():\n x = compute() \n return x"; let (out, _) =
fuzzy_find_and_replace(content, old, "def foo():\n return 0", false).expect("ok");
assert!(out.contains("return 0"));
}
#[test]
fn line_range_to_bytes_basic() {
let content = "abc\ndef\nghi";
let lines: Vec<&str> = content.split('\n').collect();
let (s, e) = line_range_to_bytes(&lines, 0, 3);
assert_eq!(&content[s..e], "abc\ndef\nghi");
let (s, e) = line_range_to_bytes(&lines, 0, 1);
assert_eq!(&content[s..e], "abc");
let (s, e) = line_range_to_bytes(&lines, 1, 2);
assert_eq!(&content[s..e], "def");
}
#[test]
fn similarity_identical() {
assert_eq!(similarity_ratio("abc", "abc"), 1.0);
}
#[test]
fn similarity_empty() {
assert_eq!(similarity_ratio("", ""), 1.0);
}
#[test]
fn similarity_partial() {
let r = similarity_ratio("hello", "hxllx");
assert!(r > 0.5 && r < 1.0, "ratio={r}");
}
}