From 8b48b0f8664638cbda0ed48130dde63940efeab3 Mon Sep 17 00:00:00 2001 From: Tomi Eckert Date: Fri, 6 Mar 2026 02:48:43 +0100 Subject: [PATCH] feat: Add file editing tools with replace, delete, move, and write operations, featuring hashline anchor validation and input sanitization. --- AppJsonContext.cs | 3 ++ ToolRegistry.cs | 1 + Tools/EditTools.cs | 105 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+) diff --git a/AppJsonContext.cs b/AppJsonContext.cs index 6271390..d3727b7 100644 --- a/AppJsonContext.cs +++ b/AppJsonContext.cs @@ -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))] [JsonSerializable(typeof(AnchorConfig))] +[JsonSerializable(typeof(BatchOperation))] +[JsonSerializable(typeof(BatchOperation[]))] [JsonSourceGenerationOptions( DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] diff --git a/ToolRegistry.cs b/ToolRegistry.cs index cebda52..ab43888 100644 --- a/ToolRegistry.cs +++ b/ToolRegistry.cs @@ -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), diff --git a/Tools/EditTools.cs b/Tools/EditTools.cs index d434162..f4dfe25 100644 --- a/Tools/EditTools.cs +++ b/Tools/EditTools.cs @@ -4,6 +4,15 @@ using AnchorCli.Hashline; namespace AnchorCli.Tools; + +/// +/// 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); + /// /// 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(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(); }