From b2c4101b5bde0057d7fc277a817784203933ab9e Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Fri, 19 Jun 2026 21:18:43 +0100 Subject: [PATCH] fix(edit): correct diff @@ hunk headers at file boundaries hunkBounds approximated one-sided start lines by reusing the opposite side's number, and re-assigned it every iteration inside the count==0 guard. For pure insertion/deletion hunks (e.g. writing into an empty file, or deleting a whole file) this produced wrong headers like '@@ -2,0 +1,2 @@' instead of '@@ -0,0 +1,2 @@'. flattenDiffLines now records both running line numbers on every line, so hunkBounds takes the start from the first line directly and applies GNU's zero-count convention (line-before-change, clamped to 0). Adds TestGenerateDiffHunkHeaderBounds. --- internal/edit/edit.go | 42 +++++++++++++++++--------------------- internal/edit/edit_test.go | 23 +++++++++++++++++++++ 2 files changed, 42 insertions(+), 23 deletions(-) diff --git a/internal/edit/edit.go b/internal/edit/edit.go index 8f45734..60402e4 100644 --- a/internal/edit/edit.go +++ b/internal/edit/edit.go @@ -642,16 +642,19 @@ func flattenDiffLines(diffs []diffmatchpatch.Diff) (lines []diffLine, maxOldN, m // Strip the terminator for display; also drop a trailing CR so CRLF files // do not leak raw carriage returns into the rendered diff. text := strings.TrimSuffix(strings.TrimSuffix(raw, "\n"), "\r") + // Record both running line numbers on every line (not just the active + // side): the opposite-side counter is the position the change sits at, + // which hunkBounds needs to compute correct @@ header start lines. switch d.Type { case diffmatchpatch.DiffEqual: lines = append(lines, diffLine{op: d.Type, text: text, oldN: oldLine, newN: newLine}) oldLine++ newLine++ case diffmatchpatch.DiffDelete: - lines = append(lines, diffLine{op: d.Type, text: text, oldN: oldLine}) + lines = append(lines, diffLine{op: d.Type, text: text, oldN: oldLine, newN: newLine}) oldLine++ case diffmatchpatch.DiffInsert: - lines = append(lines, diffLine{op: d.Type, text: text, newN: newLine}) + lines = append(lines, diffLine{op: d.Type, text: text, oldN: oldLine, newN: newLine}) newLine++ } } @@ -680,38 +683,31 @@ func diffHunkRanges(lines []diffLine) []indexRange { } // hunkBounds computes the unified-diff hunk header line numbers and counts for -// lines[start:end+1]. newStart/oldStart for one-sided lines are approximate. +// lines[start:end+1]. Each line carries both running line numbers (see +// flattenDiffLines), so the hunk start is simply the first line's numbers. When a +// side has zero lines in the hunk (pure insertion or deletion), the header start +// for that side follows GNU's convention: the line number *before* the change, +// clamped to 0 at the start of the file (e.g. "@@ -0,0 +1,N @@"). func hunkBounds(lines []diffLine, start, end int) (oldStart, oldCount, newStart, newCount int) { + oldStart = lines[start].oldN + newStart = lines[start].newN for i := start; i <= end; i++ { - l := lines[i] - switch l.op { + switch lines[i].op { case diffmatchpatch.DiffEqual: - if oldCount == 0 { - oldStart = l.oldN - } - if newCount == 0 { - newStart = l.newN - } oldCount++ newCount++ case diffmatchpatch.DiffDelete: - if oldCount == 0 { - oldStart = l.oldN - } - if newCount == 0 { - newStart = l.oldN // approximate - } oldCount++ case diffmatchpatch.DiffInsert: - if newCount == 0 { - newStart = l.newN - } - if oldCount == 0 { - oldStart = l.newN // approximate - } newCount++ } } + if oldCount == 0 { + oldStart = max(0, oldStart-1) + } + if newCount == 0 { + newStart = max(0, newStart-1) + } return } diff --git a/internal/edit/edit_test.go b/internal/edit/edit_test.go index cf400ca..7544ede 100644 --- a/internal/edit/edit_test.go +++ b/internal/edit/edit_test.go @@ -427,6 +427,29 @@ func TestGenerateDiffLineLevelAccuracy(t *testing.T) { } } +// Hunk @@ headers must be correct at file boundaries (previously approximate). +func TestGenerateDiffHunkHeaderBounds(t *testing.T) { + e := &Engine{dmp: diffmatchpatch.New()} + tests := []struct { + name string + original string + modified string + wantHdr string + }{ + {name: "insert into empty file", original: "", modified: "X\nY\n", wantHdr: "@@ -0,0 +1,2 @@"}, + {name: "delete entire file", original: "a\nb\n", modified: "", wantHdr: "@@ -1,2 +0,0 @@"}, + {name: "mid-file change keeps 1-based start", original: "l1\nl2\nl3\nl4\nl5\n", modified: "l1\nl2\nCHANGED\nl4\nl5\n", wantHdr: "@@ -1,5 +1,5 @@"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + diff := e.generateDiff(tt.original, tt.modified, "f.txt") + if !strings.Contains(diff, tt.wantHdr) { + t.Errorf("expected hunk header %q, got:\n%s", tt.wantHdr, diff) + } + }) + } +} + func TestGenerateDiffNoPhantomChanges(t *testing.T) { // Regression test: replacing a line range should not produce phantom // +/- lines for unchanged code after the edit region.