Files
DDI/server/internal/textdiff/textdiff.go
T
beug.k@beugsiud.deandClaude Opus 5.5 d5511b3286 feat: M4 contracts for push deployments, diff, rollback and live events
ADR 0022, OpenAPI additions (agent mode, deploy, rollback, diff, pending
changes, server-sent events), deployment_id in GetDesiredStateResponse
for pushes picked up by polling, and the textdiff package (Myers,
unified format).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:53:21 +02:00

243 lines
5.3 KiB
Go

// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
// Package textdiff computes line diffs of generated configuration files and
// renders them in unified format (as `diff -u`). It uses the Myers algorithm
// after trimming the common prefix and suffix; generated files usually differ
// in a few lines only (SOA serial, changed records).
package textdiff
import (
"fmt"
"strings"
)
// Op is the kind of a diff line.
type Op byte
// Line operations, rendered as the first character of a unified diff line.
const (
Equal Op = ' '
Delete Op = '-'
Insert Op = '+'
)
// Line is one line of a diff.
type Line struct {
Op Op
Text string
}
// maxEdits bounds time and memory (the trace keeps one copy of V per edit,
// about 16 MB at this limit) for pathological inputs: beyond it, the remaining
// middle part is reported as deleted and inserted in full (still correct,
// just not minimal).
const maxEdits = 1000
// Lines diffs a and b line by line. Lines are split at "\n"; a missing final
// newline is not reported separately.
func Lines(a, b string) []Line {
return diff(split(a), split(b))
}
func split(s string) []string {
if s == "" {
return nil
}
return strings.Split(strings.TrimSuffix(s, "\n"), "\n")
}
func diff(a, b []string) []Line {
// Common prefix and suffix.
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([]Line, 0, len(a)+len(b))
for _, s := range a[:pre] {
out = append(out, Line{Equal, s})
}
out = append(out, myers(a[pre:len(a)-suf], b[pre:len(b)-suf])...)
for _, s := range a[len(a)-suf:] {
out = append(out, Line{Equal, s})
}
return out
}
// myers returns a minimal edit script (or a full replacement when the edit
// distance exceeds maxEdits).
func myers(a, b []string) []Line {
n, m := len(a), len(b)
if n == 0 || m == 0 {
return replace(a, b)
}
maxD := n + m
if maxD > maxEdits {
maxD = maxEdits
}
off := maxD + 1
v := make([]int, 2*maxD+3)
var trace [][]int
for d := 0; d <= maxD; d++ {
snapshot := make([]int, len(v))
copy(snapshot, v)
trace = append(trace, snapshot)
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] // down: insertion
} else {
x = v[off+k-1] + 1 // right: deletion
}
y := x - k
for x < n && y < m && a[x] == b[y] {
x++
y++
}
v[off+k] = x
if x >= n && y >= m {
return backtrack(a, b, trace, off, d)
}
}
}
return replace(a, b)
}
func backtrack(a, b []string, trace [][]int, off, dEnd int) []Line {
x, y := len(a), len(b)
var rev []Line
for d := dEnd; d > 0; d-- {
v := trace[d]
k := x - y
var prevK int
if k == -d || (k != d && v[off+k-1] < v[off+k+1]) {
prevK = k + 1
} else {
prevK = k - 1
}
prevX := v[off+prevK]
prevY := prevX - prevK
for x > prevX && y > prevY {
x--
y--
rev = append(rev, Line{Equal, a[x]})
}
if x == prevX {
y--
rev = append(rev, Line{Insert, b[y]})
} else {
x--
rev = append(rev, Line{Delete, a[x]})
}
}
for x > 0 && y > 0 {
x--
y--
rev = append(rev, Line{Equal, a[x]})
}
out := make([]Line, len(rev))
for i, l := range rev {
out[len(rev)-1-i] = l
}
return out
}
func replace(a, b []string) []Line {
out := make([]Line, 0, len(a)+len(b))
for _, s := range a {
out = append(out, Line{Delete, s})
}
for _, s := range b {
out = append(out, Line{Insert, s})
}
return out
}
// Stats counts inserted and deleted lines.
func Stats(lines []Line) (inserted, deleted int) {
for _, l := range lines {
switch l.Op {
case Insert:
inserted++
case Delete:
deleted++
}
}
return inserted, deleted
}
// Unified renders the diff of a and b with the given number of context lines
// as hunks ("@@ -l,s +l,s @@" headers, no file header). Equal inputs yield
// "".
func Unified(a, b string, context int) string {
lines := Lines(a, b)
var sb strings.Builder
// Positions (1-based line numbers) before each diff line.
type pos struct{ a, b int }
at := make([]pos, len(lines)+1)
pa, pb := 1, 1
for i, l := range lines {
at[i] = pos{pa, pb}
if l.Op != Insert {
pa++
}
if l.Op != Delete {
pb++
}
}
at[len(lines)] = pos{pa, pb}
for i := 0; i < len(lines); {
if lines[i].Op == Equal {
i++
continue
}
// Hunk: from the first change minus context to the last change of
// a run whose gaps are at most 2*context, plus context.
start := max(0, i-context)
end := i
for j := i; j < len(lines); j++ {
if lines[j].Op != Equal {
end = j
continue
}
if j-end > 2*context {
break
}
}
stop := min(len(lines), end+1+context)
var na, nb int
for _, l := range lines[start:stop] {
if l.Op != Insert {
na++
}
if l.Op != Delete {
nb++
}
}
fmt.Fprintf(&sb, "@@ -%s +%s @@\n", rangeStr(at[start].a, na), rangeStr(at[start].b, nb))
for _, l := range lines[start:stop] {
sb.WriteByte(byte(l.Op))
sb.WriteString(l.Text)
sb.WriteByte('\n')
}
i = stop
}
return sb.String()
}
// rangeStr formats a hunk range like diff(1): an empty range starts one line
// earlier.
func rangeStr(start, count int) string {
if count == 0 {
start--
}
if count == 1 {
return fmt.Sprint(start)
}
return fmt.Sprintf("%d,%d", start, count)
}