@@ -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 = { ' \t indented' , ' old' }
502+ local result , applied = diff .apply_unified_diff (diff_text , table.concat (original , ' \n ' ))
503+ assert .is_true (applied )
504+ assert .are .same ({ ' \t indented' , ' 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 )
307615end )
0 commit comments