Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
kardolus
GitHub Repository: kardolus/chatgpt-cli
Path: blob/main/agent/utils/unified_diff_test.go
3434 views
1
package utils_test
2
3
import (
4
"github.com/kardolus/chatgpt-cli/agent/utils"
5
"testing"
6
7
. "github.com/onsi/gomega"
8
"github.com/sclevine/spec"
9
"github.com/sclevine/spec/report"
10
)
11
12
func TestUnitUnifiedDiff(t *testing.T) {
13
spec.Run(t, "UnifiedDiff", testUnifiedDiff, spec.Report(report.Terminal{}))
14
}
15
16
func testUnifiedDiff(t *testing.T, when spec.G, it spec.S) {
17
it.Before(func() {
18
RegisterTestingT(t)
19
})
20
21
when("ApplyUnifiedDiff", func() {
22
it("is a no-op for empty diff", func() {
23
orig := []byte("a\nb\n")
24
out, err := utils.ApplyUnifiedDiff(orig, nil)
25
Expect(err).NotTo(HaveOccurred())
26
Expect(out).To(Equal(orig))
27
})
28
29
it("applies an insertion", func() {
30
orig := []byte("a\nb\n")
31
diff := []byte(
32
"@@ -1,2 +1,3 @@\n" +
33
" a\n" +
34
"+x\n" +
35
" b\n",
36
)
37
38
out, err := utils.ApplyUnifiedDiff(orig, diff)
39
Expect(err).NotTo(HaveOccurred())
40
Expect(string(out)).To(Equal("a\nx\nb\n"))
41
})
42
43
it("errors on context mismatch", func() {
44
orig := []byte("a\nc\n")
45
diff := []byte(
46
"@@ -1,2 +1,2 @@\n" +
47
" a\n" +
48
" b\n",
49
)
50
51
_, err := utils.ApplyUnifiedDiff(orig, diff)
52
Expect(err).To(HaveOccurred())
53
Expect(err.Error()).To(ContainSubstring("patch context mismatch"))
54
})
55
56
it("applies a deletion", func() {
57
orig := []byte("a\nb\nc\n")
58
diff := []byte(
59
"@@ -1,3 +1,2 @@\n" +
60
" a\n" +
61
"-b\n" +
62
" c\n",
63
)
64
65
out, err := utils.ApplyUnifiedDiff(orig, diff)
66
Expect(err).NotTo(HaveOccurred())
67
Expect(string(out)).To(Equal("a\nc\n"))
68
})
69
70
it("applies a replace (delete + insert)", func() {
71
orig := []byte("a\nb\nc\n")
72
diff := []byte(
73
"@@ -1,3 +1,3 @@\n" +
74
" a\n" +
75
"-b\n" +
76
"+B\n" +
77
" c\n",
78
)
79
80
out, err := utils.ApplyUnifiedDiff(orig, diff)
81
Expect(err).NotTo(HaveOccurred())
82
Expect(string(out)).To(Equal("a\nB\nc\n"))
83
})
84
85
it("applies multiple hunks in one patch", func() {
86
orig := []byte("a\nb\nc\nd\ne\n")
87
diff := []byte(
88
"@@ -1,3 +1,3 @@\n" +
89
" a\n" +
90
"-b\n" +
91
"+B\n" +
92
" c\n" +
93
"@@ -4,2 +4,2 @@\n" +
94
" d\n" +
95
"-e\n" +
96
"+E\n",
97
)
98
99
out, err := utils.ApplyUnifiedDiff(orig, diff)
100
Expect(err).NotTo(HaveOccurred())
101
Expect(string(out)).To(Equal("a\nB\nc\nd\nE\n"))
102
})
103
104
it("keeps untouched lines before and after hunks", func() {
105
orig := []byte("0\na\nb\nc\nz\n")
106
diff := []byte(
107
"@@ -2,3 +2,3 @@\n" +
108
" a\n" +
109
"-b\n" +
110
"+B\n" +
111
" c\n",
112
)
113
114
out, err := utils.ApplyUnifiedDiff(orig, diff)
115
Expect(err).NotTo(HaveOccurred())
116
Expect(string(out)).To(Equal("0\na\nB\nc\nz\n"))
117
})
118
119
it("errors when hunk starts past EOF", func() {
120
orig := []byte("a\n")
121
diff := []byte(
122
"@@ -10,1 +10,1 @@\n" +
123
" a\n",
124
)
125
126
_, err := utils.ApplyUnifiedDiff(orig, diff)
127
Expect(err).To(HaveOccurred())
128
Expect(err.Error()).To(ContainSubstring("hunk starts past EOF"))
129
})
130
131
it("errors on overlapping or out-of-order hunks", func() {
132
orig := []byte("a\nb\nc\nd\n")
133
diff := []byte(
134
"@@ -3,1 +3,1 @@\n" +
135
"-c\n" +
136
"+C\n" +
137
"@@ -2,1 +2,1 @@\n" +
138
"-b\n" +
139
"+B\n",
140
)
141
142
_, err := utils.ApplyUnifiedDiff(orig, diff)
143
Expect(err).To(HaveOccurred())
144
Expect(err.Error()).To(ContainSubstring("overlapping or out-of-order hunks"))
145
})
146
147
it("ignores typical diff headers (diff/index/---/+++)", func() {
148
orig := []byte("a\nb\n")
149
diff := []byte(
150
"diff --git a/file.txt b/file.txt\n" +
151
"index 123..456 100644\n" +
152
"--- a/file.txt\n" +
153
"+++ b/file.txt\n" +
154
"@@ -1,2 +1,2 @@\n" +
155
" a\n" +
156
"-b\n" +
157
"+B\n",
158
)
159
160
out, err := utils.ApplyUnifiedDiff(orig, diff)
161
Expect(err).NotTo(HaveOccurred())
162
Expect(string(out)).To(Equal("a\nB\n"))
163
})
164
165
it("allows whitespace-only noise before first hunk", func() {
166
orig := []byte("a\nb\n")
167
diff := []byte(
168
"\n\n \n" +
169
"@@ -1,2 +1,2 @@\n" +
170
" a\n" +
171
"-b\n" +
172
"+B\n",
173
)
174
175
out, err := utils.ApplyUnifiedDiff(orig, diff)
176
Expect(err).NotTo(HaveOccurred())
177
Expect(string(out)).To(Equal("a\nB\n"))
178
})
179
180
it("errors if non-whitespace content appears before the first hunk (strict)", func() {
181
orig := []byte("a\nb\n")
182
diff := []byte(
183
"THIS IS NOT A HEADER\n" +
184
"@@ -1,2 +1,2 @@\n" +
185
" a\n" +
186
"-b\n" +
187
"+B\n",
188
)
189
190
_, err := utils.ApplyUnifiedDiff(orig, diff)
191
Expect(err).To(HaveOccurred())
192
Expect(err.Error()).To(ContainSubstring("missing hunk header"))
193
})
194
195
it("errors on an invalid diff line prefix", func() {
196
orig := []byte("a\nb\n")
197
diff := []byte(
198
"@@ -1,2 +1,2 @@\n" +
199
" a\n" +
200
"!b\n",
201
)
202
203
_, err := utils.ApplyUnifiedDiff(orig, diff)
204
Expect(err).To(HaveOccurred())
205
Expect(err.Error()).To(ContainSubstring("invalid diff line prefix"))
206
})
207
208
it(`honors "\ No newline at end of file" marker lines`, func() {
209
orig := []byte("a\nb\n")
210
diff := []byte(
211
"@@ -1,2 +1,2 @@\n" +
212
" a\n" +
213
"-b\n" +
214
"+B\n" +
215
`\ No newline at end of file` + "\n",
216
)
217
218
out, err := utils.ApplyUnifiedDiff(orig, diff)
219
Expect(err).NotTo(HaveOccurred())
220
Expect(string(out)).To(Equal("a\nB"))
221
})
222
223
it("errors when diff contains an empty line without a prefix inside a hunk", func() {
224
orig := []byte("a\nb\n")
225
226
// NOTE: Scanner yields line="" for the blank line between "\n\n"
227
// which your parser rejects once cur != nil.
228
diff := []byte(
229
"@@ -1,2 +1,2 @@\n" +
230
" a\n" +
231
"\n" + // invalid: empty line without ' ', '+', '-'
232
" b\n",
233
)
234
235
_, err := utils.ApplyUnifiedDiff(orig, diff)
236
Expect(err).To(HaveOccurred())
237
Expect(err.Error()).To(ContainSubstring("empty line without prefix"))
238
})
239
240
it("handles inserts at the beginning of the file (oldStart=1)", func() {
241
orig := []byte("a\nb\n")
242
diff := []byte(
243
"@@ -1,2 +1,3 @@\n" +
244
"+X\n" +
245
" a\n" +
246
" b\n",
247
)
248
249
out, err := utils.ApplyUnifiedDiff(orig, diff)
250
Expect(err).NotTo(HaveOccurred())
251
Expect(string(out)).To(Equal("X\na\nb\n"))
252
})
253
254
it("errors when patch context extends past EOF", func() {
255
orig := []byte("a\n")
256
diff := []byte(
257
"@@ -1,1 +1,2 @@\n" +
258
" a\n" +
259
" b\n", // context requires a second line that doesn't exist
260
)
261
262
_, err := utils.ApplyUnifiedDiff(orig, diff)
263
Expect(err).To(HaveOccurred())
264
Expect(err.Error()).To(ContainSubstring("patch context extends past EOF"))
265
})
266
267
it("errors when patch deletion extends past EOF", func() {
268
orig := []byte("a\n")
269
diff := []byte(
270
"@@ -1,1 +1,0 @@\n" +
271
"-a\n" +
272
"-b\n", // deletion for line that doesn't exist
273
)
274
275
_, err := utils.ApplyUnifiedDiff(orig, diff)
276
Expect(err).To(HaveOccurred())
277
Expect(err.Error()).To(ContainSubstring("patch deletion extends past EOF"))
278
})
279
280
it("supports last line without trailing newline in original", func() {
281
orig := []byte("a\nb") // no trailing '\n' on last line
282
diff := []byte(
283
"@@ -1,2 +1,2 @@\n" +
284
" a\n" +
285
"-b\n" +
286
"+B\n",
287
)
288
289
out, err := utils.ApplyUnifiedDiff(orig, diff)
290
Expect(err).NotTo(HaveOccurred())
291
292
// With your current parser/applicator behavior, patch lines are newline-terminated,
293
// so the output will typically end with '\n'.
294
Expect(string(out)).To(Equal("a\nB\n"))
295
})
296
297
it("allows trailing whitespace differences for context lines", func() {
298
orig := []byte("a\nb\nc\n")
299
diff := []byte(
300
"@@ -1,3 +1,3 @@\n" +
301
" a\n" +
302
" b \n" + // context line with trailing spaces
303
"-c\n" +
304
"+C\n",
305
)
306
307
out, err := utils.ApplyUnifiedDiff(orig, diff)
308
Expect(err).NotTo(HaveOccurred())
309
Expect(string(out)).To(Equal("a\nb\nC\n"))
310
})
311
312
it("still requires exact match for deletions (whitespace mismatch fails)", func() {
313
orig := []byte("a\nb\n")
314
diff := []byte(
315
"@@ -1,2 +1,1 @@\n" +
316
" a\n" +
317
"-b \n", // deletion line has extra spaces; should NOT match "b\n"
318
)
319
320
_, err := utils.ApplyUnifiedDiff(orig, diff)
321
Expect(err).To(HaveOccurred())
322
Expect(err.Error()).To(ContainSubstring("patch deletion mismatch"))
323
})
324
325
it("fuzzy placement chooses the closest match when context appears multiple times", func() {
326
orig := []byte(
327
"header\n" +
328
"a\n" +
329
"b\n" +
330
"c\n" +
331
"mid\n" +
332
"a\n" +
333
"b\n" +
334
"c\n" +
335
"footer\n",
336
)
337
338
// The context block "a\nb\nc\n" appears twice.
339
// We lie in the header and point near the SECOND occurrence, and expect it to patch the second.
340
diff := []byte(
341
"@@ -6,3 +6,3 @@\n" +
342
" a\n" +
343
"-b\n" +
344
"+B\n" +
345
" c\n",
346
)
347
348
out, err := utils.ApplyUnifiedDiff(orig, diff)
349
Expect(err).NotTo(HaveOccurred())
350
351
Expect(string(out)).To(Equal(
352
"header\n" +
353
"a\n" +
354
"b\n" +
355
"c\n" +
356
"mid\n" +
357
"a\n" +
358
"B\n" +
359
"c\n" +
360
"footer\n",
361
))
362
})
363
364
it("fuzzy-applies when hunk header oldStart is wrong but context matches", func() {
365
orig := []byte(" roses are red\nviolets are blue\n sugar is sweet\nand so are you\n")
366
367
// Wrong oldStart on purpose (says start at line 2, but the hunk matches starting at line 1).
368
diff := []byte(
369
"@@ -2,4 +2,4 @@\n" +
370
" roses are red\n" + // TWO spaces: ' ' prefix + content-leading space
371
" violets are blue\n" + // ONE space: ' ' prefix; content starts with 'v'
372
"- sugar is sweet\n" +
373
"+ sugar is SWEET\n" +
374
" and so are you\n", // ONE space: ' ' prefix; content starts with 'a'
375
)
376
377
hunks, err := utils.ParseUnifiedDiff(diff)
378
Expect(err).NotTo(HaveOccurred())
379
Expect(hunks).To(HaveLen(1))
380
381
out, err := utils.ApplyUnifiedDiff(orig, diff)
382
Expect(err).NotTo(HaveOccurred())
383
Expect(string(out)).To(Equal(" roses are red\nviolets are blue\n sugar is SWEET\nand so are you\n"))
384
})
385
386
it("fuzzy is required when header points to wrong place but match exists elsewhere", func() {
387
// Make file long enough so oldStart=20 is within EOF.
388
orig := []byte(
389
" roses are red\n" +
390
"violets are blue\n" +
391
" sugar is sweet\n" +
392
"and so are you\n" +
393
"pad1\npad2\npad3\npad4\npad5\npad6\npad7\npad8\npad9\npad10\npad11\npad12\npad13\npad14\npad15\npad16\n",
394
)
395
396
// oldStart is wrong: points at "pad..." not the poem.
397
diff := []byte(
398
"@@ -20,4 +20,4 @@\n" +
399
" roses are red\n" + // ' ' prefix + leading space in content
400
" violets are blue\n" + // ' ' prefix only
401
"- sugar is sweet\n" +
402
"+ sugar is SWEET\n" +
403
" and so are you\n",
404
)
405
406
out, err := utils.ApplyUnifiedDiff(orig, diff)
407
Expect(err).NotTo(HaveOccurred())
408
409
Expect(string(out)).To(Equal(
410
" roses are red\n" +
411
"violets are blue\n" +
412
" sugar is SWEET\n" +
413
"and so are you\n" +
414
"pad1\npad2\npad3\npad4\npad5\npad6\npad7\npad8\npad9\npad10\npad11\npad12\npad13\npad14\npad15\npad16\n",
415
))
416
})
417
418
it("fuzzy placement chooses the closest match when context appears multiple times (forced fuzzy)", func() {
419
orig := []byte(
420
"header\n" +
421
"a\n" +
422
"b\n" +
423
"c\n" +
424
"mid\n" +
425
"a\n" +
426
"b\n" +
427
"c\n" +
428
"footer\n",
429
)
430
431
// oldStart=5 -> preferredIdx=4 points at "mid\n" (cannot match "a\n")
432
// Both occurrences match; closest to line 5 is the SECOND block.
433
diff := []byte(
434
"@@ -5,3 +5,3 @@\n" +
435
" a\n" +
436
"-b\n" +
437
"+B\n" +
438
" c\n",
439
)
440
441
out, err := utils.ApplyUnifiedDiff(orig, diff)
442
Expect(err).NotTo(HaveOccurred())
443
444
Expect(string(out)).To(Equal(
445
"header\n" +
446
"a\n" +
447
"b\n" +
448
"c\n" +
449
"mid\n" +
450
"a\n" +
451
"B\n" +
452
"c\n" +
453
"footer\n",
454
))
455
})
456
457
it("fuzzy placement never matches before the current origIdx (ordering constraint)", func() {
458
orig := []byte(
459
"a\n" +
460
"b\n" +
461
"c\n" +
462
"X\n" +
463
"a\n" +
464
"b\n" +
465
"c\n",
466
)
467
468
// Hunk1 replaces X -> Y (consumes through line 4)
469
// Hunk2 wants to patch the "a b c" block.
470
// We lie and point header near the FIRST block, but origIdx is already past it.
471
diff := []byte(
472
"@@ -4,1 +4,1 @@\n" +
473
"-X\n" +
474
"+Y\n" +
475
"@@ -1,3 +1,3 @@\n" + // malicious/wrong header: points to first block
476
" a\n" +
477
"-b\n" +
478
"+B\n" +
479
" c\n",
480
)
481
482
out, err := utils.ApplyUnifiedDiff(orig, diff)
483
Expect(err).NotTo(HaveOccurred())
484
485
// It must patch the SECOND block (lines 5-7), not the first.
486
Expect(string(out)).To(Equal(
487
"a\n" +
488
"b\n" +
489
"c\n" +
490
"Y\n" +
491
"a\n" +
492
"B\n" +
493
"c\n",
494
))
495
})
496
})
497
}
498
499