Files
ai-harness/crates/harness-tools/src/edit/replacers.rs
T
Erik Simon bfb2dca7de harness-tools: edit tool with opencode's replacer chain, ported verbatim
Direct port of ~/repos/opencode/packages/opencode/src/tool/edit.ts: the
9-stage replacer chain (Simple, LineTrimmed, BlockAnchor, WhitespaceNormalized,
IndentationFlexible, EscapeNormalized, TrimmedBoundary, ContextAware,
MultiOccurrence), Levenshtein-based block-anchor similarity, and the
disproportionate-match guard that hard-stops rather than falling through to
the next candidate. Also ports edit.test.ts's scenarios: new-file creation,
BOM preservation, CRLF handling, replaceAll, directory/not-found/identical
errors, loose block-anchor rejection, and concurrent edits to the same file
serializing through a per-path tokio::Mutex without losing either change.

99 tests passing across the workspace, clippy clean. This lands the last
of the six M1 built-in tools (read/write/edit/bash/glob/grep).
2026-07-08 17:19:43 +02:00

697 lines
23 KiB
Rust

//! Verbatim port of opencode's replacer chain (`packages/opencode/src/tool/edit.ts`).
//! Each replacer returns candidate matches in the same order the TS generator yields them;
//! `replace()` walks replacers in a fixed sequence and takes the first candidate that
//! resolves unambiguously (or throws — the disproportionate-match guard is a hard stop,
//! not a "try the next candidate").
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum EditError {
#[error("No changes to apply: oldString and newString are identical.")]
Identical,
#[error("oldString cannot be empty when editing an existing file. Provide the exact text to replace, or use write for an intentional full-file replacement.")]
EmptyOldString,
#[error("Could not find oldString in the file. It must match exactly, including whitespace, indentation, and line endings.")]
NotFound,
#[error("Found multiple matches for oldString. Provide more surrounding context to make the match unique.")]
MultipleMatches,
#[error("Refusing replacement because the matched span is much larger than oldString. Re-read the file and provide the full exact oldString for the intended replacement.")]
DisproportionateMatch,
}
const SINGLE_CANDIDATE_SIMILARITY_THRESHOLD: f64 = 0.65;
const MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD: f64 = 0.65;
fn levenshtein(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 a.len().max(b.len());
}
let mut matrix = vec![vec![0usize; b.len() + 1]; a.len() + 1];
for (i, row) in matrix.iter_mut().enumerate() {
row[0] = i;
}
for (j, cell) in matrix[0].iter_mut().enumerate() {
*cell = j;
}
for i in 1..=a.len() {
for j in 1..=b.len() {
let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
matrix[i][j] = (matrix[i - 1][j] + 1)
.min(matrix[i][j - 1] + 1)
.min(matrix[i - 1][j - 1] + cost);
}
}
matrix[a.len()][b.len()]
}
fn simple(_content: &str, find: &str) -> Vec<String> {
vec![find.to_string()]
}
fn line_trimmed(content: &str, find: &str) -> Vec<String> {
let original_lines: Vec<&str> = content.split('\n').collect();
let mut search_lines: Vec<&str> = find.split('\n').collect();
if search_lines.last() == Some(&"") {
search_lines.pop();
}
let mut out = Vec::new();
if search_lines.is_empty() || original_lines.len() < search_lines.len() {
return out;
}
for i in 0..=(original_lines.len() - search_lines.len()) {
let matches =
(0..search_lines.len()).all(|j| original_lines[i + j].trim() == search_lines[j].trim());
if matches {
let mut match_start = 0usize;
for line in &original_lines[..i] {
match_start += line.len() + 1;
}
let mut match_end = match_start;
for k in 0..search_lines.len() {
match_end += original_lines[i + k].len();
if k < search_lines.len() - 1 {
match_end += 1;
}
}
out.push(content[match_start..match_end].to_string());
}
}
out
}
fn extract_lines(original_lines: &[&str], content: &str, start: usize, end: usize) -> String {
let mut match_start = 0usize;
for line in &original_lines[..start] {
match_start += line.len() + 1;
}
let mut match_end = match_start;
for (k, line) in original_lines.iter().enumerate().take(end + 1).skip(start) {
match_end += line.len();
if k < end {
match_end += 1;
}
}
content[match_start..match_end].to_string()
}
fn block_anchor(content: &str, find: &str) -> Vec<String> {
let original_lines: Vec<&str> = content.split('\n').collect();
let mut search_lines: Vec<&str> = find.split('\n').collect();
if search_lines.len() < 3 {
return vec![];
}
if search_lines.last() == Some(&"") {
search_lines.pop();
}
let first_line_search = search_lines[0].trim();
let last_line_search = search_lines[search_lines.len() - 1].trim();
let search_block_size = search_lines.len();
let max_line_delta = ((search_block_size as f64 * 0.25).floor() as i64).max(1);
let mut candidates: Vec<(usize, usize)> = Vec::new();
for i in 0..original_lines.len() {
if original_lines[i].trim() != first_line_search {
continue;
}
let mut j = i + 2;
while j < original_lines.len() {
if original_lines[j].trim() == last_line_search {
let actual_block_size = (j - i + 1) as i64;
if (actual_block_size - search_block_size as i64).abs() <= max_line_delta {
candidates.push((i, j));
}
break;
}
j += 1;
}
}
if candidates.is_empty() {
return vec![];
}
let middle_similarity = |start: usize, end: usize, early_exit: bool| -> f64 {
let actual_block_size = end - start + 1;
let lines_to_check = (search_block_size as i64 - 2).min(actual_block_size as i64 - 2);
if lines_to_check <= 0 {
return 1.0;
}
let lines_to_check = lines_to_check as usize;
let upper = (search_block_size - 1).min(actual_block_size - 1);
let mut similarity = 0.0f64;
for j in 1..upper {
let original_line = original_lines[start + j].trim();
let search_line = search_lines[j].trim();
let max_len = original_line.len().max(search_line.len());
if max_len == 0 {
continue;
}
let distance = levenshtein(original_line, search_line);
similarity += (1.0 - distance as f64 / max_len as f64) / lines_to_check as f64;
if early_exit && similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD {
break;
}
}
similarity
};
if candidates.len() == 1 {
let (start, end) = candidates[0];
let similarity = middle_similarity(start, end, true);
if similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD {
return vec![extract_lines(&original_lines, content, start, end)];
}
return vec![];
}
let mut best: Option<(usize, usize)> = None;
let mut max_similarity = -1.0f64;
for &(start, end) in &candidates {
let actual_block_size = end - start + 1;
let lines_to_check = (search_block_size as i64 - 2).min(actual_block_size as i64 - 2);
let similarity = if lines_to_check <= 0 {
1.0
} else {
let upper = (search_block_size - 1).min(actual_block_size - 1);
let mut sum = 0.0;
for j in 1..upper {
let original_line = original_lines[start + j].trim();
let search_line = search_lines[j].trim();
let max_len = original_line.len().max(search_line.len());
if max_len == 0 {
continue;
}
sum += 1.0 - levenshtein(original_line, search_line) as f64 / max_len as f64;
}
sum / lines_to_check as f64
};
if similarity > max_similarity {
max_similarity = similarity;
best = Some((start, end));
}
}
if max_similarity >= MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD {
if let Some((start, end)) = best {
return vec![extract_lines(&original_lines, content, start, end)];
}
}
vec![]
}
fn normalize_whitespace(text: &str) -> String {
let mut out = String::new();
let mut in_ws = false;
for c in text.chars() {
if c.is_whitespace() {
if !in_ws {
out.push(' ');
in_ws = true;
}
} else {
out.push(c);
in_ws = false;
}
}
out.trim().to_string()
}
/// Finds `words` in `line` separated by runs of whitespace, returning the exact
/// (non-normalized) substring — a hand-rolled stand-in for the TS version's regex built
/// from escaped words joined by `\s+`.
fn find_flexible_whitespace_match(line: &str, words: &[&str]) -> Option<String> {
if words.is_empty() {
return None;
}
let chars: Vec<char> = line.chars().collect();
let n = chars.len();
'outer: for start in 0..n {
let mut pos = start;
for (wi, word) in words.iter().enumerate() {
let wchars: Vec<char> = word.chars().collect();
if pos + wchars.len() > n || chars[pos..pos + wchars.len()] != wchars[..] {
continue 'outer;
}
pos += wchars.len();
if wi < words.len() - 1 {
let ws_start = pos;
while pos < n && chars[pos].is_whitespace() {
pos += 1;
}
if pos == ws_start {
continue 'outer;
}
}
}
return Some(chars[start..pos].iter().collect());
}
None
}
fn whitespace_normalized(content: &str, find: &str) -> Vec<String> {
let mut out = Vec::new();
let normalized_find = normalize_whitespace(find);
let lines: Vec<&str> = content.split('\n').collect();
for line in &lines {
if normalize_whitespace(line) == normalized_find {
out.push(line.to_string());
} else if normalize_whitespace(line).contains(&normalized_find) {
let words: Vec<&str> = find.split_whitespace().collect();
if let Some(m) = find_flexible_whitespace_match(line, &words) {
out.push(m);
}
}
}
let find_lines: Vec<&str> = find.split('\n').collect();
if find_lines.len() > 1 && lines.len() >= find_lines.len() {
for i in 0..=(lines.len() - find_lines.len()) {
let block = lines[i..i + find_lines.len()].join("\n");
if normalize_whitespace(&block) == normalized_find {
out.push(block);
}
}
}
out
}
fn remove_indentation(text: &str) -> String {
let lines: Vec<&str> = text.split('\n').collect();
let non_empty: Vec<&&str> = lines.iter().filter(|l| !l.trim().is_empty()).collect();
if non_empty.is_empty() {
return text.to_string();
}
let min_indent = non_empty
.iter()
.map(|l| l.len() - l.trim_start().len())
.min()
.unwrap_or(0);
lines
.iter()
.map(|l| {
if l.trim().is_empty() {
l.to_string()
} else {
l[min_indent.min(l.len())..].to_string()
}
})
.collect::<Vec<_>>()
.join("\n")
}
fn indentation_flexible(content: &str, find: &str) -> Vec<String> {
let normalized_find = remove_indentation(find);
let content_lines: Vec<&str> = content.split('\n').collect();
let find_lines: Vec<&str> = find.split('\n').collect();
let mut out = Vec::new();
if content_lines.len() < find_lines.len() {
return out;
}
for i in 0..=(content_lines.len() - find_lines.len()) {
let block = content_lines[i..i + find_lines.len()].join("\n");
if remove_indentation(&block) == normalized_find {
out.push(block);
}
}
out
}
fn unescape_string(s: &str) -> String {
let mut out = String::new();
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.peek().copied() {
Some('n') => {
out.push('\n');
chars.next();
}
Some('t') => {
out.push('\t');
chars.next();
}
Some('r') => {
out.push('\r');
chars.next();
}
Some('\'') => {
out.push('\'');
chars.next();
}
Some('"') => {
out.push('"');
chars.next();
}
Some('`') => {
out.push('`');
chars.next();
}
Some('\\') => {
out.push('\\');
chars.next();
}
Some('\n') => {
out.push('\n');
chars.next();
}
Some('$') => {
out.push('$');
chars.next();
}
_ => out.push(c),
}
} else {
out.push(c);
}
}
out
}
fn escape_normalized(content: &str, find: &str) -> Vec<String> {
let mut out = Vec::new();
let unescaped_find = unescape_string(find);
if content.contains(&unescaped_find) {
out.push(unescaped_find.clone());
}
let lines: Vec<&str> = content.split('\n').collect();
let find_lines: Vec<&str> = unescaped_find.split('\n').collect();
if lines.len() >= find_lines.len() {
for i in 0..=(lines.len() - find_lines.len()) {
let block = lines[i..i + find_lines.len()].join("\n");
if unescape_string(&block) == unescaped_find {
out.push(block);
}
}
}
out
}
fn multi_occurrence(content: &str, find: &str) -> Vec<String> {
let mut out = Vec::new();
if find.is_empty() {
return out;
}
let mut start = 0usize;
while let Some(idx) = content[start..].find(find) {
out.push(find.to_string());
start += idx + find.len();
}
out
}
fn trimmed_boundary(content: &str, find: &str) -> Vec<String> {
let trimmed_find = find.trim();
let mut out = Vec::new();
if trimmed_find == find {
return out;
}
if content.contains(trimmed_find) {
out.push(trimmed_find.to_string());
}
let lines: Vec<&str> = content.split('\n').collect();
let find_lines: Vec<&str> = find.split('\n').collect();
if lines.len() >= find_lines.len() {
for i in 0..=(lines.len() - find_lines.len()) {
let block = lines[i..i + find_lines.len()].join("\n");
if block.trim() == trimmed_find {
out.push(block);
}
}
}
out
}
fn context_aware(content: &str, find: &str) -> Vec<String> {
let mut find_lines: Vec<&str> = find.split('\n').collect();
if find_lines.len() < 3 {
return vec![];
}
if find_lines.last() == Some(&"") {
find_lines.pop();
}
let content_lines: Vec<&str> = content.split('\n').collect();
let first_line = find_lines[0].trim();
let last_line = find_lines[find_lines.len() - 1].trim();
let mut out = Vec::new();
for i in 0..content_lines.len() {
if content_lines[i].trim() != first_line {
continue;
}
let mut j = i + 2;
while j < content_lines.len() {
if content_lines[j].trim() == last_line {
let block_lines = &content_lines[i..=j];
if block_lines.len() == find_lines.len() {
let mut matching = 0usize;
let mut total_non_empty = 0usize;
for k in 1..block_lines.len() - 1 {
let block_line = block_lines[k].trim();
let find_line = find_lines[k].trim();
if !block_line.is_empty() || !find_line.is_empty() {
total_non_empty += 1;
if block_line == find_line {
matching += 1;
}
}
}
if total_non_empty == 0 || (matching as f64 / total_non_empty as f64) >= 0.5 {
out.push(block_lines.join("\n"));
}
}
break;
}
j += 1;
}
}
out
}
type ReplacerFn = fn(&str, &str) -> Vec<String>;
const REPLACER_CHAIN: &[ReplacerFn] = &[
simple,
line_trimmed,
block_anchor,
whitespace_normalized,
indentation_flexible,
escape_normalized,
trimmed_boundary,
context_aware,
multi_occurrence,
];
fn is_disproportionate_match(search: &str, old_string: &str) -> bool {
let old_lines = old_string.split('\n').count() as i64;
let search_lines = search.split('\n').count() as i64;
if search_lines >= (old_lines + 3).max(old_lines * 2) {
return true;
}
if old_lines == 1 {
return false;
}
let old_trimmed_len = old_string.trim().len() as i64;
search.trim().len() as i64 > (old_trimmed_len + 500).max(old_trimmed_len * 4)
}
/// Applies the replacer chain in order, returning the new content on the first
/// unambiguous match. Mirrors opencode's `replace()` exactly, including the
/// disproportionate-match guard short-circuiting immediately (not skipping ahead).
pub fn replace(
content: &str,
old_string: &str,
new_string: &str,
replace_all: bool,
) -> Result<String, EditError> {
if old_string == new_string {
return Err(EditError::Identical);
}
if old_string.is_empty() {
return Err(EditError::EmptyOldString);
}
let mut not_found = true;
for replacer in REPLACER_CHAIN {
for search in replacer(content, old_string) {
let index = match content.find(&search) {
Some(i) => i,
None => continue,
};
not_found = false;
if is_disproportionate_match(&search, old_string) {
return Err(EditError::DisproportionateMatch);
}
if replace_all {
return Ok(content.replace(&search, new_string));
}
let last_index = content.rfind(&search).unwrap();
if index != last_index {
continue;
}
return Ok(format!(
"{}{}{}",
&content[..index],
new_string,
&content[index + search.len()..]
));
}
}
if not_found {
Err(EditError::NotFound)
} else {
Err(EditError::MultipleMatches)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn replaces_exact_text_in_existing_content() {
let result = replace("old content here", "old content", "new content", false).unwrap();
assert_eq!(result, "new content here");
}
#[test]
fn rejects_identical_old_and_new() {
assert_eq!(
replace("content", "same", "same", false),
Err(EditError::Identical)
);
}
#[test]
fn rejects_empty_old_string() {
assert_eq!(
replace("content", "", "x", false),
Err(EditError::EmptyOldString)
);
}
#[test]
fn reports_not_found() {
let err = replace("actual content", "not in file", "replacement", false).unwrap_err();
assert_eq!(err, EditError::NotFound);
}
#[test]
fn rejects_loose_block_anchor_matches() {
let original = [
"function configure() {",
" keepImportantState()",
" removeAllUserData()",
" archiveBackups()",
" auditLog()",
"}",
]
.join("\n");
let old = ["function configure() {", " const enabled = true", "}"].join("\n");
let new = ["function configure() {", " const enabled = false", "}"].join("\n");
assert_eq!(
replace(&original, &old, &new, false),
Err(EditError::NotFound)
);
}
#[test]
fn rejects_block_anchor_matches_with_unrelated_middle_content() {
let original = ["function configure() {", " removeAllUserData()", "}"].join("\n");
let old = ["function configure() {", " const enabled = true", "}"].join("\n");
let new = ["function configure() {", " const enabled = false", "}"].join("\n");
assert_eq!(
replace(&original, &old, &new, false),
Err(EditError::NotFound)
);
}
#[test]
fn replaces_all_occurrences_with_replace_all() {
let result = replace("foo bar foo baz foo", "foo", "qux", true).unwrap();
assert_eq!(result, "qux bar qux baz qux");
}
#[test]
fn rejects_ambiguous_single_occurrence_request() {
let err = replace("foo bar foo", "foo", "qux", false).unwrap_err();
assert_eq!(err, EditError::MultipleMatches);
}
#[test]
fn handles_multiline_replacements() {
let result = replace(
"line1\nline2\nline3",
"line2",
"new line 2\nextra line",
false,
)
.unwrap();
assert_eq!(result, "line1\nnew line 2\nextra line\nline3");
}
#[test]
fn line_trimmed_replacer_matches_despite_surrounding_whitespace_changes() {
let content = " line one \n line two \n";
let matches = line_trimmed(content, "line one\nline two");
assert_eq!(matches, vec![" line one \n line two ".to_string()]);
}
#[test]
fn whitespace_normalized_matches_reformatted_line() {
let content = "const x = 1;\n";
let matches = whitespace_normalized(content, "const x = 1;");
assert!(matches.iter().any(|m| m == "const x = 1;"));
}
#[test]
fn indentation_flexible_matches_reindented_block() {
let content = " if true {\n foo()\n }\n";
let find = "if true {\n foo()\n}";
let matches = indentation_flexible(content, find);
assert_eq!(matches.len(), 1);
}
#[test]
fn escape_normalized_matches_escaped_newline_in_find() {
let content = "line one\nline two\n";
let matches = escape_normalized(content, "line one\\nline two");
assert!(matches.contains(&"line one\nline two".to_string()));
}
#[test]
fn trimmed_boundary_matches_padded_find() {
let content = "exact text here\n";
let matches = trimmed_boundary(content, " exact text here ");
assert!(matches.contains(&"exact text here".to_string()));
}
#[test]
fn disproportionate_match_flags_far_larger_line_count() {
let old = "one line";
let search = "a\nb\nc\nd"; // 4 lines >= max(1+3, 1*2) = 4
assert!(is_disproportionate_match(search, old));
}
#[test]
fn disproportionate_match_flags_far_larger_char_span_same_line_count() {
let old = "short";
let search = "x".repeat(600); // > max(5+500, 5*4) = 505, still 1 line like `old`
assert!(!is_disproportionate_match(&search, old)); // old_lines == 1 short-circuits to false
let old_multiline = "short\nline";
let search_multiline = format!("{}\nline", "x".repeat(600));
assert!(is_disproportionate_match(&search_multiline, old_multiline));
}
#[test]
fn disproportionate_match_allows_reasonably_sized_matches() {
assert!(!is_disproportionate_match(
"old content",
"old content here"
));
}
}