bots-garden/mini-mepublic Fork 0
main
Commits
Clone
git clone https://git.rickub.com/bots-garden/mini-me.git
git clone ssh://git@rickub.com/bots-garden/mini-me.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

edits.go · 122 lines · 4.0 KBGo Blame HistoryRaw
💾 Saved. d722711 k33g 4h ago1package fileedit
2
3import (
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.
11type 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.
17type 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.
31func 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.
74func 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.
100func 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.
106func 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}