From 6f0c02d3397270dd7e1a5b3db4adb9158ba1a9ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 5 Oct 2026 14:18:17 -0400 Subject: [PATCH] internal: line diff helper from the Agent group, shared with Git --- internal/diff.go | 133 ++++++++++++++++++++++++++++++++++++++++++ internal/diff_test.go | 110 ++++++++++++++++++++++++++++++++++ 2 files changed, 243 insertions(+) create mode 100644 internal/diff.go create mode 100644 internal/diff_test.go diff --git a/internal/diff.go b/internal/diff.go new file mode 100644 index 0000000..ccc9071 --- /dev/null +++ b/internal/diff.go @@ -0,0 +1,133 @@ +package internal + +import "strings" + +// DiffOp says whether a diff line is kept, removed or added. +type DiffOp uint8 + +const ( + DiffEqual DiffOp = iota + DiffDelete + DiffInsert +) + +// DiffLine is one line of a line diff with its 1-based line numbers; 0 means absent. +type DiffLine struct { + Op DiffOp + Text string + Old, New int +} + +// SplitLines splits text into lines without their line breaks; "" has no lines. +func SplitLines(s string) []string { + if s == "" { + return nil + } + return strings.Split(strings.TrimSuffix(s, "\n"), "\n") +} + +// LineDiff returns a shortest edit script turning a into b (Myers, O((N+M)D)). +func LineDiff(a, b []string) []DiffLine { + // Common prefix and suffix need no search. + pre := 0 + for pre < len(a) && pre < len(b) && a[pre] == b[pre] { + pre++ + } + suf := 0 + for suf < len(a)-pre && suf < len(b)-pre && a[len(a)-1-suf] == b[len(b)-1-suf] { + suf++ + } + out := make([]DiffLine, 0, len(a)+len(b)-pre-suf) + for i := range pre { + out = append(out, DiffLine{DiffEqual, a[i], i + 1, i + 1}) + } + out = myers(out, a[pre:len(a)-suf], b[pre:len(b)-suf], pre, pre) + for i := range suf { + ai, bi := len(a)-suf+i, len(b)-suf+i + out = append(out, DiffLine{DiffEqual, a[ai], ai + 1, bi + 1}) + } + return out +} + +// myers appends the edit script of a to b, numbering from offsets ao and bo. +func myers(out []DiffLine, a, b []string, ao, bo int) []DiffLine { + n, m := len(a), len(b) + total := n + m + if total == 0 { + return out + } + off := total + 1 + v := make([]int, 2*total+3) + var trace [][]int + end := -1 + for d := 0; d <= total && end < 0; d++ { + trace = append(trace, append([]int(nil), v[off-d:off+d+1]...)) // diagonals -d..d + for k := -d; k <= d; k += 2 { + var x int + if k == -d || k != d && v[off+k-1] < v[off+k+1] { + x = v[off+k+1] + } else { + x = v[off+k-1] + 1 + } + y := x - k + for x < n && y < m && a[x] == b[y] { + x, y = x+1, y+1 + } + v[off+k] = x + if x >= n && y >= m { + end = d + break + } + } + } + // Walk the trace back from (n, m), collecting moves in reverse. + var rev []DiffLine + x, y := n, m + for d := end; d >= 0; d-- { + vd := trace[d] + at := func(k int) int { return vd[k+d] } + k := x - y + var pk int + if k == -d || k != d && at(k-1) < at(k+1) { + pk = k + 1 + } else { + pk = k - 1 + } + px := 0 + if d > 0 { + px = at(pk) + } + py := px - pk + for x > px && y > py { + x, y = x-1, y-1 + rev = append(rev, DiffLine{DiffEqual, a[x], ao + x + 1, bo + y + 1}) + } + if d == 0 { + break + } + if x == px { + y-- + rev = append(rev, DiffLine{DiffInsert, b[y], 0, bo + y + 1}) + } else { + x-- + rev = append(rev, DiffLine{DiffDelete, a[x], ao + x + 1, 0}) + } + } + for i := len(rev) - 1; i >= 0; i-- { + out = append(out, rev[i]) + } + return out +} + +// DiffStats counts the added and removed lines of a diff. +func DiffStats(d []DiffLine) (added, removed int) { + for _, l := range d { + switch l.Op { + case DiffInsert: + added++ + case DiffDelete: + removed++ + } + } + return added, removed +} diff --git a/internal/diff_test.go b/internal/diff_test.go new file mode 100644 index 0000000..d087d5c --- /dev/null +++ b/internal/diff_test.go @@ -0,0 +1,110 @@ +package internal + +import ( + "fmt" + "math/rand" + "slices" + "strings" + "testing" +) + +// diffSides rebuilds both inputs from a diff and checks the line numbers. +func diffSides(t *testing.T, d []DiffLine) (a, b []string) { + t.Helper() + for _, l := range d { + if l.Op != DiffInsert { + a = append(a, l.Text) + if l.Old != len(a) { + t.Fatalf("old number %d, want %d", l.Old, len(a)) + } + } + if l.Op != DiffDelete { + b = append(b, l.Text) + if l.New != len(b) { + t.Fatalf("new number %d, want %d", l.New, len(b)) + } + } + } + return a, b +} + +// lcs is the reference longest common subsequence length. +func lcs(a, b []string) int { + prev := make([]int, len(b)+1) + for i := range a { + cur := make([]int, len(b)+1) + for j := range b { + if a[i] == b[j] { + cur[j+1] = prev[j] + 1 + } else { + cur[j+1] = max(prev[j+1], cur[j]) + } + } + prev = cur + } + return prev[len(b)] +} + +func TestLineDiffKnown(t *testing.T) { + d := LineDiff(SplitLines("a\nb\nc\nd\n"), SplitLines("a\nc\nd\ne\n")) + var got []string + for _, l := range d { + got = append(got, fmt.Sprintf("%d%s", l.Op, l.Text)) + } + want := []string{"0a", "1b", "0c", "0d", "2e"} + if !slices.Equal(got, want) { + t.Fatalf("diff %v, want %v", got, want) + } + if add, del := DiffStats(d); add != 1 || del != 1 { + t.Fatalf("stats +%d -%d", add, del) + } +} + +func TestLineDiffEdges(t *testing.T) { + for _, tc := range []struct{ a, b string }{{"", ""}, {"", "x\ny"}, {"x\ny", ""}, {"same\n", "same\n"}, {"a", "b"}} { + d := LineDiff(SplitLines(tc.a), SplitLines(tc.b)) + a, b := diffSides(t, d) + if !slices.Equal(a, SplitLines(tc.a)) || !slices.Equal(b, SplitLines(tc.b)) { + t.Errorf("%q -> %q: rebuilt %q, %q", tc.a, tc.b, a, b) + } + } +} + +func TestLineDiffMinimalRandom(t *testing.T) { + r := rand.New(rand.NewSource(7)) + for range 300 { + a := make([]string, r.Intn(30)) + b := make([]string, r.Intn(30)) + for i := range a { + a[i] = string(rune('a' + r.Intn(4))) + } + for i := range b { + b[i] = string(rune('a' + r.Intn(4))) + } + d := LineDiff(a, b) + ra, rb := diffSides(t, d) + if !slices.Equal(ra, a) || !slices.Equal(rb, b) { + t.Fatalf("rebuilt %q %q from %q %q", ra, rb, a, b) + } + add, del := DiffStats(d) + if keep := len(a) - del; keep != lcs(a, b) || len(b)-add != keep { + t.Fatalf("%q -> %q: kept %d lines, LCS is %d", a, b, keep, lcs(a, b)) + } + } +} + +func TestLineDiffLargeFileSmallEdit(t *testing.T) { + a := make([]string, 50000) + for i := range a { + a[i] = fmt.Sprint("line ", i) + } + b := slices.Clone(a) + b[25000] = "changed" + d := LineDiff(a, b) + if add, del := DiffStats(d); add != 1 || del != 1 || len(d) != 50001 { + t.Fatalf("+%d -%d over %d lines", add, del, len(d)) + } + if !strings.HasPrefix(d[25000].Text, "line 25000") || d[25000].Op != DiffDelete { + t.Fatalf("line 25000: %+v", d[25000]) + } +}