feat: Add file editing tools with replace, delete, move, and write operations, featuring hashline anchor validation and input sanitization.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using AnchorCli.OpenRouter;
|
||||
using AnchorCli.Tools;
|
||||
|
||||
namespace AnchorCli;
|
||||
|
||||
@@ -19,6 +20,8 @@ namespace AnchorCli;
|
||||
[JsonSerializable(typeof(Microsoft.Extensions.AI.ChatMessage))]
|
||||
[JsonSerializable(typeof(System.Collections.Generic.List<Microsoft.Extensions.AI.ChatMessage>))]
|
||||
[JsonSerializable(typeof(AnchorConfig))]
|
||||
[JsonSerializable(typeof(BatchOperation))]
|
||||
[JsonSerializable(typeof(BatchOperation[]))]
|
||||
[JsonSourceGenerationOptions(
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
|
||||
|
||||
@@ -16,6 +16,7 @@ internal static class ToolRegistry
|
||||
AIFunctionFactory.Create(FileTools.ListDir, serializerOptions: jsonOptions),
|
||||
AIFunctionFactory.Create(EditTools.ReplaceLines, serializerOptions: jsonOptions),
|
||||
AIFunctionFactory.Create(EditTools.DeleteRange, serializerOptions: jsonOptions),
|
||||
AIFunctionFactory.Create(EditTools.BatchEdit, serializerOptions: jsonOptions),
|
||||
AIFunctionFactory.Create(EditTools.Delete, serializerOptions: jsonOptions),
|
||||
AIFunctionFactory.Create(FileTools.FindFiles, serializerOptions: jsonOptions),
|
||||
AIFunctionFactory.Create(FileTools.GetFileInfo, serializerOptions: jsonOptions),
|
||||
|
||||
@@ -4,6 +4,15 @@ using AnchorCli.Hashline;
|
||||
|
||||
namespace AnchorCli.Tools;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single operation within a batch edit.
|
||||
public record BatchOperation(
|
||||
[property: Description("Operation type: 'replace' or 'delete'")] string Type,
|
||||
[property: Description("First line's line:hash anchor (e.g. '5:a3')")] string? StartAnchor,
|
||||
[property: Description("Last line's line:hash anchor (e.g. '8:d4')")] string? EndAnchor,
|
||||
[property: Description("Raw source code to insert. Required for 'replace' operations.")] string[]? NewLines);
|
||||
|
||||
/// <summary>
|
||||
/// Mutating file tools exposed to the LLM as AIFunctions.
|
||||
/// Every operation validates Hashline anchors (line:hash format) before touching the file.
|
||||
@@ -283,6 +292,102 @@ internal static partial class EditTools
|
||||
}
|
||||
}
|
||||
|
||||
[Description("Apply multiple edits atomically. All anchors are validated before any changes are made.")]
|
||||
public static string BatchEdit(
|
||||
[Description("Path to the file.")] string path,
|
||||
[Description("Array of operations to apply. Operations are applied in bottom-to-top order automatically.")] BatchOperation[] operations)
|
||||
{
|
||||
path = FileTools.ResolvePath(path);
|
||||
Log($"BATCH_EDIT: {path}");
|
||||
Log($" Operations: {operations.Length}");
|
||||
|
||||
if (!File.Exists(path))
|
||||
return $"ERROR: File not found: {path}";
|
||||
|
||||
if (operations.Length == 0)
|
||||
return "ERROR: No operations provided";
|
||||
|
||||
try
|
||||
{
|
||||
// Read file once
|
||||
string[] lines = File.ReadAllLines(path);
|
||||
|
||||
// Pre-validate all anchors against original content (fail-fast)
|
||||
var resolvedOps = new List<(int StartIdx, int EndIdx, BatchOperation Op)>();
|
||||
for (int i = 0; i < operations.Length; i++)
|
||||
{
|
||||
var op = operations[i];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(op.Type))
|
||||
return $"ERROR: Operation {i}: Type is required (use 'replace' or 'delete')";
|
||||
|
||||
var opType = op.Type.ToLowerInvariant();
|
||||
if (opType != "replace" && opType != "delete")
|
||||
return $"ERROR: Operation {i}: Invalid type '{op.Type}'. Must be 'replace' or 'delete'";
|
||||
|
||||
if (opType == "replace" && op.NewLines == null)
|
||||
return $"ERROR: Operation {i}: 'replace' requires NewLines";
|
||||
|
||||
if (string.IsNullOrEmpty(op.StartAnchor) || string.IsNullOrEmpty(op.EndAnchor))
|
||||
return $"ERROR: Operation {i}: StartAnchor and EndAnchor are required";
|
||||
|
||||
if (!HashlineValidator.TryResolveRange(op.StartAnchor, op.EndAnchor, lines,
|
||||
out int startIdx, out int endIdx, out string error))
|
||||
return $"ERROR: Operation {i}: Anchor validation failed\n{error}";
|
||||
|
||||
resolvedOps.Add((startIdx, endIdx, op));
|
||||
}
|
||||
|
||||
// Check for overlapping ranges (conflicting operations)
|
||||
for (int i = 0; i < resolvedOps.Count; i++)
|
||||
{
|
||||
for (int j = i + 1; j < resolvedOps.Count; j++)
|
||||
{
|
||||
var (startA, endA, _) = resolvedOps[i];
|
||||
var (startB, endB, _) = resolvedOps[j];
|
||||
|
||||
if (!(endA < startB || endB < startA))
|
||||
return $"ERROR: Operations {i} and {j} have overlapping ranges. " +
|
||||
$"Range [{startA}-{endA}] overlaps with [{startB}-{endB}].";
|
||||
}
|
||||
}
|
||||
|
||||
// Sort operations bottom-to-top (by start index descending) for safe application
|
||||
var sortedOps = resolvedOps.OrderByDescending(x => x.StartIdx).ToList();
|
||||
|
||||
// Apply all operations to a single buffer
|
||||
var result = new List<string>(lines.Length);
|
||||
int nextLineIdx = 0;
|
||||
|
||||
foreach (var (startIdx, endIdx, op) in sortedOps)
|
||||
{
|
||||
// Copy lines before this operation
|
||||
if (startIdx > nextLineIdx)
|
||||
result.AddRange(lines[nextLineIdx..startIdx]);
|
||||
|
||||
// Apply operation
|
||||
var opType = op.Type.ToLowerInvariant();
|
||||
if (opType == "replace")
|
||||
result.AddRange(SanitizeNewLines(op.NewLines!));
|
||||
// delete: don't add anything (skip the range)
|
||||
|
||||
nextLineIdx = endIdx + 1;
|
||||
}
|
||||
|
||||
// Copy remaining lines after the last operation
|
||||
if (nextLineIdx < lines.Length)
|
||||
result.AddRange(lines[nextLineIdx..]);
|
||||
|
||||
// Write file once
|
||||
File.WriteAllLines(path, result);
|
||||
return $"OK fp:{HashlineEncoder.FileFingerprint([.. result])}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"ERROR batch editing '{path}': {ex.Message}\nThis is a bug. Tell the user about it.";
|
||||
}
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^\d+:[0-9a-fA-F]{2}\|", RegexOptions.Compiled)]
|
||||
private static partial Regex MyRegex();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user