diff --git a/tests/test_edit_tool.py b/tests/test_edit_tool.py index f0782c6..83bfc6b 100644 --- a/tests/test_edit_tool.py +++ b/tests/test_edit_tool.py @@ -83,3 +83,173 @@ def test_invalid_commands(self, command, sample_file): """Test various invalid commands.""" result = tool_function(command, str(sample_file)) assert "Error" in result + +class TestReplaceBlock: + def test_replace_middle_block(self, sample_file): + """Replace lines 2-3 with new content.""" + result = tool_function( + "replace_block", + str(sample_file), + start_line=2, + end_line=3, + block_content="new line A\nnew line B" + ) + assert f"File {sample_file} updated by replacing lines 2-3" in result + expected_content = "line 1\nnew line A\nnew line B\nline 4\nline 5\n" + assert sample_file.read_text() == expected_content + + def test_replace_at_start(self, sample_file): + """Replace lines 1-2 with new content.""" + result = tool_function( + "replace_block", + str(sample_file), + start_line=1, + end_line=2, + block_content="replacement for start" + ) + assert f"File {sample_file} updated by replacing lines 1-2" in result + expected_content = "replacement for start\nline 3\nline 4\nline 5\n" + assert sample_file.read_text() == expected_content + + def test_replace_at_end(self, sample_file): + """Replace lines 4-5 with new content.""" + result = tool_function( + "replace_block", + str(sample_file), + start_line=4, + end_line=5, + block_content="replacement for end" + ) + assert f"File {sample_file} updated by replacing lines 4-5" in result + expected_content = "line 1\nline 2\nline 3\nreplacement for end\n" + assert sample_file.read_text() == expected_content + + def test_replace_entire_file(self, sample_file): + """Replace all lines (1-5) with new content.""" + result = tool_function( + "replace_block", + str(sample_file), + start_line=1, + end_line=5, + block_content="all new content" + ) + assert f"File {sample_file} updated by replacing lines 1-5" in result + expected_content = "all new content\n" + assert sample_file.read_text() == expected_content + + def test_replace_single_line(self, sample_file): + """Replace line 3 with new content.""" + result = tool_function( + "replace_block", + str(sample_file), + start_line=3, + end_line=3, + block_content="only line 3 replaced" + ) + assert f"File {sample_file} updated by replacing lines 3-3" in result + expected_content = "line 1\nline 2\nonly line 3 replaced\nline 4\nline 5\n" + assert sample_file.read_text() == expected_content + + def test_delete_block_by_empty_content(self, sample_file): + """Delete lines 2-4 by providing empty block_content.""" + result = tool_function( + "replace_block", + str(sample_file), + start_line=2, + end_line=4, + block_content="" + ) + assert f"File {sample_file} updated by replacing lines 2-4" in result + expected_content = "line 1\nline 5\n" + assert sample_file.read_text() == expected_content + + def test_replace_with_fewer_lines(self, sample_file): + """Replace lines 2-4 (3 lines) with 1 line.""" + result = tool_function( + "replace_block", + str(sample_file), + start_line=2, + end_line=4, + block_content="one line to replace three" + ) + assert f"File {sample_file} updated by replacing lines 2-4" in result + expected_content = "line 1\none line to replace three\nline 5\n" + assert sample_file.read_text() == expected_content + + def test_replace_with_more_lines(self, sample_file): + """Replace line 3 (1 line) with 3 lines.""" + result = tool_function( + "replace_block", + str(sample_file), + start_line=3, + end_line=3, + block_content="line A\nline B\nline C" + ) + assert f"File {sample_file} updated by replacing lines 3-3" in result + expected_content = "line 1\nline 2\nline A\nline B\nline C\nline 4\nline 5\n" + assert sample_file.read_text() == expected_content + + def test_replace_on_empty_file(self, temp_dir): + """Test replace_block on an empty file.""" + empty_file = temp_dir / "empty.txt" + empty_file.write_text("") + + result = tool_function( + "replace_block", + str(empty_file), + start_line=1, + end_line=1, + block_content="content for empty file" + ) + assert f"File {empty_file} updated by replacing lines 1-1" in result + expected_content = "content for empty file\n" + assert empty_file.read_text() == expected_content + + def test_invalid_line_numbers_non_positive(self, sample_file): + """Test with non-positive line numbers.""" + result = tool_function("replace_block", str(sample_file), start_line=0, end_line=1, block_content="fail") + assert "start_line and end_line must be positive integers" in result + result = tool_function("replace_block", str(sample_file), start_line=1, end_line=0, block_content="fail") + assert "start_line and end_line must be positive integers" in result + + def test_invalid_line_numbers_start_gt_end(self, sample_file): + """Test with start_line > end_line.""" + result = tool_function("replace_block", str(sample_file), start_line=3, end_line=2, block_content="fail") + assert "start_line (3) cannot be greater than end_line (2)" in result + + def test_invalid_line_numbers_out_of_bounds_non_empty_file(self, sample_file): + """Test with line numbers out of file bounds for a non-empty file.""" + result = tool_function("replace_block", str(sample_file), start_line=6, end_line=6, block_content="fail") + assert "start_line (6) is out of bounds for file with 5 lines" in result + + result = tool_function("replace_block", str(sample_file), start_line=1, end_line=6, block_content="fail") + assert "end_line (6) is out of bounds for file with 5 lines" in result + + def test_invalid_line_numbers_for_empty_file(self, temp_dir): + """Test invalid line numbers for an empty file.""" + empty_file = temp_dir / "empty.txt" + empty_file.write_text("") + result = tool_function("replace_block", str(empty_file), start_line=2, end_line=2, block_content="fail") + assert "For an empty file, start_line and end_line must both be 1" in result + + def test_missing_parameters_for_replace_block(self, sample_file): + """Test with missing required parameters for replace_block.""" + result = tool_function("replace_block", str(sample_file), start_line=1, end_line=1) # Missing block_content + assert "Missing or invalid type for required arguments" in result + result = tool_function("replace_block", str(sample_file), end_line=1, block_content="fail") # Missing start_line + assert "Missing or invalid type for required arguments" in result + result = tool_function("replace_block", str(sample_file), start_line=1, block_content="fail") # Missing end_line + assert "Missing or invalid type for required arguments" in result + + def test_replace_block_nonexistent_file(self, temp_dir): + """Test replace_block on a nonexistent file.""" + non_existent_file = temp_dir / "does_not_exist.txt" + result = tool_function( + "replace_block", + str(non_existent_file), + start_line=1, + end_line=1, + block_content="content" + ) + assert "Error" in result + assert "does not exist" in result diff --git a/tools/edit.py b/tools/edit.py index 244d440..ac14bca 100644 --- a/tools/edit.py +++ b/tools/edit.py @@ -10,21 +10,34 @@ def tool_info(): * The `create` command cannot be used if the specified `path` already exists as a file.\n * If a `command` generates a long output, it will be truncated and marked with ``.\n * The `edit` command overwrites the entire file with the provided `file_text`.\n -* No partial/line-range edits or partial viewing are supported.""", +* The `replace_block` command replaces a specified range of lines (from `start_line` to `end_line`, inclusive) with `block_content`. If `block_content` is an empty string, the specified lines are deleted. Newlines in `block_content` will result in multiple lines; content is typically joined by newlines to form the final text.\n +* No partial/line-range edits or partial viewing are supported for the basic `edit` or `view` commands (use `replace_block` for precise edits).""", "input_schema": { "type": "object", "properties": { "command": { "type": "string", - "enum": ["view", "create", "edit"], - "description": "The command to run: `view`, `create`, or `edit`." + "enum": ["view", "create", "edit", "replace_block"], + "description": "The command to run: `view`, `create`, `edit`, or `replace_block`." }, "path": { "description": "Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`.", "type": "string" }, "file_text": { - "description": "Required parameter of `create` or `edit` command, containing the content for the entire file.", + "description": "Required parameter of `create` or `edit` (full file overwrite) command, containing the content for the entire file.", + "type": "string" + }, + "start_line": { + "description": "For `replace_block` command: The first line (inclusive, 1-indexed) of the block to be replaced. Must be a positive integer.", + "type": "integer" + }, + "end_line": { + "description": "For `replace_block` command: The last line (inclusive, 1-indexed) of the block to be replaced. Must be a positive integer and greater than or equal to `start_line`.", + "type": "integer" + }, + "block_content": { + "description": "For `replace_block` command: The new textual content to replace the specified block. Newlines (e.g., '\\n') will create multiple lines in the output. An empty string means the block will be deleted.", "type": "string" } }, @@ -44,6 +57,7 @@ def validate_path(path: str, command: str) -> Path: - 'view': path may be a file or directory; must exist. - 'create': path must not exist (for new file creation). - 'edit': path must exist (for overwriting). + - 'replace_block': path must exist and be a file (for replacing content). """ path_obj = Path(path) @@ -61,7 +75,7 @@ def validate_path(path: str, command: str) -> Path: # Path must not exist if path_obj.exists(): raise ValueError(f"Cannot create new file; {path} already exists.") - elif command == "edit": + elif command == "edit" or command == "replace_block": # Path must exist and must be a file if not path_obj.exists(): raise ValueError(f"The file {path} does not exist.") @@ -119,12 +133,14 @@ def view_path(path_obj: Path) -> str: content = read_file(path_obj) return format_output(content, str(path_obj)) -def tool_function(command: str, path: str, file_text: str = None) -> str: +def tool_function(command: str, path: str, file_text: str = None, **kwargs) -> str: """ Main tool function that handles: - 'view' : View the entire file or directory listing - 'create': Create a new file with the given file_text - 'edit' : Overwrite an existing file with file_text + - 'replace_block': Replaces a specific block of lines (inclusive) with new content. + Requires kwargs: start_line (int), end_line (int), block_content (str). """ try: path_obj = validate_path(path, command) @@ -144,6 +160,55 @@ def tool_function(command: str, path: str, file_text: str = None) -> str: write_file(path_obj, file_text) return f"File at {path} has been overwritten with new content." + elif command == "replace_block": + start_line = kwargs.get("start_line") + end_line = kwargs.get("end_line") + block_content = kwargs.get("block_content") + + if not (isinstance(start_line, int) and isinstance(end_line, int) and isinstance(block_content, str)): + raise ValueError("Missing or invalid type for required arguments for 'replace_block': start_line (int), end_line (int), block_content (str).") + + if start_line <= 0 or end_line <= 0: + raise ValueError("start_line and end_line must be positive integers for 'replace_block'.") + if start_line > end_line: + raise ValueError(f"start_line ({start_line}) cannot be greater than end_line ({end_line}) for 'replace_block'.") + + original_content_str = read_file(path_obj) + original_lines = original_content_str.splitlines() + total_lines = len(original_lines) + + new_content_str = "" + + if total_lines == 0: # Handling for empty file + if start_line == 1 and end_line == 1: + new_content_str = block_content + else: + raise ValueError(f"For an empty file, start_line and end_line must both be 1 to replace its content. Got start_line={start_line}, end_line={end_line}.") + else: # Handling for non-empty file + if not (1 <= start_line <= total_lines): + raise ValueError(f"start_line ({start_line}) is out of bounds for file with {total_lines} lines.") + if not (start_line <= end_line <= total_lines): + raise ValueError(f"end_line ({end_line}) is out of bounds for file with {total_lines} lines or less than start_line ({start_line}).") + + new_content_list = [] + new_content_list.extend(original_lines[0:start_line-1]) # Lines before the block + + if block_content: # Add new block content if it's not empty + new_content_list.extend(block_content.splitlines()) + + new_content_list.extend(original_lines[end_line:]) # Lines after the block + + new_content_str = "\n".join(new_content_list) + + # Ensure a final newline if the content is not empty and doesn't already have one + if new_content_str and not new_content_str.endswith('\n'): + new_content_str += '\n' + + # If new_content_str is empty (e.g., all lines deleted and block_content was empty), + # write_file will correctly create an empty file. + + write_file(path_obj, new_content_str) + return f"File {path} updated by replacing lines {start_line}-{end_line}." else: raise ValueError(f"Unknown command: {command}") @@ -151,6 +216,4 @@ def tool_function(command: str, path: str, file_text: str = None) -> str: return f"Error: {str(e)}" if __name__ == "__main__": - # Example usage - result = tool_function("view", "./coding_agent.py", view_range=[1, 10]) - print(result) + pass