internal: line diff helper from the Agent group, shared with Git
This commit is contained in:
2 files changed
+243
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user