Try factoring out some common functionality. Incremental commit.

This commit is contained in:
Amar Sood (tekacs) 2025-04-12 08:39:16 -04:00
parent 551b357559
commit c55d789c25
5 changed files with 422 additions and 455 deletions

View file

@ -1,171 +1,82 @@
import os
import traceback 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): 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). Delete a block of text between start_pattern and end_pattern (inclusive).
Uses utility functions for validation, finding lines, and applying changes.
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.
""" """
tool_name = "DeleteBlock"
try: try:
# Get absolute file path # 1. Validate file and get content
abs_path = coder.abs_root_path(file_path) abs_path, rel_path, original_content = validate_file_for_edit(coder, file_path)
rel_path = coder.get_rel_fname(abs_path) lines = original_content.splitlines()
# 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)
if not start_pattern_line_indices: # 2. Find the start line
err_msg = f"Start pattern '{start_pattern}' not found" pattern_desc = f"Start pattern '{start_pattern}'"
if near_context: err_msg += f" near context '{near_context}'" if near_context:
err_msg += f" in file '{file_path}'." pattern_desc += f" near context '{near_context}'"
coder.io.tool_error(err_msg) start_pattern_indices = find_pattern_indices(lines, start_pattern, near_context)
return f"Error: {err_msg}" start_line_idx = select_occurrence_index(start_pattern_indices, occurrence, pattern_desc)
# Select the occurrence for the start pattern # 3. Determine the end line, passing pattern_desc for better error messages
num_occurrences = len(start_pattern_line_indices) start_line, end_line = determine_line_range(
try: lines, start_line_idx, end_pattern, line_count, pattern_desc=pattern_desc
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}'"
start_line = start_pattern_line_indices[target_idx] # 4. Prepare the deletion
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
deleted_lines = lines[start_line:end_line+1] deleted_lines = lines[start_line:end_line+1]
new_lines = lines[:start_line] + lines[end_line+1:] new_lines = lines[:start_line] + lines[end_line+1:]
new_content = '\n'.join(new_lines) new_content = '\n'.join(new_lines)
if original_content == new_content: if original_content == new_content:
coder.io.tool_warning(f"No changes made: deletion would not change file") coder.io.tool_warning(f"No changes made: deletion would not change file")
return f"Warning: 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) 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 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})") num_occurrences = len(start_pattern_indices)
return f"Successfully deleted {num_deleted} lines (change_id: {change_id}). Diff snippet:\n{diff_snippet}" 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: except Exception as e:
coder.io.tool_error(f"Error in DeleteBlock: {str(e)}\n{traceback.format_exc()}") # Handle unexpected errors
return f"Error: {str(e)}" return handle_tool_error(coder, tool_name, e)

View file

