M1: edit tool with opencode's replacer chain, ported verbatim

Ports opencode's multi-strategy string-replacer chain (and its test table) into harness-tools::edit, giving the edit tool the same fuzzy-match fallback behavior opencode relies on.
This commit is contained in:
2026-07-08 17:19:43 +02:00
parent a5af873924
commit 4118279da1
3 changed files with 1167 additions and 1 deletions
+467
View File
@@ -0,0 +1,467 @@
mod replacers;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex as StdMutex};
use async_trait::async_trait;
use harness_core::tool::{Tool, ToolCtx, ToolError, ToolOutput};
use schemars::JsonSchema;
use serde::Deserialize;
use similar::{ChangeTag, TextDiff};
use tokio::sync::Mutex as AsyncMutex;
use crate::paths;
#[derive(Debug, Deserialize, JsonSchema)]
struct EditParams {
file_path: String,
old_string: String,
new_string: String,
replace_all: Option<bool>,
}
/// Per-file `tokio::Mutex` lock map so concurrent edits to different files proceed in
/// parallel while edits to the *same* file serialize (docs/05-tools.md).
pub struct EditTool {
locks: StdMutex<HashMap<PathBuf, Arc<AsyncMutex<()>>>>,
}
impl EditTool {
pub fn new() -> Self {
Self {
locks: StdMutex::new(HashMap::new()),
}
}
fn lock_for(&self, path: &Path) -> Arc<AsyncMutex<()>> {
self.locks
.lock()
.unwrap()
.entry(path.to_path_buf())
.or_insert_with(|| Arc::new(AsyncMutex::new(())))
.clone()
}
}
impl Default for EditTool {
fn default() -> Self {
Self::new()
}
}
fn normalize_line_endings(text: &str) -> String {
text.replace("\r\n", "\n")
}
fn detect_line_ending(text: &str) -> &'static str {
if text.contains("\r\n") {
"\r\n"
} else {
"\n"
}
}
fn convert_line_ending(text: &str, ending: &str) -> String {
if ending == "\n" {
text.to_string()
} else {
text.replace('\n', "\r\n")
}
}
const UTF8_BOM: [u8; 3] = [0xEF, 0xBB, 0xBF];
fn strip_bom(bytes: &[u8]) -> (bool, &[u8]) {
if bytes.starts_with(&UTF8_BOM) {
(true, &bytes[3..])
} else {
(false, bytes)
}
}
fn unified_diff(before: &str, after: &str, file_path: &str) -> String {
TextDiff::from_lines(before, after)
.unified_diff()
.header(&format!("a/{file_path}"), &format!("b/{file_path}"))
.to_string()
}
fn diff_stats(before: &str, after: &str) -> (usize, usize) {
let diff = TextDiff::from_lines(before, after);
let mut added = 0;
let mut removed = 0;
for change in diff.iter_all_changes() {
match change.tag() {
ChangeTag::Insert => added += 1,
ChangeTag::Delete => removed += 1,
ChangeTag::Equal => {}
}
}
(added, removed)
}
#[async_trait]
impl Tool for EditTool {
fn name(&self) -> &str {
"edit"
}
fn description(&self) -> &str {
"Replaces an exact span of text in a file (fuzzy-matched against whitespace/indentation drift)."
}
fn parameters(&self) -> serde_json::Value {
serde_json::to_value(schemars::schema_for!(EditParams)).unwrap()
}
async fn execute(
&self,
input: serde_json::Value,
ctx: ToolCtx,
) -> Result<ToolOutput, ToolError> {
let params: EditParams =
serde_json::from_value(input).map_err(|e| ToolError::Invalid(e.to_string()))?;
if params.old_string == params.new_string {
return Err(ToolError::Invalid(
"No changes to apply: oldString and newString are identical.".into(),
));
}
let path = paths::resolve(&ctx.cwd, &params.file_path);
let pattern = paths::relative_pattern(&ctx.cwd, &path);
let lock = self.lock_for(&path);
let _guard = lock.lock().await;
let metadata = tokio::fs::metadata(&path).await.ok();
if metadata.as_ref().is_some_and(|m| m.is_dir()) {
return Err(ToolError::Invalid(format!(
"Path is a directory, not a file: {}",
path.display()
)));
}
let (content_old, content_new, had_bom) = if params.old_string.is_empty() {
if metadata.is_some() {
return Err(ToolError::Invalid(
"oldString cannot be empty when editing an existing file. Provide the exact text to replace, or use write for an intentional full-file replacement.".into(),
));
}
(String::new(), params.new_string.clone(), false)
} else {
if metadata.is_none() {
return Err(ToolError::Invalid(format!(
"File {} not found",
path.display()
)));
}
let bytes = tokio::fs::read(&path)
.await
.map_err(|e| ToolError::Other(format!("{}: {e}", path.display())))?;
let (had_bom, stripped) = strip_bom(&bytes);
let text = String::from_utf8_lossy(stripped).into_owned();
let ending = detect_line_ending(&text);
let old = convert_line_ending(&normalize_line_endings(&params.old_string), ending);
let new_ = convert_line_ending(&normalize_line_endings(&params.new_string), ending);
let replaced =
replacers::replace(&text, &old, &new_, params.replace_all.unwrap_or(false))
.map_err(|e| ToolError::Invalid(e.to_string()))?;
(text, replaced, had_bom)
};
let diff = unified_diff(&content_old, &content_new, &params.file_path);
ctx.ask
.ask(
"edit",
pattern.clone(),
"*",
serde_json::json!({"file_path": params.file_path, "diff": diff}),
)
.await?;
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| ToolError::Other(format!("{}: {e}", parent.display())))?;
}
let mut out_bytes = Vec::with_capacity(content_new.len() + 3);
if had_bom {
out_bytes.extend_from_slice(&UTF8_BOM);
}
out_bytes.extend_from_slice(content_new.as_bytes());
tokio::fs::write(&path, &out_bytes)
.await
.map_err(|e| ToolError::Other(format!("{}: {e}", path.display())))?;
let (added, removed) = diff_stats(&content_old, &content_new);
let mut output = ToolOutput::new(pattern, "Edit applied successfully.".to_string());
output.metadata = serde_json::json!({"diff": diff, "added": added, "removed": removed});
Ok(output)
}
}
#[cfg(test)]
mod tests {
use super::*;
use harness_core::event::EventBus;
use harness_core::permission::{spawn_auto_approve, PermissionService};
use harness_core::tool::{MetadataSink, PermissionHandle};
use harness_core::types::SessionId;
use std::sync::Mutex;
use tokio_util::sync::CancellationToken;
fn ctx(cwd: PathBuf) -> ToolCtx {
let bus = EventBus::new();
let service = Arc::new(PermissionService::new(bus.clone()));
spawn_auto_approve(bus, service.clone());
let (metadata, _rx) = MetadataSink::channel();
ToolCtx {
session_id: SessionId::new(),
message_id: harness_core::types::MessageId::new(),
call_id: "call_1".into(),
data_dir: cwd.join("tool-output"),
cwd,
cancel: CancellationToken::new(),
ask: PermissionHandle::new(
service,
SessionId::new(),
Vec::new(),
Arc::new(Mutex::new(Vec::new())),
CancellationToken::new(),
),
metadata,
}
}
#[tokio::test]
async fn creates_new_file_when_old_string_is_empty() {
let dir = tempfile::tempdir().unwrap();
let result = EditTool::new()
.execute(
serde_json::json!({"file_path": "newfile.txt", "old_string": "", "new_string": "new content"}),
ctx(dir.path().to_path_buf()),
)
.await
.unwrap();
assert!(result.metadata["diff"]
.as_str()
.unwrap()
.contains("new content"));
assert_eq!(
std::fs::read_to_string(dir.path().join("newfile.txt")).unwrap(),
"new content"
);
}
#[tokio::test]
async fn rejects_empty_old_string_on_existing_file() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("existing.txt"), "original").unwrap();
let err = EditTool::new()
.execute(
serde_json::json!({"file_path": "existing.txt", "old_string": "", "new_string": "x"}),
ctx(dir.path().to_path_buf()),
)
.await
.unwrap_err();
assert!(
matches!(&err, ToolError::Invalid(msg) if msg.contains("oldString cannot be empty"))
);
assert_eq!(
std::fs::read_to_string(dir.path().join("existing.txt")).unwrap(),
"original"
);
}
#[tokio::test]
async fn creates_new_file_with_nested_directories() {
let dir = tempfile::tempdir().unwrap();
EditTool::new()
.execute(
serde_json::json!({"file_path": "nested/dir/file.txt", "old_string": "", "new_string": "nested file"}),
ctx(dir.path().to_path_buf()),
)
.await
.unwrap();
assert_eq!(
std::fs::read_to_string(dir.path().join("nested/dir/file.txt")).unwrap(),
"nested file"
);
}
#[tokio::test]
async fn replaces_text_in_existing_file() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("existing.txt"), "old content here").unwrap();
let result = EditTool::new()
.execute(
serde_json::json!({"file_path": "existing.txt", "old_string": "old content", "new_string": "new content"}),
ctx(dir.path().to_path_buf()),
)
.await
.unwrap();
assert!(result.output.contains("Edit applied successfully"));
assert_eq!(
std::fs::read_to_string(dir.path().join("existing.txt")).unwrap(),
"new content here"
);
}
#[tokio::test]
async fn preserves_bom_and_only_edits_visible_content() {
let dir = tempfile::tempdir().unwrap();
let mut original = UTF8_BOM.to_vec();
original.extend_from_slice(b"using System;\nclass Test {}\n");
std::fs::write(dir.path().join("existing.cs"), &original).unwrap();
let result = EditTool::new()
.execute(
serde_json::json!({"file_path": "existing.cs", "old_string": "using System;", "new_string": "using Up;"}),
ctx(dir.path().to_path_buf()),
)
.await
.unwrap();
assert!(result.metadata["diff"]
.as_str()
.unwrap()
.contains("-using System;"));
assert!(result.metadata["diff"]
.as_str()
.unwrap()
.contains("+using Up;"));
let content = std::fs::read(dir.path().join("existing.cs")).unwrap();
assert_eq!(&content[..3], &UTF8_BOM);
assert_eq!(&content[3..], b"using Up;\nclass Test {}\n");
}
#[tokio::test]
async fn errors_when_file_does_not_exist() {
let dir = tempfile::tempdir().unwrap();
let err = EditTool::new()
.execute(
serde_json::json!({"file_path": "nonexistent.txt", "old_string": "old", "new_string": "new"}),
ctx(dir.path().to_path_buf()),
)
.await
.unwrap_err();
assert!(matches!(&err, ToolError::Invalid(msg) if msg.contains("not found")));
}
#[tokio::test]
async fn errors_when_old_equals_new() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("file.txt"), "content").unwrap();
let err = EditTool::new()
.execute(
serde_json::json!({"file_path": "file.txt", "old_string": "same", "new_string": "same"}),
ctx(dir.path().to_path_buf()),
)
.await
.unwrap_err();
assert!(matches!(&err, ToolError::Invalid(msg) if msg.contains("identical")));
}
#[tokio::test]
async fn errors_when_path_is_a_directory() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("adir")).unwrap();
let err = EditTool::new()
.execute(
serde_json::json!({"file_path": "adir", "old_string": "old", "new_string": "new"}),
ctx(dir.path().to_path_buf()),
)
.await
.unwrap_err();
assert!(matches!(&err, ToolError::Invalid(msg) if msg.contains("directory")));
}
#[tokio::test]
async fn replaces_all_occurrences_with_replace_all_option() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("file.txt"), "foo bar foo baz foo").unwrap();
EditTool::new()
.execute(
serde_json::json!({"file_path": "file.txt", "old_string": "foo", "new_string": "qux", "replace_all": true}),
ctx(dir.path().to_path_buf()),
)
.await
.unwrap();
assert_eq!(
std::fs::read_to_string(dir.path().join("file.txt")).unwrap(),
"qux bar qux baz qux"
);
}
#[tokio::test]
async fn handles_crlf_line_endings() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("file.txt"), "line1\r\nold\r\nline3").unwrap();
EditTool::new()
.execute(
serde_json::json!({"file_path": "file.txt", "old_string": "old", "new_string": "new"}),
ctx(dir.path().to_path_buf()),
)
.await
.unwrap();
assert_eq!(
std::fs::read_to_string(dir.path().join("file.txt")).unwrap(),
"line1\r\nnew\r\nline3"
);
}
#[tokio::test]
async fn tracks_file_diff_statistics() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("file.txt"), "line1\nline2\nline3").unwrap();
let result = EditTool::new()
.execute(
serde_json::json!({"file_path": "file.txt", "old_string": "line2", "new_string": "new line a\nnew line b"}),
ctx(dir.path().to_path_buf()),
)
.await
.unwrap();
assert!(result.metadata["added"].as_u64().unwrap() > 0);
}
#[tokio::test]
async fn concurrent_edits_to_the_same_file_serialize_without_losing_either_change() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("file.txt"),
"top = 0\nmiddle = keep\nbottom = 0\n",
)
.unwrap();
let tool = Arc::new(EditTool::new());
let t1 = tool.clone();
let cwd1 = dir.path().to_path_buf();
let h1 = tokio::spawn(async move {
t1.execute(
serde_json::json!({"file_path": "file.txt", "old_string": "top = 0", "new_string": "top = 1"}),
ctx(cwd1),
)
.await
});
let t2 = tool.clone();
let cwd2 = dir.path().to_path_buf();
let h2 = tokio::spawn(async move {
t2.execute(
serde_json::json!({"file_path": "file.txt", "old_string": "bottom = 0", "new_string": "bottom = 2"}),
ctx(cwd2),
)
.await
});
h1.await.unwrap().unwrap();
h2.await.unwrap().unwrap();
assert_eq!(
std::fs::read_to_string(dir.path().join("file.txt")).unwrap(),
"top = 1\nmiddle = keep\nbottom = 2\n"
);
}
}
+696
View File
@@ -0,0 +1,696 @@
//! 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"
));
}
}
+4 -1
View File
@@ -1,4 +1,5 @@
mod bash;
mod edit;
mod glob;
mod grep;
mod paths;
@@ -6,6 +7,7 @@ mod read;
mod write;
pub use bash::BashTool;
pub use edit::EditTool;
pub use glob::GlobTool;
pub use grep::GrepTool;
pub use read::ReadTool;
@@ -15,10 +17,11 @@ use std::sync::Arc;
use harness_core::tool::ToolRegistry;
/// Registers the M1 built-ins (`edit` is added separately once its replacer chain lands).
/// Registers all M1 built-ins: read, write, edit, bash, glob, grep.
pub fn register_builtins(registry: &mut ToolRegistry) {
registry.register(Arc::new(ReadTool));
registry.register(Arc::new(WriteTool));
registry.register(Arc::new(EditTool::new()));
registry.register(Arc::new(BashTool));
registry.register(Arc::new(GlobTool));
registry.register(Arc::new(GrepTool));