diff --git a/aider/tools/delete_block.py b/aider/tools/delete_block.py index 2f3391ee0..e2cf44d8c 100644 --- a/aider/tools/delete_block.py +++ b/aider/tools/delete_block.py @@ -1,171 +1,82 @@ -import os import traceback +from .tool_utils import ( + ToolError, + validate_file_for_edit, + find_pattern_indices, + select_occurrence_index, + determine_line_range, + apply_change, + handle_tool_error, + format_tool_result, +) def _execute_delete_block(coder, file_path, start_pattern, end_pattern=None, line_count=None, near_context=None, occurrence=1, change_id=None, dry_run=False): """ Delete a block of text between start_pattern and end_pattern (inclusive). - - Parameters: - - coder: The Coder instance - - file_path: Path to the file to modify - - start_pattern: Pattern marking the start of the block to delete (line containing this pattern) - - end_pattern: Optional pattern marking the end of the block (line containing this pattern) - - line_count: Optional number of lines to delete (alternative to end_pattern) - - near_context: Optional text nearby to help locate the correct instance of the start_pattern - - occurrence: Which occurrence of the start_pattern to use (1-based index, or -1 for last) - - change_id: Optional ID for tracking the change - - dry_run: If True, simulate the change without modifying the file - - Returns a result message. + Uses utility functions for validation, finding lines, and applying changes. """ + tool_name = "DeleteBlock" try: - # Get absolute file path - abs_path = coder.abs_root_path(file_path) - rel_path = coder.get_rel_fname(abs_path) - - # Check if file exists - if not os.path.isfile(abs_path): - coder.io.tool_error(f"File '{file_path}' not found") - return f"Error: File not found" - - # Check if file is in editable context - if abs_path not in coder.abs_fnames: - if abs_path in coder.abs_read_only_fnames: - coder.io.tool_error(f"File '{file_path}' is read-only. Use MakeEditable first.") - return f"Error: File is read-only. Use MakeEditable first." - else: - coder.io.tool_error(f"File '{file_path}' not in context") - return f"Error: File not in context" - - # Reread file content immediately before modification - file_content = coder.io.read_text(abs_path) - if file_content is None: - coder.io.tool_error(f"Could not read file '{file_path}' before DeleteBlock operation.") - return f"Error: Could not read file '{file_path}'" - - # Validate we have either end_pattern or line_count, but not both - if end_pattern and line_count: - coder.io.tool_error("Cannot specify both end_pattern and line_count") - return "Error: Cannot specify both end_pattern and line_count" - - # Split into lines for easier handling - lines = file_content.splitlines() - original_content = file_content - - # Find occurrences of the start_pattern - start_pattern_line_indices = [] - for i, line in enumerate(lines): - if start_pattern in line: - if near_context: - context_window_start = max(0, i - 5) - context_window_end = min(len(lines), i + 6) - context_block = "\n".join(lines[context_window_start:context_window_end]) - if near_context in context_block: - start_pattern_line_indices.append(i) - else: - start_pattern_line_indices.append(i) + # 1. Validate file and get content + abs_path, rel_path, original_content = validate_file_for_edit(coder, file_path) + lines = original_content.splitlines() - if not start_pattern_line_indices: - err_msg = f"Start pattern '{start_pattern}' not found" - if near_context: err_msg += f" near context '{near_context}'" - err_msg += f" in file '{file_path}'." - coder.io.tool_error(err_msg) - return f"Error: {err_msg}" + # 2. Find the start line + pattern_desc = f"Start pattern '{start_pattern}'" + if near_context: + pattern_desc += f" near context '{near_context}'" + start_pattern_indices = find_pattern_indices(lines, start_pattern, near_context) + start_line_idx = select_occurrence_index(start_pattern_indices, occurrence, pattern_desc) - # Select the occurrence for the start pattern - num_occurrences = len(start_pattern_line_indices) - try: - occurrence = int(occurrence) - if occurrence == -1: - target_idx = num_occurrences - 1 - elif occurrence > 0 and occurrence <= num_occurrences: - target_idx = occurrence - 1 - else: - err_msg = f"Occurrence number {occurrence} is out of range for start pattern '{start_pattern}'. Found {num_occurrences} occurrences" - if near_context: err_msg += f" near '{near_context}'" - err_msg += f" in '{file_path}'." - coder.io.tool_error(err_msg) - return f"Error: {err_msg}" - except ValueError: - coder.io.tool_error(f"Invalid occurrence value: '{occurrence}'. Must be an integer.") - return f"Error: Invalid occurrence value '{occurrence}'" + # 3. Determine the end line, passing pattern_desc for better error messages + start_line, end_line = determine_line_range( + lines, start_line_idx, end_pattern, line_count, pattern_desc=pattern_desc + ) - start_line = start_pattern_line_indices[target_idx] - occurrence_str = f"occurrence {occurrence} of " if num_occurrences > 1 else "" - - # Find the end line based on end_pattern or line_count - end_line = -1 - if end_pattern: - for i in range(start_line, len(lines)): - if end_pattern in lines[i]: - end_line = i - break - if end_line == -1: - err_msg = f"End pattern '{end_pattern}' not found after {occurrence_str}start pattern '{start_pattern}' (line {start_line + 1}) in '{file_path}'." - coder.io.tool_error(err_msg) - return f"Error: {err_msg}" - elif line_count: - try: - line_count = int(line_count) - if line_count <= 0: raise ValueError("Line count must be positive") - end_line = min(start_line + line_count - 1, len(lines) - 1) - except ValueError: - coder.io.tool_error(f"Invalid line_count value: '{line_count}'. Must be a positive integer.") - return f"Error: Invalid line_count value '{line_count}'" - else: - end_line = start_line - - # Prepare the deletion + # 4. Prepare the deletion deleted_lines = lines[start_line:end_line+1] new_lines = lines[:start_line] + lines[end_line+1:] new_content = '\n'.join(new_lines) - + if original_content == new_content: coder.io.tool_warning(f"No changes made: deletion would not change file") return f"Warning: No changes made (deletion would not change file)" - # Generate diff for feedback (assuming _generate_diff_snippet_delete is available on coder) + # 5. Generate diff for feedback diff_snippet = coder._generate_diff_snippet_delete(original_content, start_line, end_line) - - # Handle dry run - if dry_run: - coder.io.tool_output(f"Dry run: Would delete lines {start_line+1}-{end_line+1} (based on {occurrence_str}start pattern '{start_pattern}') in {file_path}") - return f"Dry run: Would delete block. Diff snippet:\n{diff_snippet}" - - # --- Apply Change (Not dry run) --- - coder.io.write_text(abs_path, new_content) - - # Track the change - try: - metadata = { - 'start_line': start_line + 1, - 'end_line': end_line + 1, - 'start_pattern': start_pattern, - 'end_pattern': end_pattern, - 'line_count': line_count, - 'near_context': near_context, - 'occurrence': occurrence, - 'deleted_content': '\n'.join(deleted_lines) - } - change_id = coder.change_tracker.track_change( - file_path=rel_path, - change_type='deleteblock', - original_content=original_content, - new_content=new_content, - metadata=metadata, - change_id=change_id - ) - except Exception as track_e: - coder.io.tool_error(f"Error tracking change for DeleteBlock: {track_e}") - change_id = "TRACKING_FAILED" - - coder.aider_edited_files.add(rel_path) - - # Improve feedback num_deleted = end_line - start_line + 1 - coder.io.tool_output(f"✅ Deleted {num_deleted} lines (from {occurrence_str}start pattern) in {file_path} (change_id: {change_id})") - return f"Successfully deleted {num_deleted} lines (change_id: {change_id}). Diff snippet:\n{diff_snippet}" - + num_occurrences = len(start_pattern_indices) + occurrence_str = f"occurrence {occurrence} of " if num_occurrences > 1 else "" + + # 6. Handle dry run + if dry_run: + dry_run_message = f"Dry run: Would delete {num_deleted} lines ({start_line+1}-{end_line+1}) based on {occurrence_str}start pattern '{start_pattern}' in {file_path}." + return format_tool_result(coder, tool_name, "", dry_run=True, dry_run_message=dry_run_message, diff_snippet=diff_snippet) + + # 7. Apply Change (Not dry run) + metadata = { + 'start_line': start_line + 1, + 'end_line': end_line + 1, + 'start_pattern': start_pattern, + 'end_pattern': end_pattern, + 'line_count': line_count, + 'near_context': near_context, + 'occurrence': occurrence, + 'deleted_content': '\n'.join(deleted_lines) + } + final_change_id = apply_change( + coder, abs_path, rel_path, original_content, new_content, 'deleteblock', metadata, change_id + ) + + # 8. Format and return result, adding line range to success message + success_message = f"Deleted {num_deleted} lines ({start_line+1}-{end_line+1}) (from {occurrence_str}start pattern) in {file_path}" + return format_tool_result( + coder, tool_name, success_message, change_id=final_change_id, diff_snippet=diff_snippet + ) + + except ToolError as e: + # Handle errors raised by utility functions (expected errors) + return handle_tool_error(coder, tool_name, e, add_traceback=False) except Exception as e: - coder.io.tool_error(f"Error in DeleteBlock: {str(e)}\n{traceback.format_exc()}") - return f"Error: {str(e)}" + # Handle unexpected errors + return handle_tool_error(coder, tool_name, e) diff --git a/aider/tools/insert_block.py b/aider/tools/insert_block.py index 139d8aa4a..26e83fed2 100644 --- a/aider/tools/insert_block.py +++ b/aider/tools/insert_block.py @@ -1,159 +1,87 @@ import os import traceback +from .tool_utils import ( + ToolError, + validate_file_for_edit, + find_pattern_indices, + select_occurrence_index, + apply_change, + handle_tool_error, + format_tool_result, +) def _execute_insert_block(coder, file_path, content, after_pattern=None, before_pattern=None, near_context=None, occurrence=1, change_id=None, dry_run=False): """ - Insert a block of text after or before a specified pattern. - - Parameters: - - coder: The Coder instance - - file_path: Path to the file to modify - - content: Text block to insert - - after_pattern: Pattern after which to insert the block (line containing this pattern) - specify one of after/before - - before_pattern: Pattern before which to insert the block (line containing this pattern) - specify one of after/before - - near_context: Optional text nearby to help locate the correct instance of the pattern - - occurrence: Which occurrence of the pattern to use (1-based index, or -1 for last) - - change_id: Optional ID for tracking the change - - dry_run: If True, simulate the change without modifying the file - - Returns a result message. + Insert a block of text after or before a specified pattern using utility functions. """ + tool_name = "InsertBlock" try: - # Get absolute file path - abs_path = coder.abs_root_path(file_path) - rel_path = coder.get_rel_fname(abs_path) - - # Check if file exists - if not os.path.isfile(abs_path): - coder.io.tool_error(f"File '{file_path}' not found") - return f"Error: File not found" - - # Check if file is in editable context - if abs_path not in coder.abs_fnames: - if abs_path in coder.abs_read_only_fnames: - coder.io.tool_error(f"File '{file_path}' is read-only. Use MakeEditable first.") - return f"Error: File is read-only. Use MakeEditable first." - else: - coder.io.tool_error(f"File '{file_path}' not in context") - return f"Error: File not in context" - - # Reread file content immediately before modification (Fixes Point 3: Stale Reads) - file_content = coder.io.read_text(abs_path) - if file_content is None: - # Provide more specific error (Improves Point 4) - coder.io.tool_error(f"Could not read file '{file_path}' before InsertBlock operation.") - return f"Error: Could not read file '{file_path}'" - - # Validate we have either after_pattern or before_pattern, but not both + # 1. Validate parameters if after_pattern and before_pattern: - coder.io.tool_error("Cannot specify both after_pattern and before_pattern") - return "Error: Cannot specify both after_pattern and before_pattern" + raise ToolError("Cannot specify both after_pattern and before_pattern") if not after_pattern and not before_pattern: - coder.io.tool_error("Must specify either after_pattern or before_pattern") - return "Error: Must specify either after_pattern or before_pattern" - - # Split into lines for easier handling - lines = file_content.splitlines() - original_content = file_content - - # Find occurrences of the pattern (either after_pattern or before_pattern) + raise ToolError("Must specify either after_pattern or before_pattern") + + # 2. Validate file and get content + abs_path, rel_path, original_content = validate_file_for_edit(coder, file_path) + lines = original_content.splitlines() + + # 3. Find the target line index pattern = after_pattern if after_pattern else before_pattern pattern_type = "after" if after_pattern else "before" - - # Find line indices containing the pattern - pattern_line_indices = [] - for i, line in enumerate(lines): - if pattern in line: - # If near_context is provided, check if it's nearby - if near_context: - context_window_start = max(0, i - 5) # Check 5 lines before/after - context_window_end = min(len(lines), i + 6) - context_block = "\n".join(lines[context_window_start:context_window_end]) - if near_context in context_block: - pattern_line_indices.append(i) - else: - pattern_line_indices.append(i) + pattern_desc = f"Pattern '{pattern}'" + if near_context: + pattern_desc += f" near context '{near_context}'" - if not pattern_line_indices: - err_msg = f"Pattern '{pattern}' not found" - if near_context: err_msg += f" near context '{near_context}'" - err_msg += f" in file '{file_path}'." - coder.io.tool_error(err_msg) - return f"Error: {err_msg}" # Improve Point 4 - - # Select the occurrence (Implements Point 5) - num_occurrences = len(pattern_line_indices) - try: - occurrence = int(occurrence) # Ensure occurrence is an integer - if occurrence == -1: # Last occurrence - target_idx = num_occurrences - 1 - elif occurrence > 0 and occurrence <= num_occurrences: - target_idx = occurrence - 1 # Convert 1-based to 0-based - else: - err_msg = f"Occurrence number {occurrence} is out of range for pattern '{pattern}'. Found {num_occurrences} occurrences" - if near_context: err_msg += f" near '{near_context}'" - err_msg += f" in '{file_path}'." - coder.io.tool_error(err_msg) - return f"Error: {err_msg}" # Improve Point 4 - except ValueError: - coder.io.tool_error(f"Invalid occurrence value: '{occurrence}'. Must be an integer.") - return f"Error: Invalid occurrence value '{occurrence}'" + pattern_line_indices = find_pattern_indices(lines, pattern, near_context) + target_line_idx = select_occurrence_index(pattern_line_indices, occurrence, pattern_desc) # Determine the final insertion line index - insertion_line_idx = pattern_line_indices[target_idx] + insertion_line_idx = target_line_idx if pattern_type == "after": insertion_line_idx += 1 # Insert on the line *after* the matched line - # Prepare the content to insert + + # 4. Prepare the insertion content_lines = content.splitlines() - - # Create the new lines array new_lines = lines[:insertion_line_idx] + content_lines + lines[insertion_line_idx:] - new_content = '\n'.join(new_lines) # Use '\n' to match io.write_text behavior - + new_content = '\n'.join(new_lines) + if original_content == new_content: coder.io.tool_warning(f"No changes made: insertion would not change file") return f"Warning: No changes made (insertion would not change file)" - # Generate diff for feedback + # 5. Generate diff for feedback diff_snippet = coder._generate_diff_snippet_insert(original_content, insertion_line_idx, content_lines) - - # Handle dry run (Implements Point 6) - if dry_run: - occurrence_str = f"occurrence {occurrence} of " if num_occurrences > 1 else "" - coder.io.tool_output(f"Dry run: Would insert block {pattern_type} {occurrence_str}pattern '{pattern}' in {file_path}") - return f"Dry run: Would insert block. Diff snippet:\n{diff_snippet}" - - # --- Apply Change (Not dry run) --- - coder.io.write_text(abs_path, new_content) - - # Track the change - try: - metadata = { - 'insertion_line_idx': insertion_line_idx, - 'after_pattern': after_pattern, - 'before_pattern': before_pattern, - 'near_context': near_context, - 'occurrence': occurrence, - 'content': content - } - change_id = coder.change_tracker.track_change( - file_path=rel_path, - change_type='insertblock', - original_content=original_content, - new_content=new_content, - metadata=metadata, - change_id=change_id - ) - except Exception as track_e: - coder.io.tool_error(f"Error tracking change for InsertBlock: {track_e}") - change_id = "TRACKING_FAILED" - - coder.aider_edited_files.add(rel_path) - - # Improve feedback (Point 5 & 6) + num_occurrences = len(pattern_line_indices) occurrence_str = f"occurrence {occurrence} of " if num_occurrences > 1 else "" - coder.io.tool_output(f"✅ Inserted block {pattern_type} {occurrence_str}pattern in {file_path} (change_id: {change_id})") - return f"Successfully inserted block (change_id: {change_id}). Diff snippet:\n{diff_snippet}" + + # 6. Handle dry run + if dry_run: + dry_run_message = f"Dry run: Would insert block {pattern_type} {occurrence_str}pattern '{pattern}' in {file_path} at line {insertion_line_idx + 1}." + return format_tool_result(coder, tool_name, "", dry_run=True, dry_run_message=dry_run_message, diff_snippet=diff_snippet) + + # 7. Apply Change (Not dry run) + metadata = { + 'insertion_line_idx': insertion_line_idx, + 'after_pattern': after_pattern, + 'before_pattern': before_pattern, + 'near_context': near_context, + 'occurrence': occurrence, + 'content': content + } + final_change_id = apply_change( + coder, abs_path, rel_path, original_content, new_content, 'insertblock', metadata, change_id + ) + + # 8. Format and return result + success_message = f"Inserted block {pattern_type} {occurrence_str}pattern in {file_path} at line {insertion_line_idx + 1}" + return format_tool_result( + coder, tool_name, success_message, change_id=final_change_id, diff_snippet=diff_snippet + ) + + except ToolError as e: + # Handle errors raised by utility functions (expected errors) + return handle_tool_error(coder, tool_name, e, add_traceback=False) except Exception as e: coder.io.tool_error(f"Error in InsertBlock: {str(e)}\n{traceback.format_exc()}") # Add traceback diff --git a/aider/tools/replace_all.py b/aider/tools/replace_all.py index 468bb15b7..1764a23ee 100644 --- a/aider/tools/replace_all.py +++ b/aider/tools/replace_all.py @@ -1,95 +1,65 @@ -import os import traceback +from .tool_utils import ( + ToolError, + validate_file_for_edit, + apply_change, + handle_tool_error, + format_tool_result, +) def _execute_replace_all(coder, file_path, find_text, replace_text, change_id=None, dry_run=False): """ - Replace all occurrences of text in a file. - - Parameters: - - coder: The Coder instance - - file_path: Path to the file to modify - - find_text: Text to find and replace - - replace_text: Text to replace it with - - change_id: Optional ID for tracking the change - - dry_run: If True, simulate the change without modifying the file - - Returns a result message. + Replace all occurrences of text in a file using utility functions. """ + # Get absolute file path + abs_path = coder.abs_root_path(file_path) + rel_path = coder.get_rel_fname(abs_path) + tool_name = "ReplaceAll" try: - # Get absolute file path - abs_path = coder.abs_root_path(file_path) - rel_path = coder.get_rel_fname(abs_path) - - # Check if file exists - if not os.path.isfile(abs_path): - coder.io.tool_error(f"File '{file_path}' not found") - return f"Error: File not found" - - # Check if file is in editable context - if abs_path not in coder.abs_fnames: - if abs_path in coder.abs_read_only_fnames: - coder.io.tool_error(f"File '{file_path}' is read-only. Use MakeEditable first.") - return f"Error: File is read-only. Use MakeEditable first." - else: - coder.io.tool_error(f"File '{file_path}' not in context") - return f"Error: File not in context" - - # Reread file content immediately before modification - content = coder.io.read_text(abs_path) - if content is None: - coder.io.tool_error(f"Could not read file '{file_path}' before ReplaceAll operation.") - return f"Error: Could not read file '{file_path}'" - - # Count occurrences - count = content.count(find_text) + # 1. Validate file and get content + abs_path, rel_path, original_content = validate_file_for_edit(coder, file_path) + + # 2. Count occurrences + count = original_content.count(find_text) if count == 0: - coder.io.tool_warning(f"Text '{find_text}' not found in file") + coder.io.tool_warning(f"Text '{find_text}' not found in file '{file_path}'") return f"Warning: Text not found in file" - - # Perform the replacement - original_content = content - new_content = content.replace(find_text, replace_text) - + + # 3. Perform the replacement + new_content = original_content.replace(find_text, replace_text) + if original_content == new_content: coder.io.tool_warning(f"No changes made: replacement text is identical to original") return f"Warning: No changes made (replacement identical to original)" - - # Generate diff for feedback (more comprehensive for ReplaceAll) + + # 4. Generate diff for feedback + # Note: _generate_diff_chunks is currently on the Coder class diff_examples = coder._generate_diff_chunks(original_content, find_text, replace_text) - # Handle dry run + # 5. Handle dry run if dry_run: - coder.io.tool_output(f"Dry run: Would replace {count} occurrences of '{find_text}' in {file_path}") - return f"Dry run: Would replace {count} occurrences. Diff examples:\n{diff_examples}" + dry_run_message = f"Dry run: Would replace {count} occurrences of '{find_text}' in {file_path}." + return format_tool_result(coder, tool_name, "", dry_run=True, dry_run_message=dry_run_message, diff_snippet=diff_examples) - # --- Apply Change (Not dry run) --- - coder.io.write_text(abs_path, new_content) - - # Track the change - try: - metadata = { - 'find_text': find_text, - 'replace_text': replace_text, - 'occurrences': count - } - change_id = coder.change_tracker.track_change( - file_path=rel_path, - change_type='replaceall', - original_content=original_content, - new_content=new_content, - metadata=metadata, - change_id=change_id - ) - except Exception as track_e: - coder.io.tool_error(f"Error tracking change for ReplaceAll: {track_e}") - change_id = "TRACKING_FAILED" + # 6. Apply Change (Not dry run) + metadata = { + 'find_text': find_text, + 'replace_text': replace_text, + 'occurrences': count + } + final_change_id = apply_change( + coder, abs_path, rel_path, original_content, new_content, 'replaceall', metadata, change_id + ) - coder.aider_edited_files.add(rel_path) - - # Improve feedback - coder.io.tool_output(f"✅ Replaced {count} occurrences in {file_path} (change_id: {change_id})") - return f"Successfully replaced {count} occurrences (change_id: {change_id}). Diff examples:\n{diff_examples}" - + # 7. Format and return result + success_message = f"Replaced {count} occurrences in {file_path}" + return format_tool_result( + coder, tool_name, success_message, change_id=final_change_id, diff_snippet=diff_examples + ) + + except ToolError as e: + # Handle errors raised by utility functions + return handle_tool_error(coder, tool_name, e, add_traceback=False) except Exception as e: - coder.io.tool_error(f"Error in ReplaceAll: {str(e)}\n{traceback.format_exc()}") - return f"Error: {str(e)}" + # Handle unexpected errors + return handle_tool_error(coder, tool_name, e) diff --git a/aider/tools/replace_text.py b/aider/tools/replace_text.py index 54a149059..9e1a3dfcc 100644 --- a/aider/tools/replace_text.py +++ b/aider/tools/replace_text.py @@ -1,125 +1,91 @@ -import os import traceback +from .tool_utils import ( + ToolError, + validate_file_for_edit, + apply_change, + handle_tool_error, + format_tool_result, +) def _execute_replace_text(coder, file_path, find_text, replace_text, near_context=None, occurrence=1, change_id=None, dry_run=False): """ Replace specific text with new text, optionally using nearby context for disambiguation. - - Parameters: - - coder: The Coder instance - - file_path: Path to the file to modify - - find_text: Text to find and replace - - replace_text: Text to replace it with - - near_context: Optional text nearby to help locate the correct instance - - occurrence: Which occurrence to replace (1-based index, or -1 for last) - - change_id: Optional ID for tracking the change - - dry_run: If True, simulate the change without modifying the file - - Returns a result message. + Uses utility functions for validation, finding occurrences, and applying changes. """ + tool_name = "ReplaceText" try: - # Get absolute file path - abs_path = coder.abs_root_path(file_path) - rel_path = coder.get_rel_fname(abs_path) - - # Check if file exists - if not os.path.isfile(abs_path): - coder.io.tool_error(f"File '{file_path}' not found") - return f"Error: File not found" - - # Check if file is in editable context - if abs_path not in coder.abs_fnames: - if abs_path in coder.abs_read_only_fnames: - coder.io.tool_error(f"File '{file_path}' is read-only. Use MakeEditable first.") - return f"Error: File is read-only. Use MakeEditable first." - else: - coder.io.tool_error(f"File '{file_path}' not in context") - return f"Error: File not in context" - - # Reread file content immediately before modification - content = coder.io.read_text(abs_path) - if content is None: - coder.io.tool_error(f"Could not read file '{file_path}' before ReplaceText operation.") - return f"Error: Could not read file '{file_path}'" - - # Find occurrences using helper function (assuming _find_occurrences is available on coder) - occurrences = coder._find_occurrences(content, find_text, near_context) - + # 1. Validate file and get content + abs_path, rel_path, original_content = validate_file_for_edit(coder, file_path) + + # 2. Find occurrences using helper function + # Note: _find_occurrences is currently on the Coder class, not in tool_utils + occurrences = coder._find_occurrences(original_content, find_text, near_context) + if not occurrences: err_msg = f"Text '{find_text}' not found" if near_context: err_msg += f" near context '{near_context}'" err_msg += f" in file '{file_path}'." - coder.io.tool_error(err_msg) - return f"Error: {err_msg}" + raise ToolError(err_msg) - # Select the occurrence + # 3. Select the occurrence index num_occurrences = len(occurrences) try: occurrence = int(occurrence) if occurrence == -1: + if num_occurrences == 0: + raise ToolError(f"Text '{find_text}' not found, cannot select last occurrence.") target_idx = num_occurrences - 1 - elif occurrence > 0 and occurrence <= num_occurrences: - target_idx = occurrence - 1 + elif 1 <= occurrence <= num_occurrences: + target_idx = occurrence - 1 # Convert 1-based to 0-based else: err_msg = f"Occurrence number {occurrence} is out of range. Found {num_occurrences} occurrences of '{find_text}'" if near_context: err_msg += f" near '{near_context}'" err_msg += f" in '{file_path}'." - coder.io.tool_error(err_msg) - return f"Error: {err_msg}" + raise ToolError(err_msg) except ValueError: - coder.io.tool_error(f"Invalid occurrence value: '{occurrence}'. Must be an integer.") - return f"Error: Invalid occurrence value '{occurrence}'" + raise ToolError(f"Invalid occurrence value: '{occurrence}'. Must be an integer.") start_index = occurrences[target_idx] - - # Perform the replacement - original_content = content - new_content = content[:start_index] + replace_text + content[start_index + len(find_text):] - + + # 4. Perform the replacement + new_content = original_content[:start_index] + replace_text + original_content[start_index + len(find_text):] + if original_content == new_content: coder.io.tool_warning(f"No changes made: replacement text is identical to original") return f"Warning: No changes made (replacement identical to original)" - - # Generate diff for feedback (assuming _generate_diff_snippet is available on coder) - diff_example = coder._generate_diff_snippet(original_content, start_index, len(find_text), replace_text) - # Handle dry run - if dry_run: - coder.io.tool_output(f"Dry run: Would replace occurrence {occurrence} of '{find_text}' in {file_path}") - return f"Dry run: Would replace text (occurrence {occurrence}). Diff snippet:\n{diff_example}" - - # --- Apply Change (Not dry run) --- - coder.io.write_text(abs_path, new_content) - - # Track the change - try: - metadata = { - 'start_index': start_index, - 'find_text': find_text, - 'replace_text': replace_text, - 'near_context': near_context, - 'occurrence': occurrence - } - change_id = coder.change_tracker.track_change( - file_path=rel_path, - change_type='replacetext', - original_content=original_content, - new_content=new_content, - metadata=metadata, - change_id=change_id - ) - except Exception as track_e: - coder.io.tool_error(f"Error tracking change for ReplaceText: {track_e}") - change_id = "TRACKING_FAILED" - - coder.aider_edited_files.add(rel_path) - - # Improve feedback + # 5. Generate diff for feedback + # Note: _generate_diff_snippet is currently on the Coder class + diff_snippet = coder._generate_diff_snippet(original_content, start_index, len(find_text), replace_text) occurrence_str = f"occurrence {occurrence}" if num_occurrences > 1 else "text" - coder.io.tool_output(f"✅ Replaced {occurrence_str} in {file_path} (change_id: {change_id})") - return f"Successfully replaced {occurrence_str} (change_id: {change_id}). Diff snippet:\n{diff_example}" - + + # 6. Handle dry run + if dry_run: + dry_run_message = f"Dry run: Would replace {occurrence_str} of '{find_text}' in {file_path}." + return format_tool_result(coder, tool_name, "", dry_run=True, dry_run_message=dry_run_message, diff_snippet=diff_snippet) + + # 7. Apply Change (Not dry run) + metadata = { + 'start_index': start_index, + 'find_text': find_text, + 'replace_text': replace_text, + 'near_context': near_context, + 'occurrence': occurrence + } + final_change_id = apply_change( + coder, abs_path, rel_path, original_content, new_content, 'replacetext', metadata, change_id + ) + + # 8. Format and return result + success_message = f"Replaced {occurrence_str} in {file_path}" + return format_tool_result( + coder, tool_name, success_message, change_id=final_change_id, diff_snippet=diff_snippet + ) + + except ToolError as e: + # Handle errors raised by utility functions or explicitly raised here + return handle_tool_error(coder, tool_name, e, add_traceback=False) except Exception as e: - coder.io.tool_error(f"Error in ReplaceText: {str(e)}\n{traceback.format_exc()}") - return f"Error: {str(e)}" + # Handle unexpected errors + return handle_tool_error(coder, tool_name, e) diff --git a/aider/tools/tool_utils.py b/aider/tools/tool_utils.py new file mode 100644 index 000000000..339c0af0c --- /dev/null +++ b/aider/tools/tool_utils.py @@ -0,0 +1,192 @@ +import os +import traceback + +class ToolError(Exception): + """Custom exception for tool-specific errors that should be reported to the LLM.""" + pass + +def resolve_paths(coder, file_path): + """Resolves absolute and relative paths for a given file path.""" + try: + abs_path = coder.abs_root_path(file_path) + rel_path = coder.get_rel_fname(abs_path) + return abs_path, rel_path + except Exception as e: + # Wrap unexpected errors during path resolution + raise ToolError(f"Error resolving path '{file_path}': {e}") + +def validate_file_for_edit(coder, file_path): + """ + Validates if a file exists, is in context, and is editable. + Reads and returns original content if valid. + Raises ToolError on failure. + + Returns: + tuple: (absolute_path, relative_path, original_content) + """ + abs_path, rel_path = resolve_paths(coder, file_path) + + if not os.path.isfile(abs_path): + raise ToolError(f"File '{file_path}' not found") + + if abs_path not in coder.abs_fnames: + if abs_path in coder.abs_read_only_fnames: + raise ToolError(f"File '{file_path}' is read-only. Use MakeEditable first.") + else: + # File exists but is not in context at all + raise ToolError(f"File '{file_path}' not in context. Use View or MakeEditable first.") + + # Reread content immediately before potential modification + content = coder.io.read_text(abs_path) + if content is None: + # This indicates an issue reading a file we know exists and is in context + coder.io.tool_error(f"Internal error: Could not read file '{file_path}' which should be accessible.") + raise ToolError(f"Could not read file '{file_path}'") + + return abs_path, rel_path, content + +def find_pattern_indices(lines, pattern, near_context=None): + """Finds all line indices matching a pattern, optionally filtered by context.""" + indices = [] + for i, line in enumerate(lines): + if pattern in line: + if near_context: + # Check if near_context is within a window around the match + context_window_start = max(0, i - 5) # Check 5 lines before/after + context_window_end = min(len(lines), i + 6) + context_block = "\n".join(lines[context_window_start:context_window_end]) + if near_context in context_block: + indices.append(i) + else: + indices.append(i) + return indices + +def select_occurrence_index(indices, occurrence, pattern_desc="Pattern"): + """ + Selects the target 0-based index from a list of indices based on the 1-based occurrence parameter. + Raises ToolError if the pattern wasn't found or the occurrence is invalid. + """ + num_occurrences = len(indices) + if not indices: + raise ToolError(f"{pattern_desc} not found") + + try: + occurrence = int(occurrence) # Ensure occurrence is an integer + if occurrence == -1: # Last occurrence + if num_occurrences == 0: + raise ToolError(f"{pattern_desc} not found, cannot select last occurrence.") + target_idx = num_occurrences - 1 + elif 1 <= occurrence <= num_occurrences: + target_idx = occurrence - 1 # Convert 1-based to 0-based + else: + raise ToolError(f"Occurrence number {occurrence} is out of range for {pattern_desc}. Found {num_occurrences} occurrences.") + except ValueError: + raise ToolError(f"Invalid occurrence value: '{occurrence}'. Must be an integer.") + + return indices[target_idx] + +def determine_line_range(lines, start_pattern_line_index, end_pattern=None, line_count=None, pattern_desc="Block"): + """ + Determines the end line index based on end_pattern or line_count. + Raises ToolError if end_pattern is not found or line_count is invalid. + """ + start_line = start_pattern_line_index + end_line = -1 + + if end_pattern and line_count: + raise ToolError("Cannot specify both end_pattern and line_count") + + if end_pattern: + found_end = False + # Search from the start_line onwards for the end_pattern + for i in range(start_line, len(lines)): + if end_pattern in lines[i]: + end_line = i + found_end = True + break + if not found_end: + raise ToolError(f"End pattern '{end_pattern}' not found after start pattern on line {start_line + 1}") + elif line_count: + try: + line_count = int(line_count) + if line_count <= 0: + raise ValueError("Line count must be positive") + # Calculate end line index, ensuring it doesn't exceed file bounds + end_line = min(start_line + line_count - 1, len(lines) - 1) + except ValueError: + raise ToolError(f"Invalid line_count value: '{line_count}'. Must be a positive integer.") + else: + # If neither end_pattern nor line_count is given, the range is just the start line + end_line = start_line + + return start_line, end_line + + +def apply_change(coder, abs_path, rel_path, original_content, new_content, change_type, metadata, change_id=None): + """ + Writes the new content, tracks the change, and updates coder state. + Returns the final change ID. Raises ToolError on tracking failure. + """ + coder.io.write_text(abs_path, new_content) + try: + final_change_id = coder.change_tracker.track_change( + file_path=rel_path, + change_type=change_type, + original_content=original_content, + new_content=new_content, + metadata=metadata, + change_id=change_id + ) + except Exception as track_e: + # Log the error but also raise ToolError to inform the LLM + coder.io.tool_error(f"Error tracking change for {change_type}: {track_e}") + raise ToolError(f"Failed to track change: {track_e}") + + coder.aider_edited_files.add(rel_path) + return final_change_id + + +def handle_tool_error(coder, tool_name, e, add_traceback=True): + """Logs tool errors and returns a formatted error message for the LLM.""" + error_message = f"Error in {tool_name}: {str(e)}" + if add_traceback: + error_message += f"\n{traceback.format_exc()}" + coder.io.tool_error(error_message) + # Return only the core error message to the LLM for brevity + return f"Error: {str(e)}" + +def format_tool_result(coder, tool_name, success_message, change_id=None, diff_snippet=None, dry_run=False, dry_run_message=None): + """Formats the result message for tool execution.""" + if dry_run: + full_message = dry_run_message or f"Dry run: Would execute {tool_name}." + if diff_snippet: + full_message += f" Diff snippet:\n{diff_snippet}" + coder.io.tool_output(full_message) # Log the dry run action + return full_message + else: + # Use the provided success message, potentially adding change_id and diff + full_message = f"✅ {success_message}" + if change_id: + full_message += f" (change_id: {change_id})" + coder.io.tool_output(full_message) # Log the success action + + result_for_llm = f"Successfully executed {tool_name}." + if change_id: + result_for_llm += f" Change ID: {change_id}." + if diff_snippet: + result_for_llm += f" Diff snippet:\n{diff_snippet}" + return result_for_llm + +# Example usage within a hypothetical tool: +# try: +# abs_path, rel_path, original_content = validate_file_for_edit(coder, file_path) +# # ... tool specific logic to determine new_content and metadata ... +# if dry_run: +# return format_tool_result(coder, "MyTool", "", dry_run=True, diff_snippet=diff) +# +# change_id = apply_change(coder, abs_path, rel_path, original_content, new_content, 'mytool', metadata) +# return format_tool_result(coder, "MyTool", f"Applied change to {file_path}", change_id=change_id, diff_snippet=diff) +# except ToolError as e: +# return handle_tool_error(coder, "MyTool", e, add_traceback=False) # Don't need traceback for ToolErrors +# except Exception as e: +# return handle_tool_error(coder, "MyTool", e)