@ -1,159 +1,87 @@
import os import os
import traceback 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): 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. Insert a block of text after or before a specified pattern using utility functions.
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.
""" """
tool_name = "InsertBlock"
try: try:
# Get absolute file path # 1. Validate parameters
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
if after_pattern and before_pattern: if after_pattern and before_pattern:
coder.io.tool_error("Cannot specify both after_pattern and before_pattern") raise ToolError("Cannot specify both after_pattern and before_pattern")
return "Error: Cannot specify both after_pattern and before_pattern"
if not after_pattern and not before_pattern: if not after_pattern and not before_pattern:
coder.io.tool_error("Must specify either after_pattern or before_pattern") raise ToolError("Must specify either after_pattern or before_pattern")
return "Error: Must specify either after_pattern or before_pattern"
# 2. Validate file and get content
# Split into lines for easier handling abs_path, rel_path, original_content = validate_file_for_edit(coder, file_path)
lines = file_content.splitlines() lines = original_content.splitlines()
original_content = file_content
# 3. Find the target line index
# Find occurrences of the pattern (either after_pattern or before_pattern)
pattern = after_pattern if after_pattern else before_pattern pattern = after_pattern if after_pattern else before_pattern
pattern_type = "after" if after_pattern else "before" pattern_type = "after" if after_pattern else "before"
pattern_desc = f"Pattern '{pattern}'"
# Find line indices containing the pattern if near_context:
pattern_line_indices = [] pattern_desc += f" near context '{near_context}'"
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)
if not pattern_line_indices: pattern_line_indices = find_pattern_indices(lines, pattern, near_context)
err_msg = f"Pattern '{pattern}' not found" target_line_idx = select_occurrence_index(pattern_line_indices, occurrence, pattern_desc)
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}'"
# Determine the final insertion line index # Determine the final insertion line index
insertion_line_idx = pattern_line_indices[target_idx] insertion_line_idx = target_line_idx
if pattern_type == "after": if pattern_type == "after":
insertion_line_idx += 1 # Insert on the line *after* the matched line 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() content_lines = content.splitlines()
# Create the new lines array
new_lines = lines[:insertion_line_idx] + content_lines + lines[insertion_line_idx:] 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: if original_content == new_content:
coder.io.tool_warning(f"No changes made: insertion would not change file") coder.io.tool_warning(f"No changes made: insertion would not change file")
return f"Warning: 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) diff_snippet = coder._generate_diff_snippet_insert(original_content, insertion_line_idx, content_lines)
num_occurrences = len(pattern_line_indices)
# 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)
occurrence_str = f"occurrence {occurrence} of " if num_occurrences > 1 else "" 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: except Exception as e:
coder.io.tool_error(f"Error in InsertBlock: {str(e)}\n{traceback.format_exc()}") # Add traceback coder.io.tool_error(f"Error in InsertBlock: {str(e)}\n{traceback.format_exc()}") # Add traceback

View file

