From 6ec2b4bc6a32aa8b5d9f01718039ddd1cf7bf497 Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 18 Oct 2026 15:34:04 +0530 Subject: [PATCH] fix(semdiff): keep an edit at the end of the file in one replace hunk When the LCS walk exhausted one side mid-hunk (e.g. editing the last line of a template with no trailing newline), changes() closed the hunk early and emitted the remainder as a separate trailing hunk: a delete plus an insert, each with an empty side and point delta 1. Extend the hunk to the end of both sides when either runs out, since nothing left can match. Fixes #5 Co-Authored-By: Claude Opus 5.5 --- semdiff/semdiff.go | 6 ++++++ semdiff/semdiff_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/semdiff/semdiff.go b/semdiff/semdiff.go index 5b19c29..c0acce6 100644 --- a/semdiff/semdiff.go +++ b/semdiff/semdiff.go @@ -249,6 +249,12 @@ func changes(old, new []string) []Change { j++ } } + // One side ran out mid-hunk: nothing left can match, so the rest of the + // other side belongs to this hunk. Without this, editing the last line + // came out as a delete plus a separate trailing insert. + if i == n || j == m { + i, j = n, m + } out = append(out, Change{oS, i, nS, j}) } if i < n || j < m { // trailing insert or delete diff --git a/semdiff/semdiff_test.go b/semdiff/semdiff_test.go index 4a28fc4..3c1810b 100644 --- a/semdiff/semdiff_test.go +++ b/semdiff/semdiff_test.go @@ -226,3 +226,27 @@ func TestEmptySideIsMaximumDelta(t *testing.T) { t.Errorf("whitespace-only side = %v (err %v), want 1", d, err) } } + +// Editing the last line of a template with no trailing newline is one replace +// hunk. The LCS walk used to run off the end of one side mid-hunk and close the +// hunk early, so the rest came out as a separate trailing hunk: a delete plus an +// insert, each with an empty side (point delta 1), instead of one replace. +func TestEditAtEndIsOneReplace(t *testing.T) { + cases := map[string]struct { + old, new []string + want Change + }{ + "edit last line": {lines("keep one\nkeep two\nold last"), lines("keep one\nkeep two\nnew last"), Change{2, 3, 2, 3}}, + "edit only line": {lines("old"), lines("new"), Change{0, 1, 0, 1}}, + "last line becomes two": {lines("keep one\nold last"), lines("keep one\nnew a\nnew b"), Change{1, 2, 1, 3}}, + "last two become one": {lines("keep one\nold a\nold b"), lines("keep one\nnew last"), Change{1, 3, 1, 2}}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + got := changes(c.old, c.new) + if len(got) != 1 || got[0] != c.want { + t.Fatalf("changes = %+v, want [%+v]", got, c.want) + } + }) + } +}