bots-garden/mini-mepublic Fork 0
d72271127802973540c648bfb372176cdaaa8e4f
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.

fileedit.go · 166 lines · 5.2 KBGo Blame HistoryRaw
💾 Saved. d722711 k33g 7h ago1package fileedit
2
3import (
4 "fmt"
5 "strings"
6)
7
8// Result is what a write or a replace reports — the fields the `edit` CLI
9// prints and the Pi tool returns: a headline, the readable diff, the unified
10// patch, the first changed line. Headline + Diff is what goes back to the
11// model; the caller decides what the screen shows.
12type Result struct {
13 Path string
14 Created bool
15 Changed bool
16 DryRun bool
17 Edits int
18 Added, Deleted int
19 FirstChangedLine int
20 Headline string
21 Diff string
22 Patch string
23}
24
25// Read returns the file, or a slice of it (1-based, inclusive; 0 = from the
26// start / to the end). It is the mandatory companion of Replace: an exact
27// replacement assumes the exact text was READ, not remembered.
28func Read(path string, start, end int, numbered bool) (string, error) {
29 f, err := load(path)
30 if err != nil {
31 return "", fmt.Errorf("cannot read %s: %v", path, err)
32 }
33 if f.isNew {
34 return "", fmt.Errorf("%s does not exist. Create it with write_file", path)
35 }
36
37 lines, finalNewline := splitLines(f.body)
38 from, to, err := bounds(start, end, len(lines))
39 if err != nil {
40 return "", fmt.Errorf("%s: %v", path, err)
41 }
42 slice := lines[from-1 : to]
43
44 if numbered {
45 var b strings.Builder
46 for i, l := range slice {
47 fmt.Fprintf(&b, "%6d %s\n", from+i, l)
48 }
49 return b.String(), nil
50 }
51 body := strings.Join(slice, "\n")
52 // The final newline is only put back if it existed AND the end of the file
53 // is shown: otherwise a slice would invent a line the file does not have.
54 if finalNewline || to < len(lines) {
55 body += "\n"
56 }
57 return body, nil
58}
59
60// bounds turns start/end into a valid slice, or says why it is not one.
61func bounds(start, end, total int) (int, int, error) {
62 if total == 0 {
63 return 1, 0, nil // empty file: nothing to show, not an error
64 }
65 if start == 0 {
66 start = 1
67 }
68 if end == 0 {
69 end = total
70 }
71 if start < 1 || end < 1 {
72 return 0, 0, fmt.Errorf("start and end are 1-based line numbers")
73 }
74 if start > total {
75 return 0, 0, fmt.Errorf("start %d is past the end of the file (%d lines)", start, total)
76 }
77 if end > total {
78 end = total
79 }
80 if start > end {
81 return 0, 0, fmt.Errorf("start %d comes after end %d", start, end)
82 }
83 return start, end, nil
84}
85
86// Write writes the WHOLE file. The tool for a new file or a deliberate
87// rewrite; to change three lines of an existing file, Replace is safer — it
88// fails when the targeted text is not what one thought, where Write overwrites
89// without checking.
90func Write(path, content string) (Result, error) {
91 f, err := load(path)
92 if err != nil {
93 return Result{}, fmt.Errorf("cannot read %s: %v", path, err)
94 }
95 // A content without a final newline would give a file without one: on a
96 // text file that is almost always an accident, and every diff tool then
97 // flags it.
98 if content != "" && !strings.HasSuffix(content, "\n") {
99 content += "\n"
100 }
101 return finish(Result{Path: path, Created: f.isNew}, f, content)
102}
103
104// Replace applies exact replacements to an existing file — the operation that
105// matters: it changes a file without rewriting it, and refuses anything
106// ambiguous. With dryRun the diff is computed and nothing is written.
107func Replace(path string, edits []Edit, dryRun bool) (Result, error) {
108 f, err := load(path)
109 if err != nil {
110 return Result{}, fmt.Errorf("cannot read %s: %v", path, err)
111 }
112 if f.isNew {
113 return Result{}, fmt.Errorf("%s does not exist — nothing to replace. Create it with write_file", path)
114 }
115 body, err := apply(f.body, edits)
116 if err != nil {
117 // The error carries the file name: it is read out of context, in a
118 // tool result among others.
119 return Result{}, fmt.Errorf("%s: %v", path, err)
120 }
121 return finish(Result{Path: path, Edits: len(edits), DryRun: dryRun}, f, body)
122}
123
124// finish compares, writes if needed, and reports. Write and Replace both end
125// here: that is what gives them exactly the same output, hence one format for
126// the model to learn.
127func finish(r Result, f *file, body string) (Result, error) {
128 c := compare(f.body, body)
129 r.Changed = c.changed()
130 r.Added, r.Deleted = c.stat()
131 r.FirstChangedLine = c.firstChanged()
132 r.Diff = c.render()
133 r.Patch = c.unified(r.Path)
134
135 // Nothing to write: SAY so rather than touch the file. An identical
136 // rewrite would bump the modification time and wake every watcher, for
137 // nothing.
138 if r.Changed && !r.DryRun {
139 if err := f.write(body); err != nil {
140 return Result{}, fmt.Errorf("cannot write %s: %v", r.Path, err)
141 }
142 }
143 r.Headline = headline(r)
144 return r, nil
145}
146
147// headline sums the operation up in one line — the one the model re-reads in
148// its tool result, and the one the screen shows under the display line.
149func headline(r Result) string {
150 switch {
151 case !r.Changed:
152 return fmt.Sprintf("%s: unchanged", r.Path)
153 case r.Created && r.DryRun:
154 return fmt.Sprintf("%s: would be created, %d line(s)", r.Path, r.Added)
155 case r.Created:
156 return fmt.Sprintf("%s: created, %d line(s)", r.Path, r.Added)
157 }
158 what := fmt.Sprintf("+%d -%d, first change at line %d", r.Added, r.Deleted, r.FirstChangedLine)
159 if r.Edits > 0 {
160 what = fmt.Sprintf("%d edit(s) applied, %s", r.Edits, what)
161 }
162 if r.DryRun {
163 return fmt.Sprintf("%s: dry run — nothing written (%s)", r.Path, what)
164 }
165 return fmt.Sprintf("%s: %s", r.Path, what)
166}