"""File editing tool with exact text replacement.""" import os from pathlib import Path from .base_fs_tool import BaseFsTool from .edit_diff import ( detect_line_ending, fuzzy_find_text, generate_diff_string, normalize_for_fuzzy_match, normalize_to_lf, restore_line_endings, strip_bom, ) from ...core.schema import ToolCall class EditTool(BaseFsTool): """Edit a file by replacing exact text.""" def __init__(self, cwd: str | None = None): """Initialize edit tool. Args: cwd: Working directory (defaults to current directory) """ super().__init__() self.cwd = cwd or os.getcwd() def _build_tool_call(self) -> ToolCall: return ToolCall( **{ "description": ( "Edit a file by replacing exact text. The oldText must match exactly " "(including whitespace). Use this for precise, surgical edits." ), "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "Path to the file to edit (relative or absolute)", }, "oldText": { "type": "string", "description": "Exact text to find and replace (must match exactly)", }, "newText": { "type": "string", "description": "New text to replace the old text with", }, }, "required": ["path", "oldText", "newText"], }, }, ) async def execute(self) -> str: """Execute the edit operation.""" path: str = self.context.path old_text: str = self.context.oldText new_text: str = self.context.newText # Resolve path if not os.path.isabs(path): absolute_path = os.path.join(self.cwd, path) else: absolute_path = path # Check file exists and is writable path_obj = Path(absolute_path) if not path_obj.exists(): raise FileNotFoundError(f"File not found: {path}") if not os.access(absolute_path, os.R_OK | os.W_OK): raise PermissionError(f"File not readable/writable: {path}") # Read file with open(absolute_path, "r", encoding="utf-8") as f: raw_content = f.read() # Strip BOM (LLM won't include invisible BOM in oldText) bom, content = strip_bom(raw_content) original_ending = detect_line_ending(content) normalized_content = normalize_to_lf(content) normalized_old_text = normalize_to_lf(old_text) normalized_new_text = normalize_to_lf(new_text) # Find old text using fuzzy matching match_result = fuzzy_find_text(normalized_content, normalized_old_text) if not match_result.found: raise ValueError( f"Could not find the exact text in {path}. The old text must match " f"exactly including all whitespace and newlines.", ) # Count occurrences for uniqueness check fuzzy_content = normalize_for_fuzzy_match(normalized_content) fuzzy_old_text = normalize_for_fuzzy_match(normalized_old_text) occurrences = fuzzy_content.count(fuzzy_old_text) if occurrences > 1: raise ValueError( f"Found {occurrences} occurrences of the text in {path}. " f"The text must be unique. Please provide more context to make it unique.", ) # Perform replacement base_content = match_result.content_for_replacement new_content = ( base_content[: match_result.index] + normalized_new_text + base_content[match_result.index + match_result.match_length :] ) # Verify replacement changed something if base_content == new_content: raise ValueError( f"No changes made to {path}. The replacement produced identical content. " f"This might indicate an issue with special characters or the text not " f"exist as expected.", ) # Write file final_content = bom + restore_line_endings(new_content, original_ending) with open(absolute_path, "w", encoding="utf-8") as f: f.write(final_content) # Generate diff diff_result = generate_diff_string(base_content, new_content) return f"Successfully replaced text in {path}.\n\n{diff_result.diff}"