1package tools
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "os"
9 "strings"
10
11 "github.com/bnema/zut/packages/agent/lsp"
12 "github.com/bnema/zut/packages/core"
13 "github.com/bnema/zut/packages/provider"
14)
15
16// EditTool applies one or more exact-match replacements to a file.
17type EditTool struct {
18 CWD string
19 Sandbox *Sandbox
20 LSP *lsp.Manager
21 LSPDiagnostics bool
22}
23
24type editOp struct {
25 OldText string `json:"oldText"`
26 NewText string `json:"newText"`
27}
28
29type editArgs struct {
30 Path string `json:"path"`
31 Edits []editOp `json:"edits"`
32}
33
34const editSchema = `{"type":"object","properties":{"path":{"type":"string","description":"File to modify, given as an absolute path or one relative to the working directory."},"edits":{"type":"array","description":"A set of non-overlapping substitutions, all located in the file content as it existed before this call.","items":{"type":"object","properties":{"oldText":{"type":"string","description":"A verbatim excerpt from the file being modified. Include enough surrounding text to identify exactly one location, without intersecting another edit."},"newText":{"type":"string","description":"Content that will replace the matched excerpt."}},"required":["oldText","newText"]}}},"required":["path","edits"]}`
35
36func (t *EditTool) Name() string { return "edit" }
37func (t *EditTool) Description() string {
38 return "Apply exact substitutions to an existing file. Inspect that file before editing and take every oldText directly from its current contents. Use short excerpts that identify one location; choose write when replacing most or all of a file."
39}
40func (t *EditTool) Schema() json.RawMessage { return json.RawMessage(editSchema) }
41
42type editPlan struct {
43 path string
44 final []byte
45 result core.ToolResult
46}
47
48// Preview validates the edit against the current file and returns the exact
49// diff without writing anything. The confirmation UI shows this result before
50// the user decides whether Execute may apply it.
51func (t *EditTool) Preview(ctx context.Context, raw json.RawMessage) (core.ToolResult, error) {
52 plan, err := t.plan(raw)
53 if err != nil {
54 return core.ToolResult{}, err
55 }
56 return plan.result, nil
57}
58
59func (t *EditTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
60 plan, err := t.plan(raw)
61 if err != nil {
62 return core.ToolResult{}, err
63 }
64 if err := os.WriteFile(plan.path, plan.final, 0o644); err != nil {
65 return core.ToolResult{}, err
66 }
67 if t.LSPDiagnostics {
68 attachWriteDiagnostics(ctx, t.CWD, plan.path, t.LSP, &plan.result)
69 }
70 return plan.result, nil
71}
72
73func (t *EditTool) plan(raw json.RawMessage) (editPlan, error) {
74 var a editArgs
75 if err := json.Unmarshal(raw, &a); err != nil {
76 return editPlan{}, fmt.Errorf("invalid args: %w", err)
77 }
78 if a.Path == "" {
79 return editPlan{}, fmt.Errorf("path is required")
80 }
81 if len(a.Edits) == 0 {
82 return editPlan{}, fmt.Errorf("at least one edit is required")
83 }
84 path := resolvePath(t.CWD, a.Path)
85 if err := t.Sandbox.CheckWritePath(path); err != nil {
86 return editPlan{}, err
87 }
88
89 orig, err := os.ReadFile(path)
90 if err != nil {
91 return editPlan{}, err
92 }
93
94 // Detect BOM and line endings.
95 var bom []byte
96 if bytes.HasPrefix(orig, []byte{0xEF, 0xBB, 0xBF}) {
97 bom = orig[:3]
98 orig = orig[3:]
99 }
100 nl := detectLineEnding(orig)
101 // Normalize to \n for matching.
102 body := string(bytes.ReplaceAll(orig, []byte("\r\n"), []byte("\n")))
103
104 // Validate all edits first (against original content, not sequentially).
105 for i, e := range a.Edits {
106 if e.OldText == "" {
107 return editPlan{}, fmt.Errorf("edit %d: oldText must not be empty", i+1)
108 }
109 if e.OldText == e.NewText {
110 return editPlan{}, fmt.Errorf("edit %d: oldText equals newText", i+1)
111 }
112 count := strings.Count(body, e.OldText)
113 if count == 0 {
114 return editPlan{}, fmt.Errorf("edit %d: oldText not found in %s; inspect that file again and use a verbatim excerpt with matching spaces and line breaks", i+1, a.Path)
115 }
116 if count > 1 {
117 return editPlan{}, fmt.Errorf("edit %d: oldText matches %d times (must be unique) in %s", i+1, count, a.Path)
118 }
119 }
120
121 // Apply atomically (sorted by position so offsets stay valid as we splice).
122 type span struct {
123 start, end int
124 replacement string
125 }
126 spans := make([]span, 0, len(a.Edits))
127 for _, e := range a.Edits {
128 idx := strings.Index(body, e.OldText)
129 spans = append(spans, span{start: idx, end: idx + len(e.OldText), replacement: e.NewText})
130 }
131 // Check for overlaps.
132 for i := 0; i < len(spans); i++ {
133 for j := i + 1; j < len(spans); j++ {
134 if spans[i].start < spans[j].end && spans[j].start < spans[i].end {
135 return editPlan{}, fmt.Errorf("edits overlap; merge them into one edit")
136 }
137 }
138 }
139 // Sort ascending.
140 for i := 1; i < len(spans); i++ {
141 for j := i; j > 0 && spans[j-1].start > spans[j].start; j-- {
142 spans[j-1], spans[j] = spans[j], spans[j-1]
143 }
144 }
145 var out strings.Builder
146 prev := 0
147 for _, s := range spans {
148 out.WriteString(body[prev:s.start])
149 out.WriteString(s.replacement)
150 prev = s.end
151 }
152 out.WriteString(body[prev:])
153 newBody := out.String()
154
155 // Restore line endings.
156 if nl == "\r\n" {
157 newBody = strings.ReplaceAll(newBody, "\n", "\r\n")
158 }
159 final := append([]byte{}, bom...)
160 final = append(final, []byte(newBody)...)
161
162 diff := unifiedDiff(a.Path, string(orig), strings.ReplaceAll(newBody, "\r\n", "\n"))
163 // The tool-call header renders the path above the result, so the
164 // result body is just the context diff. Details carries metadata for
165 // programmatic consumers and confirmation previews.
166 result := core.ToolResult{
167 Content: []provider.Content{provider.TextBlock{Text: diff}},
168 Details: map[string]any{"path": path, "edits": len(a.Edits), "diff": diff},
169 }
170 return editPlan{path: path, final: final, result: result}, nil
171}
172
173func detectLineEnding(b []byte) string {
174 if bytes.Contains(b, []byte("\r\n")) {
175 return "\r\n"
176 }
177 return "\n"
178}
179
180// diffContextLines is the number of unchanged lines kept on each
181// side of an edit when rendering the diff. 3 is the git-diff
182// default and balances readability with transcript size.
183const diffContextLines = 3
184
185// unifiedDiff emits a context diff for the edit tool's result.
186//
187// Shape: each output row is either
188// - " <line>" unchanged context
189// - "-<line>" deletion (from a)
190// - "+<line>" addition (to b)
191// - "..." context break between hunks
192//
193// The legacy "--- name / +++ name" header is omitted because the
194// tool-call header above the result already shows the path. Only
195// lines within diffContextLines of a +/- row are kept; longer
196// runs of unchanged content collapse into a single "..." row so
197// a one-line edit in a thousand-line file produces a short
198// transcript.
199func unifiedDiff(name, a, b string) string {
200 if a == b {
201 return ""
202 }
203 aLines := strings.Split(a, "\n")
204 bLines := strings.Split(b, "\n")
205 ops := diffLines(aLines, bLines)
206
207 // Mark ops that sit within diffContextLines of any +/- op.
208 keep := make([]bool, len(ops))
209 for i, op := range ops {
210 if op.kind == '+' || op.kind == '-' {
211 keep[i] = true
212 for d := 1; d <= diffContextLines; d++ {
213 if i-d >= 0 {
214 keep[i-d] = true
215 }
216 if i+d < len(ops) {
217 keep[i+d] = true
218 }
219 }
220 }
221 }
222
223 var sb strings.Builder
224 prevKept := false
225 anyOutput := false
226 for i, op := range ops {
227 if !keep[i] {
228 if prevKept {
229 sb.WriteString("...\n")
230 prevKept = false
231 }
232 continue
233 }
234 if !prevKept && anyOutput {
235 sb.WriteString("...\n")
236 }
237 switch op.kind {
238 case ' ':
239 fmt.Fprintf(&sb, " %s\n", op.line)
240 case '-':
241 fmt.Fprintf(&sb, "-%s\n", op.line)
242 case '+':
243 fmt.Fprintf(&sb, "+%s\n", op.line)
244 }
245 prevKept = true
246 anyOutput = true
247 }
248 _ = name // header dropped; kept in signature for call-site stability
249 return sb.String()
250}
251
252type diffOp struct {
253 kind byte
254 line string
255}
256
257func diffLines(a, b []string) []diffOp {
258 // LCS table.
259 m, n := len(a), len(b)
260 dp := make([][]int, m+1)
261 for i := range dp {
262 dp[i] = make([]int, n+1)
263 }
264 for i := 0; i < m; i++ {
265 for j := 0; j < n; j++ {
266 if a[i] == b[j] {
267 dp[i+1][j+1] = dp[i][j] + 1
268 } else if dp[i+1][j] >= dp[i][j+1] {
269 dp[i+1][j+1] = dp[i+1][j]
270 } else {
271 dp[i+1][j+1] = dp[i][j+1]
272 }
273 }
274 }
275 // Backtrack.
276 var ops []diffOp
277 i, j := m, n
278 for i > 0 && j > 0 {
279 if a[i-1] == b[j-1] {
280 ops = append([]diffOp{{' ', a[i-1]}}, ops...)
281 i--
282 j--
283 } else if dp[i][j-1] >= dp[i-1][j] {
284 ops = append([]diffOp{{'+', b[j-1]}}, ops...)
285 j--
286 } else {
287 ops = append([]diffOp{{'-', a[i-1]}}, ops...)
288 i--
289 }
290 }
291 for i > 0 {
292 ops = append([]diffOp{{'-', a[i-1]}}, ops...)
293 i--
294 }
295 for j > 0 {
296 ops = append([]diffOp{{'+', b[j-1]}}, ops...)
297 j--
298 }
299 return ops
300}