| 💾 Saved. d722711 k33g 7h ago | 1 | package fileedit |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "sort" |
| 6 | "strings" |
| 7 | ) |
| 8 | |
| 9 | // Edit is one replacement: a text to find, a text to put in its place. The |
| 10 | // JSON names are the ones the model sees in the edit_file schema. |
| 11 | type Edit struct { |
| 12 | Old string `json:"old" jsonschema_description:"Exact text to find: copied from the file character for character, and present exactly once"` |
| 13 | New string `json:"new" jsonschema_description:"Replacement text; empty deletes the matched text"` |
| 14 | } |
| 15 | |
| 16 | // span is the region an edit occupies in the ORIGINAL body. |
| 17 | type span struct { |
| 18 | start, end int |
| 19 | index int // rank of the edit, for error messages |
| 20 | } |
| 21 | |
| 22 | // apply applies every edit to the body and returns the result. |
| 23 | // |
| 24 | // The rules of the `edit` CLI, kept to the letter because they are what makes |
| 25 | // an edit verifiable: each `old` appears once and only once; edits are resolved |
| 26 | // against the ORIGINAL body, never in cascade; two edits must not touch, nest |
| 27 | // or repeat; an empty `new` deletes. An ambiguous edit — two occurrences, two |
| 28 | // overlapping regions — would give a result that depends on application order, |
| 29 | // hence a file nobody has read. Nothing is written when one edit fails: a |
| 30 | // half-edited file is the worst outcome, because it looks like a success. |
| 31 | func apply(body string, list []Edit) (string, error) { |
| 32 | if len(list) == 0 { |
| 33 | return "", fmt.Errorf("no edit given: each edit needs an old and a new text") |
| 34 | } |
| 35 | |
| 36 | spans := make([]span, 0, len(list)) |
| 37 | for i, e := range list { |
| 38 | s, err := locate(body, e, i) |
| 39 | if err != nil { |
| 40 | return "", err |
| 41 | } |
| 42 | spans = append(spans, s) |
| 43 | } |
| 44 | |
| 45 | // Sorted by position: the overlap check and the rebuild both walk the file |
| 46 | // in its order, not in the order of the request. |
| 47 | sort.Slice(spans, func(i, j int) bool { return spans[i].start < spans[j].start }) |
| 48 | for i := 1; i < len(spans); i++ { |
| 49 | if spans[i].start < spans[i-1].end { |
| 50 | a, b := spans[i-1], spans[i] |
| 51 | return "", fmt.Errorf( |
| 52 | "edits #%d and #%d overlap (lines %d-%d and %d-%d): merge them into a single old/new pair", |
| 53 | a.index+1, b.index+1, |
| 54 | lineOf(body, a.start), lineOf(body, a.end), |
| 55 | lineOf(body, b.start), lineOf(body, b.end)) |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | var b strings.Builder |
| 60 | at := 0 |
| 61 | for _, s := range spans { |
| 62 | b.WriteString(body[at:s.start]) |
| 63 | b.WriteString(list[s.index].New) |
| 64 | at = s.end |
| 65 | } |
| 66 | b.WriteString(body[at:]) |
| 67 | return b.String(), nil |
| 68 | } |
| 69 | |
| 70 | // locate finds the single occurrence of an `old` text. |
| 71 | // |
| 72 | // The messages are written FOR A MODEL: they say what is wrong and the move |
| 73 | // that fixes it, because a model is what will retry the call. |
| 74 | func locate(body string, e Edit, i int) (span, error) { |
| 75 | if e.Old == "" { |
| 76 | return span{}, fmt.Errorf( |
| 77 | "edit #%d has an empty old text: it would match everywhere — use write_file to replace the whole file", |
| 78 | i+1) |
| 79 | } |
| 80 | if e.Old == e.New { |
| 81 | return span{}, fmt.Errorf("edit #%d changes nothing: old and new are identical", i+1) |
| 82 | } |
| 83 | |
| 84 | switch n := strings.Count(body, e.Old); { |
| 85 | case n == 0: |
| 86 | return span{}, fmt.Errorf( |
| 87 | "edit #%d: old text not found — it must match the file exactly, including indentation and line breaks:\n%s", |
| 88 | i+1, quote(e.Old)) |
| 89 | case n > 1: |
| 90 | return span{}, fmt.Errorf( |
| 91 | "edit #%d: old text appears %d times — add the surrounding lines until it is unique:\n%s", |
| 92 | i+1, n, quote(e.Old)) |
| 93 | } |
| 94 | |
| 95 | start := strings.Index(body, e.Old) |
| 96 | return span{start: start, end: start + len(e.Old), index: i}, nil |
| 97 | } |
| 98 | |
| 99 | // lineOf returns the 1-based line number of an offset in the body. |
| 100 | func lineOf(body string, offset int) int { |
| 101 | return 1 + strings.Count(body[:offset], "\n") |
| 102 | } |
| 103 | |
| 104 | // quote shows the searched text, indented, capped to its first lines: past |
| 105 | // that the error would drown the output and teach nothing more. |
| 106 | func quote(s string) string { |
| 107 | const maxLines = 6 |
| 108 | |
| 109 | lines := strings.Split(s, "\n") |
| 110 | shown := lines |
| 111 | if len(lines) > maxLines { |
| 112 | shown = lines[:maxLines] |
| 113 | } |
| 114 | var b strings.Builder |
| 115 | for _, l := range shown { |
| 116 | b.WriteString(" │ " + l + "\n") |
| 117 | } |
| 118 | if len(lines) > len(shown) { |
| 119 | fmt.Fprintf(&b, " └ … %d more line(s)\n", len(lines)-len(shown)) |
| 120 | } |
| 121 | return strings.TrimRight(b.String(), "\n") |
| 122 | } |