diff --git a/merge.go b/merge.go new file mode 100644 index 0000000..f66f70f --- /dev/null +++ b/merge.go @@ -0,0 +1,81 @@ +// Copyright 2025 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package udiff + +import ( + "slices" +) + +// Merge merges two valid, ordered lists of edits. +// It returns zero if there was a conflict. +// +// If corresponding edits in x and y are identical, +// they are coalesced in the result. +// +// If x and y both provide different insertions at the same point, +// the insertions from x will be first in the result. +// +// TODO(adonovan): this algorithm could be improved, for example by +// working harder to coalesce non-identical edits that share a common +// deletion or common prefix of insertion (see the tests). +// Survey the academic literature for insights. +func Merge(x, y []Edit) ([]Edit, bool) { + // Make a defensive (premature) copy of the arrays. + x = slices.Clone(x) + y = slices.Clone(y) + + var merged []Edit + add := func(edit Edit) { + merged = append(merged, edit) + } + var xi, yi int + for xi < len(x) && yi < len(y) { + px := &x[xi] + py := &y[yi] + + if *px == *py { + // x and y are identical: coalesce. + add(*px) + xi++ + yi++ + + } else if px.End <= py.Start { + // x is entirely before y, + // or an insertion at start of y. + add(*px) + xi++ + + } else if py.End <= px.Start { + // y is entirely before x, + // or an insertion at start of x. + add(*py) + yi++ + + } else if px.Start < py.Start { + // x is partly before y: + // split it into a deletion and an edit. + add(Edit{px.Start, py.Start, ""}) + px.Start = py.Start + + } else if py.Start < px.Start { + // y is partly before x: + // split it into a deletion and an edit. + add(Edit{py.Start, px.Start, ""}) + py.Start = px.Start + + } else { + // x and y are unequal non-insertions + // at the same point: conflict. + return nil, false + } + } + for ; xi < len(x); xi++ { + add(x[xi]) + } + for ; yi < len(y); yi++ { + add(y[yi]) + } + return merged, true +} diff --git a/merge_test.go b/merge_test.go new file mode 100644 index 0000000..ec21a2c --- /dev/null +++ b/merge_test.go @@ -0,0 +1,65 @@ +// Copyright 2025 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package udiff_test + +import ( + "testing" + + diff "github.com/aymanbagabas/go-udiff" +) + +func TestMerge(t *testing.T) { + // For convenience, we test Merge using strings, not sequences + // of edits, though this does put us at the mercy of the diff + // algorithm. + for _, test := range []struct { + base, x, y string + want string // "!" => conflict + }{ + // independent insertions + {"abcdef", "abXcdef", "abcdeYf", "abXcdeYf"}, + // independent deletions + {"abcdef", "acdef", "abcdf", "acdf"}, + // colocated insertions (X first) + {"abcdef", "abcXdef", "abcYdef", "abcXYdef"}, + // colocated identical insertions (coalesced) + {"abcdef", "abcXdef", "abcXdef", "abcXdef"}, + // colocated insertions with common prefix (X first) + // TODO(adonovan): would "abcXYdef" be better? + // i.e. should we dissect the insertions? + {"abcdef", "abcXdef", "abcXYdef", "abcXXYdef"}, + // mix of identical and independent insertions (X first) + {"abcdef", "aIbcdXef", "aIbcdYef", "aIbcdXYef"}, + // independent deletions + {"abcdef", "def", "abc", ""}, + // overlapping deletions: conflict + {"abcdef", "adef", "abef", "!"}, + // overlapping deletions with distinct insertions, X first + {"abcdef", "abXef", "abcYf", "!"}, + // overlapping deletions with distinct insertions, Y first + {"abcdef", "abcXf", "abYef", "!"}, + // overlapping deletions with common insertions + {"abcdef", "abXef", "abcXf", "!"}, + // trailing insertions in X (observe X bias) + {"abcdef", "aXbXcXdXeXfX", "aYbcdef", "aXYbXcXdXeXfX"}, + // trailing insertions in Y (observe X bias) + {"abcdef", "aXbcdef", "aYbYcYdYeYfY", "aXYbYcYdYeYfY"}, + } { + dx := diff.Strings(test.base, test.x) + dy := diff.Strings(test.base, test.y) + got := "!" // conflict + if dz, ok := diff.Merge(dx, dy); ok { + var err error + got, err = diff.Apply(test.base, dz) + if err != nil { + t.Errorf("Merge(%q, %q, %q) produced invalid edits %v: %v", test.base, test.x, test.y, dz, err) + continue + } + } + if test.want != got { + t.Errorf("base=%q x=%q y=%q: got %q, want %q", test.base, test.x, test.y, got, test.want) + } + } +}