Add a tool to extract lines from a file into others

This commit is contained in:
Amar Sood (tekacs) 2025-04-12 03:22:01 -04:00
parent 643442019a
commit dea5bd54f2
2 changed files with 242 additions and 1 deletions

View file

@ -701,7 +701,25 @@ class NavigatorCoder(Coder):
limit = params.get('limit', 10)
result_message = self._execute_list_changes(file_path, limit)
elif norm_tool_name == 'extractlines':
source_file_path = params.get('source_file_path')
target_file_path = params.get('target_file_path')
start_pattern = params.get('start_pattern')
end_pattern = params.get('end_pattern')
line_count = params.get('line_count')
near_context = params.get('near_context')
occurrence = params.get('occurrence', 1)
dry_run = params.get('dry_run', False)
if source_file_path and target_file_path and start_pattern:
result_message = self._execute_extract_lines(
source_file_path, target_file_path, start_pattern, end_pattern,
line_count, near_context, occurrence, dry_run
)
else:
result_message = "Error: Missing required parameters for ExtractLines (source_file_path, target_file_path, start_pattern)"
else:
result_message = f"Error: Unknown tool name '{tool_name}'"
@ -2809,6 +2827,225 @@ Just reply with fixed versions of the {blocks} above that failed to match.
self.io.tool_error(f"Error in ListChanges: {str(e)}\n{traceback.format_exc()}") # Add traceback
return f"Error: {str(e)}"
def _execute_extract_lines(self, source_file_path, target_file_path, start_pattern, end_pattern=None, line_count=None, near_context=None, occurrence=1, dry_run=False):
"""
Extract a range of lines from a source file and move them to a target file.
Parameters:
- source_file_path: Path to the file to extract lines from
- target_file_path: Path to the file to append extracted lines to (will be created if needed)
- start_pattern: Pattern marking the start of the block to extract
- end_pattern: Optional pattern marking the end of the block
- line_count: Optional number of lines to extract (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)
- dry_run: If True, simulate the change without modifying files
Returns a result message.
"""
try:
# --- Validate Source File ---
abs_source_path = self.abs_root_path(source_file_path)
rel_source_path = self.get_rel_fname(abs_source_path)
if not os.path.isfile(abs_source_path):
self.io.tool_error(f"Source file '{source_file_path}' not found")
return f"Error: Source file not found"
if abs_source_path not in self.abs_fnames:
if abs_source_path in self.abs_read_only_fnames:
self.io.tool_error(f"Source file '{source_file_path}' is read-only. Use MakeEditable first.")
return f"Error: Source file is read-only. Use MakeEditable first."
else:
self.io.tool_error(f"Source file '{source_file_path}' not in context")
return f"Error: Source file not in context"
# --- Validate Target File ---
abs_target_path = self.abs_root_path(target_file_path)
rel_target_path = self.get_rel_fname(abs_target_path)
target_exists = os.path.isfile(abs_target_path)
target_is_editable = abs_target_path in self.abs_fnames
target_is_readonly = abs_target_path in self.abs_read_only_fnames
if target_exists and not target_is_editable:
if target_is_readonly:
self.io.tool_error(f"Target file '{target_file_path}' exists but is read-only. Use MakeEditable first.")
return f"Error: Target file exists but is read-only. Use MakeEditable first."
else:
# This case shouldn't happen if file exists, but handle defensively
self.io.tool_error(f"Target file '{target_file_path}' exists but is not in context. Add it first.")
return f"Error: Target file exists but is not in context."
# --- Read Source Content ---
source_content = self.io.read_text(abs_source_path)
if source_content is None:
self.io.tool_error(f"Could not read source file '{source_file_path}' before ExtractLines operation.")
return f"Error: Could not read source file '{source_file_path}'"
# --- Find Extraction Range ---
if end_pattern and line_count:
self.io.tool_error("Cannot specify both end_pattern and line_count")
return "Error: Cannot specify both end_pattern and line_count"
source_lines = source_content.splitlines()
original_source_content = source_content
start_pattern_line_indices = []
for i, line in enumerate(source_lines):
if start_pattern in line:
if near_context:
context_window_start = max(0, i - 5)
context_window_end = min(len(source_lines), i + 6)
context_block = "\n".join(source_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:
err_msg = f"Start pattern '{start_pattern}' not found"
if near_context: err_msg += f" near context '{near_context}'"
err_msg += f" in source file '{source_file_path}'."
self.io.tool_error(err_msg)
return f"Error: {err_msg}"
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 '{source_file_path}'."
self.io.tool_error(err_msg)
return f"Error: {err_msg}"
except ValueError:
self.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]
occurrence_str = f"occurrence {occurrence} of " if num_occurrences > 1 else ""
end_line = -1
if end_pattern:
for i in range(start_line, len(source_lines)):
if end_pattern in source_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 '{source_file_path}'."
self.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(source_lines) - 1)
except ValueError:
self.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 # Extract just the start line if no end specified
# --- Prepare Content Changes ---
extracted_lines = source_lines[start_line:end_line+1]
new_source_lines = source_lines[:start_line] + source_lines[end_line+1:]
new_source_content = '\n'.join(new_source_lines)
target_content = ""
if target_exists:
target_content = self.io.read_text(abs_target_path)
if target_content is None:
self.io.tool_error(f"Could not read existing target file '{target_file_path}'.")
return f"Error: Could not read target file '{target_file_path}'"
original_target_content = target_content # For tracking
# Append extracted lines to target content, ensuring a newline if target wasn't empty
extracted_block = '\n'.join(extracted_lines)
if target_content and not target_content.endswith('\n'):
target_content += '\n' # Add newline before appending if needed
new_target_content = target_content + extracted_block
# --- Generate Diffs ---
source_diff_snippet = self._generate_diff_snippet_delete(original_source_content, start_line, end_line)
target_insertion_line = len(target_content.splitlines()) if target_content else 0
target_diff_snippet = self._generate_diff_snippet_insert(original_target_content, target_insertion_line, extracted_lines)
# --- Handle Dry Run ---
# --- Handle Dry Run ---
if dry_run:
num_extracted = end_line - start_line + 1
target_action = "append to" if target_exists else "create"
self.io.tool_output(f"Dry run: Would extract {num_extracted} lines (from {occurrence_str}start pattern '{start_pattern}') in {source_file_path} and {target_action} {target_file_path}")
# Provide more informative dry run response with diffs
return (
f"Dry run: Would extract {num_extracted} lines from {rel_source_path} and {target_action} {rel_target_path}.\n"
f"Source Diff (Deletion):\n{source_diff_snippet}\n"
f"Target Diff (Insertion):\n{target_diff_snippet}"
)
# --- Apply Changes (Not Dry Run) ---
self.io.write_text(abs_source_path, new_source_content)
self.io.write_text(abs_target_path, new_target_content)
# --- Track Changes ---
source_change_id = "TRACKING_FAILED"
target_change_id = "TRACKING_FAILED"
try:
source_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,
'extracted_content': extracted_block, 'target_file': rel_target_path
}
source_change_id = self.change_tracker.track_change(
file_path=rel_source_path, change_type='extractlines_source',
original_content=original_source_content, new_content=new_source_content,
metadata=source_metadata
)
except Exception as track_e:
self.io.tool_error(f"Error tracking source change for ExtractLines: {track_e}")
try:
target_metadata = {
'insertion_line': target_insertion_line + 1,
'inserted_content': extracted_block, 'source_file': rel_source_path
}
target_change_id = self.change_tracker.track_change(
file_path=rel_target_path, change_type='extractlines_target',
original_content=original_target_content, new_content=new_target_content,
metadata=target_metadata
)
except Exception as track_e:
self.io.tool_error(f"Error tracking target change for ExtractLines: {track_e}")
# --- Update Context ---
self.aider_edited_files.add(rel_source_path)
self.aider_edited_files.add(rel_target_path)
if not target_exists:
# Add the newly created file to editable context
self.abs_fnames.add(abs_target_path)
self.io.tool_output(f"✨ Created and added '{target_file_path}' to editable context.")
# --- Return Result ---
num_extracted = end_line - start_line + 1
target_action = "appended to" if target_exists else "created"
self.io.tool_output(f"✅ Extracted {num_extracted} lines from {rel_source_path} (change_id: {source_change_id}) and {target_action} {rel_target_path} (change_id: {target_change_id})")
# Provide more informative success response with change IDs and diffs
return (
f"Successfully extracted {num_extracted} lines from {rel_source_path} and {target_action} {rel_target_path}.\n"
f"Source Change ID: {source_change_id}\nSource Diff (Deletion):\n{source_diff_snippet}\n"
f"Target Change ID: {target_change_id}\nTarget Diff (Insertion):\n{target_diff_snippet}"
)
except Exception as e:
self.io.tool_error(f"Error in ExtractLines: {str(e)}\n{traceback.format_exc()}")
return f"Error: {str(e)}"
# ------------------- Diff Generation Helpers -------------------
def _generate_diff_snippet(self, original_content, start_index, replaced_len, replacement_text):

View file

@ -88,6 +88,10 @@ Act as an expert software engineer with the ability to autonomously navigate and
- **ListChanges**: `[tool_call(ListChanges, file_path="...", limit=5)]`
List recent changes, optionally filtered by `file_path` and limited.
- **ExtractLines**: `[tool_call(ExtractLines, source_file_path="...", target_file_path="...", start_pattern="...", end_pattern="...", near_context="...", occurrence=1, dry_run=False)]`
Extract lines from `start_pattern` to `end_pattern` (or use `line_count`) in `source_file_path` and move them to `target_file_path`. Creates `target_file_path` if it doesn't exist. Use `near_context` and `occurrence` (optional, default 1, -1 for last) for `start_pattern`. `dry_run=True` simulates.
*Useful for refactoring by moving functions, classes, or blocks of code into separate files.*
### Other Tools
- **Command**: `[tool_call(Command, command_string="git diff HEAD~1")]`
Execute a shell command. Requires user confirmation.