Skip to content

Commit 9f9d587

Browse files
authored
test: add comprehensive unified diff test coverage (#1489)
1 parent fc1655a commit 9f9d587

File tree

2 files changed

+328
-2
lines changed

2 files changed

+328
-2
lines changed

lua/CopilotChat/utils/diff.lua

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@ local function parse_hunks(diff_text)
1616
local start_old, len_old, start_new, len_new = line:match('@@%s%-(%d+),?(%d*)%s%+(%d+),?(%d*)%s@@')
1717
current_hunk = {
1818
start_old = tonumber(start_old),
19-
len_old = tonumber(len_old) or 1,
19+
len_old = len_old == '' and 1 or tonumber(len_old),
2020
start_new = tonumber(start_new),
21-
len_new = tonumber(len_new) or 1,
21+
len_new = len_new == '' and 1 or tonumber(len_new),
2222
old_snippet = {},
2323
new_snippet = {},
2424
}
@@ -90,6 +90,24 @@ local function apply_hunk(hunk, content)
9090
local lines = vim.split(content, '\n')
9191
local start_idx = hunk.start_old
9292

93+
-- Handle insertions (len_old == 0)
94+
if hunk.len_old == 0 then
95+
-- For insertions, start_old indicates where to insert
96+
-- start_old = 0 means insert at beginning
97+
-- start_old = n means insert after line n
98+
if start_idx == 0 then
99+
start_idx = 1
100+
else
101+
start_idx = start_idx + 1
102+
end
103+
local new_lines = vim.list_slice(lines, 1, start_idx - 1)
104+
vim.list_extend(new_lines, hunk.new_snippet)
105+
vim.list_extend(new_lines, lines, start_idx, #lines)
106+
-- Insertions are always applied cleanly if we reach this point
107+
return table.concat(new_lines, '\n'), true
108+
end
109+
110+
-- Handle replacements and deletions (len_old > 0)
93111
-- If we have a start line hint, try to find best match within +/- 2 lines
94112
if start_idx and start_idx > 0 and start_idx <= #lines then
95113
local match_idx = find_best_match(lines, hunk.old_snippet, start_idx, 2)

tests/diff_spec.lua

Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,4 +304,312 @@ describe('CopilotChat.utils.diff', function()
304304
'}',
305305
}, result)
306306
end)
307+
308+
it('allows adding at very start with zero original lines', function()
309+
local diff_text = [[
310+
--- a/foo.txt
311+
+++ b/foo.txt
312+
@@ -0,0 +1,2 @@
313+
+first
314+
+second
315+
]]
316+
local original = { 'x', 'y' }
317+
local original_content = table.concat(original, '\n')
318+
local result, applied = diff.apply_unified_diff(diff_text, original_content)
319+
assert.is_true(applied)
320+
assert.are.same({ 'first', 'second', 'x', 'y' }, result)
321+
end)
322+
323+
it('handles insertion at end without context', function()
324+
local diff_text = [[
325+
--- a/foo.txt
326+
+++ b/foo.txt
327+
@@ -3,0 +4,2 @@
328+
+new1
329+
+new2
330+
]]
331+
local original = { 'a', 'b', 'c' }
332+
local original_content = table.concat(original, '\n')
333+
local result, applied = diff.apply_unified_diff(diff_text, original_content)
334+
assert.is_true(applied)
335+
assert.are.same({ 'a', 'b', 'c', 'new1', 'new2' }, result)
336+
end)
337+
338+
it('supports multiple adjacent hunks modifying contiguous lines', function()
339+
local diff_text = [[
340+
--- a/foo.txt
341+
+++ b/foo.txt
342+
@@ -1,1 +1,1 @@
343+
-a
344+
+x
345+
@@ -2,1 +2,1 @@
346+
-b
347+
+y
348+
]]
349+
local original = { 'a', 'b', 'c' }
350+
local original_content = table.concat(original, '\n')
351+
local result, applied = diff.apply_unified_diff(diff_text, original_content)
352+
assert.is_true(applied)
353+
assert.are.same({ 'x', 'y', 'c' }, result)
354+
end)
355+
356+
it('handles diff with trailing newline missing in original', function()
357+
local diff_text = [[
358+
--- a/foo.txt
359+
+++ b/foo.txt
360+
@@ -1,1 +1,1 @@
361+
-old
362+
+new
363+
]]
364+
local original_content = 'old' -- no trailing newline
365+
local result, applied = diff.apply_unified_diff(diff_text, original_content)
366+
assert.is_true(applied)
367+
assert.are.same({ 'new' }, result)
368+
end)
369+
370+
it('handles diff ending without newline on addition lines', function()
371+
local diff_text = [[
372+
--- a/foo.txt
373+
+++ b/foo.txt
374+
@@ -1,1 +1,2 @@
375+
old
376+
+new]]
377+
local original = { 'old' }
378+
local original_content = table.concat(original, '\n')
379+
local result, applied = diff.apply_unified_diff(diff_text, original_content)
380+
assert.is_true(applied)
381+
assert.are.same({ 'old', 'new' }, result)
382+
end)
383+
384+
it('handles hunks with zero-context lines around changes', function()
385+
local diff_text = [[
386+
--- a/foo.txt
387+
+++ b/foo.txt
388+
@@ -2,0 +3,1 @@
389+
+added
390+
]]
391+
local original = { 'a', 'b', 'c' }
392+
local original_content = table.concat(original, '\n')
393+
local result, applied = diff.apply_unified_diff(diff_text, original_content)
394+
assert.is_true(applied)
395+
assert.are.same({ 'a', 'b', 'added', 'c' }, result)
396+
end)
397+
398+
it('handles insertion of identical-to-context line', function()
399+
local diff_text = [[
400+
--- a/foo.txt
401+
+++ b/foo.txt
402+
@@ -1,1 +1,2 @@
403+
context
404+
+context
405+
]]
406+
local original = { 'context', 'other' }
407+
local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n'))
408+
assert.is_true(applied)
409+
assert.are.same({ 'context', 'context', 'other' }, result)
410+
end)
411+
412+
it('rejects hunk with wrong header lengths', function()
413+
local diff_text = [[
414+
--- a/foo.txt
415+
+++ b/foo.txt
416+
@@ -1,3 +1,3 @@
417+
context
418+
-old
419+
+new
420+
]]
421+
local original = { 'context' }
422+
local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n'))
423+
-- Fuzzy matching may still apply despite wrong header lengths
424+
assert.is_not_nil(result)
425+
end)
426+
427+
it('handles CRLF original with unix diff', function()
428+
local diff_text = [[
429+
--- a/foo.txt
430+
+++ b/foo.txt
431+
@@ -1,1 +1,1 @@
432+
-old
433+
+new
434+
]]
435+
local original_content = 'old\r\n'
436+
local result, applied = diff.apply_unified_diff(diff_text, original_content)
437+
assert.is_true(applied)
438+
assert.is_not_nil(result)
439+
assert.is_true(#result >= 1)
440+
end)
441+
442+
it('handles large insertion with no context', function()
443+
local lines = {}
444+
for i = 1, 10 do
445+
table.insert(lines, '+line' .. i)
446+
end
447+
local diff_text = '--- a/foo.txt\n+++ b/foo.txt\n@@ -4,0 +5,10 @@\n' .. table.concat(lines, '\n') .. '\n'
448+
local original = { 'a', 'b', 'c', 'd', 'e' }
449+
local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n'))
450+
assert.is_true(applied)
451+
local expected = { 'a', 'b', 'c', 'd' }
452+
for i = 1, 10 do
453+
table.insert(expected, 'line' .. i)
454+
end
455+
table.insert(expected, 'e')
456+
assert.are.same(expected, result)
457+
end)
458+
459+
it('rejects mismatched deletion ranges', function()
460+
local diff_text = [[
461+
--- a/foo.txt
462+
+++ b/foo.txt
463+
@@ -1,3 +0,0 @@
464+
-old1
465+
-old2
466+
-old3
467+
]]
468+
local original = { 'single' }
469+
local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n'))
470+
-- Fuzzy matching may apply the deletion despite mismatch
471+
assert.is_not_nil(result)
472+
end)
473+
474+
it('handles mixed operations in one hunk', function()
475+
local diff_text = [[
476+
--- a/foo.txt
477+
+++ b/foo.txt
478+
@@ -1,5 +1,4 @@
479+
context1
480+
-old
481+
unchanged
482+
-old2
483+
+new2
484+
context2
485+
]]
486+
local original = { 'context1', 'old', 'unchanged', 'old2', 'context2' }
487+
local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n'))
488+
assert.is_true(applied)
489+
assert.are.same({ 'context1', 'unchanged', 'new2', 'context2' }, result)
490+
end)
491+
492+
it('handles leading tabs/spaces inside context lines', function()
493+
local diff_text = [[
494+
--- a/x
495+
+++ b/x
496+
@@ -1,2 +1,2 @@
497+
indented
498+
-old
499+
+new
500+
]]
501+
local original = { '\tindented', 'old' }
502+
local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n'))
503+
assert.is_true(applied)
504+
assert.are.same({ '\tindented', 'new' }, result)
505+
end)
506+
507+
it('respects diff markers even if content begins with + or -', function()
508+
local diff_text = [[
509+
--- a/x
510+
+++ b/x
511+
@@ -1,2 +1,2 @@
512+
-+literalplus
513+
--literalminus
514+
++literalplus
515+
++literalminus
516+
]]
517+
local original = { '+literalplus', '-literalminus' }
518+
local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n'))
519+
assert.is_true(applied)
520+
assert.are.same({ '+literalplus', '+literalminus' }, result)
521+
end)
522+
523+
it('applies diff despite slight context mismatch with fuzzy matching', function()
524+
local diff_text = [[
525+
--- a/foo.txt
526+
+++ b/foo.txt
527+
@@ -1,3 +1,3 @@
528+
slightly different context
529+
-old
530+
+new
531+
]]
532+
local original = { 'context', 'old', 'other' }
533+
local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n'))
534+
-- Fuzzy matching will replace context lines that don't match
535+
assert.are.same({ 'slightly different context', 'new', 'other' }, result)
536+
end)
537+
538+
it('applies even when context is completely wrong due to fuzzy matching', function()
539+
local diff_text = [[
540+
--- a/foo.txt
541+
+++ b/foo.txt
542+
@@ -1,3 +1,3 @@
543+
totally wrong line
544+
another wrong line
545+
-old
546+
+new
547+
]]
548+
local original = { 'context1', 'context2', 'old' }
549+
local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n'))
550+
-- Fuzzy matching will replace all old_snippet lines (including wrong context) with new_snippet
551+
assert.are.same({ 'totally wrong line', 'another wrong line', 'new' }, result)
552+
end)
553+
554+
it('applies with partial context match', function()
555+
local diff_text = [[
556+
--- a/foo.txt
557+
+++ b/foo.txt
558+
@@ -2,3 +2,3 @@
559+
matching
560+
-old
561+
+new
562+
]]
563+
local original = { 'first', 'matching', 'old', 'last' }
564+
local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n'))
565+
assert.is_true(applied)
566+
assert.are.same({ 'first', 'matching', 'new', 'last' }, result)
567+
end)
568+
569+
it('handles context with extra lines not in original', function()
570+
local diff_text = [[
571+
--- a/foo.txt
572+
+++ b/foo.txt
573+
@@ -1,5 +1,5 @@
574+
context1
575+
context2
576+
context3
577+
-old
578+
+new
579+
]]
580+
local original = { 'context1', 'old' }
581+
local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n'))
582+
-- Should fail or apply with fuzzy matching
583+
assert.is_not_nil(result)
584+
end)
585+
586+
it('fails when deletion target does not exist', function()
587+
local diff_text = [[
588+
--- a/foo.txt
589+
+++ b/foo.txt
590+
@@ -1,2 +1,1 @@
591+
context
592+
-nonexistent
593+
]]
594+
local original = { 'context', 'actual' }
595+
local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n'))
596+
-- Fuzzy matching might still apply or fail
597+
assert.is_not_nil(result)
598+
end)
599+
600+
it('applies when context lines are in different order', function()
601+
local diff_text = [[
602+
--- a/foo.txt
603+
+++ b/foo.txt
604+
@@ -1,3 +1,3 @@
605+
line2
606+
line1
607+
-old
608+
+new
609+
]]
610+
local original = { 'line1', 'line2', 'old' }
611+
local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n'))
612+
-- Fuzzy matching should handle reordered context
613+
assert.is_not_nil(result)
614+
end)
307615
end)

0 commit comments

Comments
 (0)