From bfb2dca7deca6c747eaeb8b4bb42d1c71e0b2057 Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Wed, 8 Jul 2026 17:19:43 +0200 Subject: [PATCH] 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). --- crates/harness-tools/src/edit/mod.rs | 467 ++++++++++++++ crates/harness-tools/src/edit/replacers.rs | 696 +++++++++++++++++++++ crates/harness-tools/src/lib.rs | 5 +- 3 files changed, 1167 insertions(+), 1 deletion(-) create mode 100644 crates/harness-tools/src/edit/mod.rs create mode 100644 crates/harness-tools/src/edit/replacers.rs diff --git a/crates/harness-tools/src/edit/mod.rs b/crates/harness-tools/src/edit/mod.rs new file mode 100644 index 0000000..5e53fb8 --- /dev/null +++ b/crates/harness-tools/src/edit/mod.rs @@ -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, +} + +/// 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>>>, +} + +impl EditTool { + pub fn new() -> Self { + Self { + locks: StdMutex::new(HashMap::new()), + } + } + + fn lock_for(&self, path: &Path) -> Arc> { + 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 { + 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, ¶ms.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(¶ms.old_string), ending); + let new_ = convert_line_ending(&normalize_line_endings(¶ms.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, ¶ms.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" + ); + } +} diff --git a/crates/harness-tools/src/edit/replacers.rs b/crates/harness-tools/src/edit/replacers.rs new file mode 100644 index 0000000..701f242 --- /dev/null +++ b/crates/harness-tools/src/edit/replacers.rs @@ -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 = a.chars().collect(); + let b: Vec = 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 { + vec![find.to_string()] +} + +fn line_trimmed(content: &str, find: &str) -> Vec { + 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 { + 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 { + if words.is_empty() { + return None; + } + let chars: Vec = 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 = 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 { + 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::>() + .join("\n") +} + +fn indentation_flexible(content: &str, find: &str) -> Vec { + 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 { + 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 { + 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 { + 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 { + 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; + +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 { + 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" + )); + } +} diff --git a/crates/harness-tools/src/lib.rs b/crates/harness-tools/src/lib.rs index 5a16ca7..211e25b 100644 --- a/crates/harness-tools/src/lib.rs +++ b/crates/harness-tools/src/lib.rs @@ -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));