Initial commit
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Assisted-by: GLM 5.3
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
// Package diff renders a line-based difference between two texts. The
|
||||
// classic longest-common-subsequence dynamic program is enough here:
|
||||
// post-sized inputs are small, and inputs beyond the cell budget fall
|
||||
// back to an honest full replacement rather than a slow or hungry walk.
|
||||
package diff
|
||||
|
||||
import "strings"
|
||||
|
||||
// Op classifies one line of the result.
|
||||
type Op uint8
|
||||
|
||||
const (
|
||||
// Equal marks a line both texts share.
|
||||
Equal Op = iota
|
||||
// Removed marks a line only the old text carries.
|
||||
Removed
|
||||
// Added marks a line only the new text carries.
|
||||
Added
|
||||
// Skipped marks equal lines the context collapse hid.
|
||||
Skipped
|
||||
)
|
||||
|
||||
// Is reports whether the operation matches the name a template asks
|
||||
// for: "equal", "removed", "added" or "skipped".
|
||||
func (o Op) Is(name string) bool {
|
||||
switch name {
|
||||
case "equal":
|
||||
return o == Equal
|
||||
case "removed":
|
||||
return o == Removed
|
||||
case "added":
|
||||
return o == Added
|
||||
case "skipped":
|
||||
return o == Skipped
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Chunk is a run of consecutive lines with one operation.
|
||||
type Chunk struct {
|
||||
Op Op
|
||||
Lines []string
|
||||
Skipped int // Skipped only: how many equal lines the collapse hid
|
||||
}
|
||||
|
||||
// maxCells bounds the dynamic-programming table. Past it, the diff
|
||||
// degrades to a whole-text replacement: rare, and never wrong.
|
||||
const maxCells = 4_000_000
|
||||
|
||||
// Chunks diffs the old text against the new one line by line and
|
||||
// collapses long equal runs to context lines around each change. The
|
||||
// texts are split on newlines; a trailing newline does not create an
|
||||
// empty final line.
|
||||
func Chunks(oldText, newText string, context int) []Chunk {
|
||||
oldLines := split(oldText)
|
||||
newLines := split(newText)
|
||||
|
||||
ops := make([]Op, 0, len(oldLines)+len(newLines))
|
||||
if len(oldLines)*len(newLines) > maxCells {
|
||||
ops = appendAll(ops, Removed, oldLines)
|
||||
ops = appendAll(ops, Added, newLines)
|
||||
} else {
|
||||
ops = lcs(oldLines, newLines)
|
||||
}
|
||||
return collapse(ops, oldLines, newLines, context)
|
||||
}
|
||||
|
||||
// split breaks a text into lines without a phantom empty line for the
|
||||
// trailing newline.
|
||||
func split(text string) []string {
|
||||
text = strings.TrimSuffix(text, "\n")
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(text, "\n")
|
||||
}
|
||||
|
||||
func appendAll(ops []Op, op Op, lines []string) []Op {
|
||||
for range lines {
|
||||
ops = append(ops, op)
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
// lcs walks the dynamic-programming table backwards and yields the
|
||||
// per-line operations.
|
||||
func lcs(a, b []string) []Op {
|
||||
n, m := len(a), len(b)
|
||||
// table[i][j] holds the LCS length of a[i:] and b[j:].
|
||||
table := make([]int32, (n+1)*(m+1))
|
||||
at := func(i, j int) *int32 { return &table[i*(m+1)+j] }
|
||||
for i := n - 1; i >= 0; i-- {
|
||||
for j := m - 1; j >= 0; j-- {
|
||||
if a[i] == b[j] {
|
||||
*at(i, j) = *at(i+1, j+1) + 1
|
||||
} else {
|
||||
down, right := *at(i+1, j), *at(i, j+1)
|
||||
*at(i, j) = max(down, right)
|
||||
}
|
||||
}
|
||||
}
|
||||
ops := make([]Op, 0, n+m)
|
||||
i, j := 0, 0
|
||||
for i < n && j < m {
|
||||
switch {
|
||||
case a[i] == b[j]:
|
||||
ops = append(ops, Equal)
|
||||
i++
|
||||
j++
|
||||
case *at(i+1, j) >= *at(i, j+1):
|
||||
ops = append(ops, Removed)
|
||||
i++
|
||||
default:
|
||||
ops = append(ops, Added)
|
||||
j++
|
||||
}
|
||||
}
|
||||
ops = appendAll(ops, Removed, a[i:])
|
||||
ops = appendAll(ops, Added, b[j:])
|
||||
return ops
|
||||
}
|
||||
|
||||
// collapse groups the operations into chunks and trims equal runs to
|
||||
// context lines around the changes.
|
||||
func collapse(ops []Op, a, b []string, context int) []Chunk {
|
||||
// Position each operation over its source line.
|
||||
type line struct {
|
||||
op Op
|
||||
text string
|
||||
}
|
||||
out := make([]line, 0, len(ops))
|
||||
ai, bi := 0, 0
|
||||
for _, op := range ops {
|
||||
switch op {
|
||||
case Removed:
|
||||
out = append(out, line{Removed, a[ai]})
|
||||
ai++
|
||||
case Added:
|
||||
out = append(out, line{Added, b[bi]})
|
||||
bi++
|
||||
default:
|
||||
out = append(out, line{Equal, a[ai]})
|
||||
ai++
|
||||
bi++
|
||||
}
|
||||
}
|
||||
|
||||
var chunks []Chunk
|
||||
for start := 0; start < len(out); {
|
||||
op := out[start].op
|
||||
end := start
|
||||
for end < len(out) && out[end].op == op {
|
||||
end++
|
||||
}
|
||||
lines := make([]string, 0, end-start)
|
||||
for _, l := range out[start:end] {
|
||||
lines = append(lines, l.text)
|
||||
}
|
||||
chunks = append(chunks, Chunk{Op: op, Lines: lines})
|
||||
start = end
|
||||
}
|
||||
|
||||
// Collapse an equal chunk only when another change follows it; the
|
||||
// leading context of the first change stays whole.
|
||||
if context < 0 {
|
||||
context = 0
|
||||
}
|
||||
final := make([]Chunk, 0, len(chunks))
|
||||
for _, chunk := range chunks {
|
||||
final = append(final, chunk)
|
||||
}
|
||||
for i := 0; i < len(final)-1; i++ {
|
||||
c := &final[i]
|
||||
if c.Op != Equal || len(c.Lines) <= 2*context+4 {
|
||||
continue
|
||||
}
|
||||
hidden := len(c.Lines) - 2*context
|
||||
collapsed := Chunk{Op: Skipped, Skipped: hidden}
|
||||
with := make([]Chunk, 0, len(final)+1)
|
||||
with = append(with, final[:i]...)
|
||||
with = append(with, Chunk{Op: Equal, Lines: c.Lines[:context]})
|
||||
with = append(with, collapsed)
|
||||
with = append(with, Chunk{Op: Equal, Lines: c.Lines[len(c.Lines)-context:]})
|
||||
with = append(with, final[i+1:]...)
|
||||
final = with
|
||||
i++
|
||||
}
|
||||
return final
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package diff
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func render(chunks []Chunk) string {
|
||||
var b strings.Builder
|
||||
for _, c := range chunks {
|
||||
switch c.Op {
|
||||
case Equal:
|
||||
for _, l := range c.Lines {
|
||||
b.WriteString(" " + l + "\n")
|
||||
}
|
||||
case Removed:
|
||||
for _, l := range c.Lines {
|
||||
b.WriteString("- " + l + "\n")
|
||||
}
|
||||
case Added:
|
||||
for _, l := range c.Lines {
|
||||
b.WriteString("+ " + l + "\n")
|
||||
}
|
||||
case Skipped:
|
||||
b.WriteString("… " + strings.Repeat("#", c.Skipped) + "\n")
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func TestIdentical(t *testing.T) {
|
||||
got := render(Chunks("alpha\nbeta\n", "alpha\nbeta\n", 3))
|
||||
if got != " alpha\n beta\n" {
|
||||
t.Errorf("identical diff = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingleChange(t *testing.T) {
|
||||
got := render(Chunks("alpha\nbeta\ngamma\n", "alpha\nbeta+\ngamma\n", 3))
|
||||
want := " alpha\n- beta\n+ beta+\n gamma\n"
|
||||
if got != want {
|
||||
t.Errorf("change diff = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsertAndDelete(t *testing.T) {
|
||||
got := render(Chunks("one\ntwo\n", "one\nthree\nfour\ntwo\n", 3))
|
||||
want := " one\n+ three\n+ four\n two\n"
|
||||
if got != want {
|
||||
t.Errorf("insert diff = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
got = render(Chunks("one\nthree\ntwo\n", "one\ntwo\n", 3))
|
||||
want = " one\n- three\n two\n"
|
||||
if got != want {
|
||||
t.Errorf("delete diff = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptySides(t *testing.T) {
|
||||
got := render(Chunks("", "alpha\n", 3))
|
||||
if got != "+ alpha\n" {
|
||||
t.Errorf("empty old = %q", got)
|
||||
}
|
||||
got = render(Chunks("alpha\n", "", 3))
|
||||
if got != "- alpha\n" {
|
||||
t.Errorf("empty new = %q", got)
|
||||
}
|
||||
got = render(Chunks("", "", 3))
|
||||
if got != "" {
|
||||
t.Errorf("both empty = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextCollapse(t *testing.T) {
|
||||
// Twelve equal lines between two changes: with context 3 the middle
|
||||
// six collapse into one skipped chunk.
|
||||
old := "x1\nx2\nx3\nx4\nx5\nx6\nx7\nx8\nx9\nx10\nx11\nx12\nx13\nx14\n"
|
||||
new := "A1\nx2\nx3\nx4\nx5\nx6\nx7\nx8\nx9\nx10\nx11\nx12\nx13\nA14\n"
|
||||
got := render(Chunks(old, new, 3))
|
||||
want := "- x1\n+ A1\n" +
|
||||
" x2\n x3\n x4\n" +
|
||||
"… ######\n" +
|
||||
" x11\n x12\n x13\n" +
|
||||
"- x14\n+ A14\n"
|
||||
if got != want {
|
||||
t.Errorf("collapse diff = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrailingNewlineIsNotALine(t *testing.T) {
|
||||
with := render(Chunks("alpha\n", "alpha\n", 3))
|
||||
without := render(Chunks("alpha", "alpha", 3))
|
||||
if with != without {
|
||||
t.Errorf("trailing newline changed the diff: %q vs %q", with, without)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCellBudgetFallsBackToReplace(t *testing.T) {
|
||||
// 2500 equal lines on both sides exceeds the cell budget; the diff
|
||||
// must degrade to a full replacement instead of hanging.
|
||||
var b strings.Builder
|
||||
for range 2500 {
|
||||
b.WriteString(strings.Repeat("x", 40) + "\n")
|
||||
}
|
||||
big := b.String()
|
||||
chunks := Chunks(big, big, 3)
|
||||
var removed, added bool
|
||||
for _, c := range chunks {
|
||||
switch c.Op {
|
||||
case Removed:
|
||||
removed = true
|
||||
case Added:
|
||||
added = true
|
||||
}
|
||||
}
|
||||
if !removed || !added {
|
||||
t.Errorf("oversized input did not degrade to replacement (removed=%v added=%v)", removed, added)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpIs(t *testing.T) {
|
||||
if !Removed.Is("removed") || Added.Is("removed") || Skipped.Is("skipped") == false {
|
||||
t.Error("Op.Is misclassifies")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user