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>
243 lines
5.3 KiB
Go
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)
|
|
}
|