@ -1,95 +1,65 @@
import os
import traceback 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): 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. Replace all occurrences of text in a file using utility functions.
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.
""" """
# Get absolute file path
abs_path = coder.abs_root_path(file_path)
rel_path = coder.get_rel_fname(abs_path)
tool_name = "ReplaceAll"
try: try:
# Get absolute file path # 1. Validate file and get content
abs_path = coder.abs_root_path(file_path) abs_path, rel_path, original_content = validate_file_for_edit(coder, file_path)
rel_path = coder.get_rel_fname(abs_path)
# 2. Count occurrences
# Check if file exists count = original_content.count(find_text)
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)
if count == 0: 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" return f"Warning: Text not found in file"
# Perform the replacement # 3. Perform the replacement
original_content = content new_content = original_content.replace(find_text, replace_text)
new_content = content.replace(find_text, replace_text)
if original_content == new_content: if original_content == new_content:
coder.io.tool_warning(f"No changes made: replacement text is identical to original") coder.io.tool_warning(f"No changes made: replacement text is identical to original")
return f"Warning: No changes made (replacement 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) diff_examples = coder._generate_diff_chunks(original_content, find_text, replace_text)
# Handle dry run # 5. Handle dry run
if dry_run: if dry_run:
coder.io.tool_output(f"Dry run: Would replace {count} occurrences of '{find_text}' in {file_path}") dry_run_message = 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}" 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) --- # 6. Apply Change (Not dry run)
coder.io.write_text(abs_path, new_content) metadata = {
'find_text': find_text,
# Track the change 'replace_text': replace_text,
try: 'occurrences': count
metadata = { }
'find_text': find_text, final_change_id = apply_change(
'replace_text': replace_text, coder, abs_path, rel_path, original_content, new_content, 'replaceall', metadata, change_id
'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"
coder.aider_edited_files.add(rel_path) # 7. Format and return result
success_message = f"Replaced {count} occurrences in {file_path}"
# Improve feedback return format_tool_result(
coder.io.tool_output(f"✅ Replaced {count} occurrences in {file_path} (change_id: {change_id})") coder, tool_name, success_message, change_id=final_change_id, diff_snippet=diff_examples
return f"Successfully replaced {count} occurrences (change_id: {change_id}). Diff examples:\n{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: except Exception as e:
coder.io.tool_error(f"Error in ReplaceAll: {str(e)}\n{traceback.format_exc()}") # Handle unexpected errors
return f"Error: {str(e)}" return handle_tool_error(coder, tool_name, e)

View file

@ -1,125 +1,91 @@
import os
import traceback 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): 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. Replace specific text with new text, optionally using nearby context for disambiguation.
Uses utility functions for validation, finding occurrences, and applying changes.
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.
""" """
tool_name = "ReplaceText"
try: try:
# Get absolute file path # 1. Validate file and get content
abs_path = coder.abs_root_path(file_path) abs_path, rel_path, original_content = validate_file_for_edit(coder, file_path)
rel_path = coder.get_rel_fname(abs_path)
# 2. Find occurrences using helper function
# Check if file exists # Note: _find_occurrences is currently on the Coder class, not in tool_utils
if not os.path.isfile(abs_path): occurrences = coder._find_occurrences(original_content, find_text, near_context)
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)
if not occurrences: if not occurrences:
err_msg = f"Text '{find_text}' not found" err_msg = f"Text '{find_text}' not found"
if near_context: if near_context:
err_msg += f" near context '{near_context}'" err_msg += f" near context '{near_context}'"
err_msg += f" in file '{file_path}'." err_msg += f" in file '{file_path}'."
coder.io.tool_error(err_msg) raise ToolError(err_msg)
return f"Error: {err_msg}"
# Select the occurrence # 3. Select the occurrence index
num_occurrences = len(occurrences) num_occurrences = len(occurrences)
try: try:
occurrence = int(occurrence) occurrence = int(occurrence)
if occurrence == -1: if occurrence == -1:
if num_occurrences == 0:
raise ToolError(f"Text '{find_text}' not found, cannot select last occurrence.")
target_idx = num_occurrences - 1 target_idx = num_occurrences - 1
elif occurrence > 0 and occurrence <= num_occurrences: elif 1 <= occurrence <= num_occurrences:
target_idx = occurrence - 1 target_idx = occurrence - 1 # Convert 1-based to 0-based
else: else:
err_msg = f"Occurrence number {occurrence} is out of range. Found {num_occurrences} occurrences of '{find_text}'" 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}'" if near_context: err_msg += f" near '{near_context}'"
err_msg += f" in '{file_path}'." err_msg += f" in '{file_path}'."
coder.io.tool_error(err_msg) raise ToolError(err_msg)
return f"Error: {err_msg}"
except ValueError: except ValueError:
coder.io.tool_error(f"Invalid occurrence value: '{occurrence}'. Must be an integer.") raise ToolError(f"Invalid occurrence value: '{occurrence}'. Must be an integer.")
return f"Error: Invalid occurrence value '{occurrence}'"
start_index = occurrences[target_idx] start_index = occurrences[target_idx]
# Perform the replacement # 4. Perform the replacement
original_content = content new_content = original_content[:start_index] + replace_text + original_content[start_index + len(find_text):]
new_content = content[:start_index] + replace_text + content[start_index + len(find_text):]
if original_content == new_content: if original_content == new_content:
coder.io.tool_warning(f"No changes made: replacement text is identical to original") coder.io.tool_warning(f"No changes made: replacement text is identical to original")
return f"Warning: No changes made (replacement 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 # 5. Generate diff for feedback
if dry_run: # Note: _generate_diff_snippet is currently on the Coder class
coder.io.tool_output(f"Dry run: Would replace occurrence {occurrence} of '{find_text}' in {file_path}") diff_snippet = coder._generate_diff_snippet(original_content, start_index, len(find_text), replace_text)
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
occurrence_str = f"occurrence {occurrence}" if num_occurrences > 1 else "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: except Exception as e:
coder.io.tool_error(f"Error in ReplaceText: {str(e)}\n{traceback.format_exc()}") # Handle unexpected errors
return f"Error: {str(e)}" return handle_tool_error(coder, tool_name, e)

192
aider/tools/tool_utils.py Normal file
View file

@ -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)