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.

diff.go · 367 lines · 9.6 KBGo Blame HistoryRaw
💾 Saved. d722711 k33g 4h ago1package fileedit
2
3import (
4 "fmt"
5 "strings"
6)
7
8// op is what happened to a line. The values are the characters of the unified
9// format, which saves a conversion table.
10type op byte
11
12const (
13 keep op = ' '
14 del op = '-'
15 add op = '+'
16)
17
18// line is one line of the diff. oldNo and newNo are 1-based, 0 when the line
19// does not exist on that side.
20type line struct {
21 op op
22 text string
23 oldNo int
24 newNo int
25}
26
27// comparison is a diff ready to render: the lines, and what splitting into
28// lines cannot say — whether each side ended with a newline. Two files that
29// differ only there are two different files, and `git apply` refuses a patch
30// that ignores it.
31type comparison struct {
32 lines []line
33 oldNL, newNL bool
34}
35
36// compare splits, compares, and returns what is needed to display and to patch.
37func compare(old, new string) comparison {
38 oldLines, oldNL := splitLines(old)
39 newLines, newNL := splitLines(new)
40 r := comparison{lines: diffLines(oldLines, newLines), oldNL: oldNL, newNL: newNL}
41
42 // Edge case: identical line by line, only the final newline moved. Nothing
43 // is marked changed and the patch would be empty while the file changes.
44 // The last line is replayed — deleted then added — which is exactly what
45 // `git diff` prints.
46 if oldNL != newNL && !changed(r.lines) && len(r.lines) > 0 {
47 last := r.lines[len(r.lines)-1]
48 r.lines = append(r.lines[:len(r.lines)-1],
49 line{del, last.text, last.oldNo, 0},
50 line{add, last.text, 0, last.newNo},
51 )
52 }
53 return r
54}
55
56func (r comparison) changed() bool { return changed(r.lines) }
57
58// firstChanged is the number, in the NEW version, of the first touched line —
59// 0 when nothing changed. Enough to open the file at the right place, or to
60// say "it starts at line 42" rather than "done".
61func (r comparison) firstChanged() int {
62 for _, l := range r.lines {
63 switch l.op {
64 case add:
65 return l.newNo
66 case del:
67 // A deletion has no line in the new version: point at where it
68 // happened.
69 return max(1, newNoBefore(r.lines, l))
70 }
71 }
72 return 0
73}
74
75func (r comparison) stat() (added, deleted int) {
76 for _, l := range r.lines {
77 switch l.op {
78 case add:
79 added++
80 case del:
81 deleted++
82 }
83 }
84 return added, deleted
85}
86
87// splitLines splits a body into lines and says whether it ended with a
88// newline. Both matter: a file without a final newline is a different file,
89// and the unified patch has to say so.
90func splitLines(body string) (lines []string, finalNewline bool) {
91 if body == "" {
92 return nil, true
93 }
94 finalNewline = strings.HasSuffix(body, "\n")
95 if finalNewline {
96 body = body[:len(body)-1]
97 }
98 return strings.Split(body, "\n"), finalNewline
99}
100
101// maxCells caps the LCS table. Past it, a line-by-line comparison would cost
102// hundreds of megabytes for a diff nobody will read: the middle block is then
103// rendered as a block (all deleted, all added), still exact — only coarser.
104const maxCells = 4 << 20
105
106// diffLines compares two sequences of lines.
107//
108// Common prefix and suffix are removed BEFORE the LCS: a targeted edit touches
109// a few lines in the middle of a file, and without that cut the table would be
110// the size of the file squared.
111func diffLines(old, new []string) []line {
112 var out []line
113
114 head := 0
115 for head < len(old) && head < len(new) && old[head] == new[head] {
116 out = append(out, line{keep, old[head], head + 1, head + 1})
117 head++
118 }
119 tail := 0
120 for tail < len(old)-head && tail < len(new)-head &&
121 old[len(old)-1-tail] == new[len(new)-1-tail] {
122 tail++
123 }
124
125 midOld, midNew := old[head:len(old)-tail], new[head:len(new)-tail]
126 out = append(out, middle(midOld, midNew, head)...)
127
128 for i := len(old) - tail; i < len(old); i++ {
129 out = append(out, line{keep, old[i], i + 1, i + 1 - len(old) + len(new)})
130 }
131 return out
132}
133
134// middle compares what remains after trimming the edges. `off` is the number
135// of lines already consumed, to number correctly.
136func middle(old, new []string, off int) []line {
137 if len(old) == 0 && len(new) == 0 {
138 return nil
139 }
140 if len(old)*len(new) > maxCells {
141 out := make([]line, 0, len(old)+len(new))
142 for i, l := range old {
143 out = append(out, line{del, l, off + i + 1, 0})
144 }
145 for j, l := range new {
146 out = append(out, line{add, l, 0, off + j + 1})
147 }
148 return out
149 }
150
151 // Classic LCS: length table, then walk. Files edited by an agent fit in it
152 // comfortably.
153 lcs := make([][]int, len(old)+1)
154 for i := range lcs {
155 lcs[i] = make([]int, len(new)+1)
156 }
157 for i := len(old) - 1; i >= 0; i-- {
158 for j := len(new) - 1; j >= 0; j-- {
159 if old[i] == new[j] {
160 lcs[i][j] = lcs[i+1][j+1] + 1
161 } else {
162 lcs[i][j] = max(lcs[i+1][j], lcs[i][j+1])
163 }
164 }
165 }
166
167 var out []line
168 i, j := 0, 0
169 for i < len(old) && j < len(new) {
170 switch {
171 case old[i] == new[j]:
172 out = append(out, line{keep, old[i], off + i + 1, off + j + 1})
173 i, j = i+1, j+1
174 case lcs[i+1][j] >= lcs[i][j+1]:
175 // Deletion first on a tie: a replacement then reads "- old" then
176 // "+ new", the order one expects.
177 out = append(out, line{del, old[i], off + i + 1, 0})
178 i++
179 default:
180 out = append(out, line{add, new[j], 0, off + j + 1})
181 j++
182 }
183 }
184 for ; i < len(old); i++ {
185 out = append(out, line{del, old[i], off + i + 1, 0})
186 }
187 for ; j < len(new); j++ {
188 out = append(out, line{add, new[j], 0, off + j + 1})
189 }
190 return out
191}
192
193// newNoBefore finds the number of the kept line that precedes `target`.
194func newNoBefore(lines []line, target line) int {
195 last := 0
196 for _, l := range lines {
197 if l == target {
198 break
199 }
200 if l.newNo > 0 {
201 last = l.newNo
202 }
203 }
204 return last + 1
205}
206
207func changed(lines []line) bool {
208 for _, l := range lines {
209 if l.op != keep {
210 return true
211 }
212 }
213 return false
214}
215
216// noNewline is the line the unified format requires when a file does not end
217// with a newline. `git apply` reads it; without it, it adds a newline nobody
218// asked for.
219const noNewline = `\ No newline at end of file`
220
221// contextLines is how many unchanged lines are kept around each change. Three
222// is the value of `diff -u` and of git.
223const contextLines = 3
224
225// unified renders a unified patch, applicable as-is by `patch -p1` or
226// `git apply`. Paths are prefixed a/ and b/ like git does.
227func (r comparison) unified(path string) string {
228 if !changed(r.lines) {
229 return ""
230 }
231 var b strings.Builder
232 fmt.Fprintf(&b, "--- a/%s\n+++ b/%s\n", path, path)
233
234 lastOld, lastNew := 0, 0
235 for _, l := range r.lines {
236 lastOld, lastNew = max(lastOld, l.oldNo), max(lastNew, l.newNo)
237 }
238 for _, h := range hunks(r.lines) {
239 oldStart, oldCount, newStart, newCount := h.ranges()
240 fmt.Fprintf(&b, "@@ -%d,%d +%d,%d @@\n", oldStart, oldCount, newStart, newCount)
241 for _, l := range h {
242 b.WriteByte(byte(l.op))
243 b.WriteString(l.text)
244 b.WriteByte('\n')
245 // The marker follows the LAST line of each version, and a kept
246 // line belongs to both: it may carry it for both at once.
247 if (!r.oldNL && l.oldNo == lastOld && l.op != add) ||
248 (!r.newNL && l.newNo == lastNew && l.op != del) {
249 b.WriteString(noNewline + "\n")
250 }
251 }
252 }
253 return b.String()
254}
255
256// hunk is a group of contiguous lines shown together.
257type hunk []line
258
259// hunks cuts the diff into pieces: each change plus `contextLines` on both
260// sides. Touching pieces are merged — otherwise two neighbouring changes would
261// produce overlapping @@ headers, and the patch would be refused.
262func hunks(lines []line) []hunk {
263 var out []hunk
264 i := 0
265 for i < len(lines) {
266 if lines[i].op == keep {
267 i++
268 continue
269 }
270 start := max(0, i-contextLines)
271 if n := len(out); n > 0 {
272 if prevEnd := indexAfter(lines, out[n-1]); prevEnd >= start {
273 out[n-1] = append(out[n-1], lines[prevEnd:hunkEnd(lines, i)]...)
274 i = hunkEnd(lines, i)
275 continue
276 }
277 }
278 out = append(out, append(hunk(nil), lines[start:hunkEnd(lines, i)]...))
279 i = hunkEnd(lines, i)
280 }
281 return out
282}
283
284// hunkEnd finds the end of the piece opened at `i`: advance while a change
285// occurs within `contextLines` lines, then keep the trailing context.
286func hunkEnd(lines []line, i int) int {
287 last := i
288 for j := i; j < len(lines); j++ {
289 if lines[j].op != keep {
290 last = j
291 continue
292 }
293 if j-last > contextLines {
294 break
295 }
296 }
297 return min(len(lines), last+contextLines+1)
298}
299
300// indexAfter returns the position, in `lines`, of the line following the last
301// element of `h`.
302func indexAfter(lines []line, h hunk) int {
303 if len(h) == 0 {
304 return 0
305 }
306 target := h[len(h)-1]
307 for i, l := range lines {
308 if l == target {
309 return i + 1
310 }
311 }
312 return 0
313}
314
315// ranges computes the @@ header of the piece.
316func (h hunk) ranges() (oldStart, oldCount, newStart, newCount int) {
317 for _, l := range h {
318 if l.op != add {
319 if oldStart == 0 {
320 oldStart = l.oldNo
321 }
322 oldCount++
323 }
324 if l.op != del {
325 if newStart == 0 {
326 newStart = l.newNo
327 }
328 newCount++
329 }
330 }
331 if oldCount == 0 {
332 oldStart = 0
333 }
334 if newCount == 0 {
335 newStart = 0
336 }
337 return oldStart, oldCount, newStart, newCount
338}
339
340// render formats the diff for the screen and for the model: a line number, a
341// sign, the text. No colours here at all — the same text goes back to the
342// model, and ANSI codes in a tool result only get in its way; the screen gets
343// its grey from the caller's preview mechanism.
344//
345// The number shown is ALWAYS the new version's: the one to look at after the
346// edit. A deleted line no longer exists there, so it gets the number of the
347// place where it vanished — a replacement shows "-" and "+" on the SAME number
348// and the column never goes backwards.
349func (r comparison) render() string {
350 if !changed(r.lines) {
351 return ""
352 }
353 var b strings.Builder
354 for _, h := range hunks(r.lines) {
355 current := 0
356 for _, l := range h {
357 no := l.newNo
358 if no > 0 {
359 current = no
360 } else {
361 no = current + 1
362 }
363 fmt.Fprintf(&b, "%4d %c %s\n", no, l.op, l.text)
364 }
365 }
366 return b.String()
367}