|
| 1 | +"""Tool for making regex-based replacements in files.""" |
| 2 | + |
| 3 | +import difflib |
| 4 | +import re |
| 5 | +from typing import Optional |
| 6 | + |
| 7 | +from codegen import Codebase |
| 8 | + |
| 9 | +from .view_file import add_line_numbers |
| 10 | + |
| 11 | + |
| 12 | +def generate_diff(original: str, modified: str) -> str: |
| 13 | + """Generate a unified diff between two strings. |
| 14 | +
|
| 15 | + Args: |
| 16 | + original: Original content |
| 17 | + modified: Modified content |
| 18 | +
|
| 19 | + Returns: |
| 20 | + Unified diff as a string |
| 21 | + """ |
| 22 | + original_lines = original.splitlines(keepends=True) |
| 23 | + modified_lines = modified.splitlines(keepends=True) |
| 24 | + |
| 25 | + diff = difflib.unified_diff( |
| 26 | + original_lines, |
| 27 | + modified_lines, |
| 28 | + fromfile="original", |
| 29 | + tofile="modified", |
| 30 | + lineterm="", |
| 31 | + ) |
| 32 | + |
| 33 | + return "".join(diff) |
| 34 | + |
| 35 | + |
| 36 | +def _merge_content(original_content: str, edited_content: str, start: int, end: int) -> str: |
| 37 | + """Merge edited content with original content, preserving content outside the edit range. |
| 38 | +
|
| 39 | + Args: |
| 40 | + original_content: Original file content |
| 41 | + edited_content: New content for the specified range |
| 42 | + start: Start line (1-indexed) |
| 43 | + end: End line (1-indexed or -1 for end of file) |
| 44 | +
|
| 45 | + Returns: |
| 46 | + Merged content |
| 47 | + """ |
| 48 | + original_lines = original_content.split("\n") |
| 49 | + edited_lines = edited_content.split("\n") |
| 50 | + |
| 51 | + if start == -1 and end == -1: # Append mode |
| 52 | + return original_content + "\n" + edited_content |
| 53 | + |
| 54 | + # Convert to 0-indexed |
| 55 | + start_idx = start - 1 |
| 56 | + end_idx = end - 1 if end != -1 else len(original_lines) |
| 57 | + |
| 58 | + # Merge the content |
| 59 | + result_lines = original_lines[:start_idx] + edited_lines + original_lines[end_idx + 1 :] |
| 60 | + |
| 61 | + return "\n".join(result_lines) |
| 62 | + |
| 63 | + |
| 64 | +def replacement_edit( |
| 65 | + codebase: Codebase, |
| 66 | + filepath: str, |
| 67 | + pattern: str, |
| 68 | + replacement: str, |
| 69 | + start: int = 1, |
| 70 | + end: int = -1, |
| 71 | + count: Optional[int] = None, |
| 72 | + flags: re.RegexFlag = re.MULTILINE, |
| 73 | +) -> dict[str, str]: |
| 74 | + """Replace text in a file using regex pattern matching. |
| 75 | +
|
| 76 | + Args: |
| 77 | + codebase: The codebase to operate on |
| 78 | + filepath: Path to the file to edit |
| 79 | + pattern: Regex pattern to match |
| 80 | + replacement: Replacement text (can include regex groups) |
| 81 | + start: Start line (1-indexed, default: 1) |
| 82 | + end: End line (1-indexed, -1 for end of file) |
| 83 | + count: Maximum number of replacements (None for all) |
| 84 | + flags: Regex flags (default: re.MULTILINE) |
| 85 | +
|
| 86 | + Returns: |
| 87 | + Dict containing edit results and status |
| 88 | +
|
| 89 | + Raises: |
| 90 | + FileNotFoundError: If file not found |
| 91 | + ValueError: If invalid line range or regex pattern |
| 92 | + """ |
| 93 | + try: |
| 94 | + file = codebase.get_file(filepath) |
| 95 | + except ValueError: |
| 96 | + msg = f"File not found: {filepath}" |
| 97 | + raise FileNotFoundError(msg) |
| 98 | + |
| 99 | + # Get the original content |
| 100 | + original_content = file.content |
| 101 | + original_lines = original_content.split("\n") |
| 102 | + |
| 103 | + # Get the section to edit |
| 104 | + total_lines = len(original_lines) |
| 105 | + start_idx = start - 1 |
| 106 | + end_idx = end - 1 if end != -1 else total_lines |
| 107 | + |
| 108 | + # Get the content to edit |
| 109 | + section_lines = original_lines[start_idx : end_idx + 1] |
| 110 | + section_content = "\n".join(section_lines) |
| 111 | + |
| 112 | + try: |
| 113 | + # Compile pattern for better error messages |
| 114 | + regex = re.compile(pattern, flags) |
| 115 | + except re.error as e: |
| 116 | + msg = f"Invalid regex pattern: {e}" |
| 117 | + raise ValueError(msg) |
| 118 | + |
| 119 | + # Perform the replacement |
| 120 | + if count is None: |
| 121 | + new_section = regex.sub(replacement, section_content) |
| 122 | + else: |
| 123 | + new_section = regex.sub(replacement, section_content, count=count) |
| 124 | + |
| 125 | + # If no changes were made, return early |
| 126 | + if new_section == section_content: |
| 127 | + return { |
| 128 | + "filepath": filepath, |
| 129 | + "status": "unchanged", |
| 130 | + "message": "No matches found for the given pattern", |
| 131 | + } |
| 132 | + |
| 133 | + # Merge the edited content with the original |
| 134 | + new_content = _merge_content(original_content, new_section, start, end) |
| 135 | + |
| 136 | + # Generate diff |
| 137 | + diff = generate_diff(original_content, new_content) |
| 138 | + |
| 139 | + # Apply the edit |
| 140 | + file.edit(new_content) |
| 141 | + codebase.commit() |
| 142 | + |
| 143 | + return { |
| 144 | + "filepath": filepath, |
| 145 | + "diff": diff, |
| 146 | + "status": "success", |
| 147 | + "new_content": add_line_numbers(new_content), |
| 148 | + } |
0 commit comments