Збір працював, але подивитись на зібране було ніде: жодної сторінки, а lines_added завжди 0 — порівняння ніхто не рахував. difftext: власне порядкове порівняння з ділянками й контекстом. Бібліотека принесла б підтримку слів, символів і кольорів у терміналі — десяток речей, які тут не знадобляться. - спільний початок і кінець відкидаються до основного алгоритму: у конфігах змінюється кілька рядків із тисячі, і квадратична таблиця будувалася б там, де досить порівняти десяток - понад чотири мільйони клітинок дають truncated і грубу заміну блоку: точність там нічого не дає, а чесна позначка краща за правдоподібний, але вигаданий diff - сусідні зміни зливаються в одну ділянку, інакше контекст дублюється Результат кешується в ncm.diffs — diff двох версій незмінний назавжди. Підсумок +N/−M заразом дозаписується у version, щоб список історії не розшифровував два тіла на кожен рядок. UI: сторінка «Конфіги» — хости зліва, історія й diff справа. Порівняння з попередньою версією відкривається одразу: питання завжди одне — що змінилось цього разу. Перевірено наживо: друга версія стенду дала @@ −8,3 +8,4 @@ з одним доданим рядком і контекстом, лічильник +1 −0 дозаписався, повний текст на 292 байти читається. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
255 lines
7.5 KiB
Go
255 lines
7.5 KiB
Go
// Package difftext — порядкове порівняння конфігів.
|
||
//
|
||
// Власна реалізація, а не бібліотека: потрібен рівно один алгоритм на
|
||
// рядках із виводом у формі, яку розуміє UI. Зовнішня залежність тут
|
||
// принесла б підтримку слів, символів, кольорів у терміналі й ще десяток
|
||
// речей, які ніколи не знадобляться.
|
||
package difftext
|
||
|
||
import (
|
||
"strings"
|
||
)
|
||
|
||
// Op — що сталося з рядком.
|
||
type Op string
|
||
|
||
const (
|
||
OpEqual Op = "="
|
||
OpAdd Op = "+"
|
||
OpRemove Op = "-"
|
||
)
|
||
|
||
// Line — рядок результату порівняння.
|
||
type Line struct {
|
||
Op Op `json:"op"`
|
||
// Номери рядків у старій і новій версіях; 0 означає «немає».
|
||
OldNum int `json:"old_num,omitempty"`
|
||
NewNum int `json:"new_num,omitempty"`
|
||
Text string `json:"text"`
|
||
}
|
||
|
||
// Hunk — ділянка змін із контекстом навколо.
|
||
type Hunk struct {
|
||
OldStart int `json:"old_start"`
|
||
OldLines int `json:"old_lines"`
|
||
NewStart int `json:"new_start"`
|
||
NewLines int `json:"new_lines"`
|
||
Lines []Line `json:"lines"`
|
||
}
|
||
|
||
// Result — підсумок порівняння.
|
||
type Result struct {
|
||
Hunks []Hunk `json:"hunks"`
|
||
LinesAdded int `json:"lines_added"`
|
||
LinesRemoved int `json:"lines_removed"`
|
||
// Truncated каже, що точне порівняння виявилось задорогим і
|
||
// показана груба заміна блоку. Краще чесно зізнатись, ніж
|
||
// показати правдоподібний, але вигаданий diff.
|
||
Truncated bool `json:"truncated,omitempty"`
|
||
}
|
||
|
||
// maxCells обмежує таблицю LCS.
|
||
//
|
||
// Чотири мільйони клітинок — приблизно 2000×2000 рядків, тобто пара
|
||
// великих конфігів, які розійшлися повністю. Далі час і пам'ять ростуть
|
||
// квадратично, а користі від посимвольної точності вже немає: людині
|
||
// однаково доведеться читати весь блок.
|
||
const maxCells = 4 << 20
|
||
|
||
// ContextLines — скільки незмінених рядків показувати навколо зміни.
|
||
const ContextLines = 3
|
||
|
||
// Compare порівнює два тексти порядково.
|
||
func Compare(oldText, newText string) Result {
|
||
oldLines := splitLines(oldText)
|
||
newLines := splitLines(newText)
|
||
return CompareLines(oldLines, newLines)
|
||
}
|
||
|
||
func CompareLines(a, b []string) Result {
|
||
// Спільний початок і кінець відкидаються до основного алгоритму.
|
||
// У конфігах змінюється кілька рядків із тисячі, і без цього
|
||
// кроку квадратична таблиця будувалася б там, де достатньо
|
||
// порівняти десяток рядків.
|
||
prefix := 0
|
||
for prefix < len(a) && prefix < len(b) && a[prefix] == b[prefix] {
|
||
prefix++
|
||
}
|
||
suffix := 0
|
||
for suffix < len(a)-prefix && suffix < len(b)-prefix &&
|
||
a[len(a)-1-suffix] == b[len(b)-1-suffix] {
|
||
suffix++
|
||
}
|
||
|
||
midA := a[prefix : len(a)-suffix]
|
||
midB := b[prefix : len(b)-suffix]
|
||
|
||
var ops []Line
|
||
truncated := false
|
||
|
||
switch {
|
||
case len(midA) == 0 && len(midB) == 0:
|
||
// Ідентичні.
|
||
case len(midA)*len(midB) > maxCells:
|
||
truncated = true
|
||
for i, l := range midA {
|
||
ops = append(ops, Line{Op: OpRemove, OldNum: prefix + i + 1, Text: l})
|
||
}
|
||
for i, l := range midB {
|
||
ops = append(ops, Line{Op: OpAdd, NewNum: prefix + i + 1, Text: l})
|
||
}
|
||
default:
|
||
ops = lcsDiff(midA, midB, prefix)
|
||
}
|
||
|
||
// Склеюємо: незмінений початок, зміни, незмінений кінець.
|
||
all := make([]Line, 0, len(a)+len(b))
|
||
for i := 0; i < prefix; i++ {
|
||
all = append(all, Line{Op: OpEqual, OldNum: i + 1, NewNum: i + 1, Text: a[i]})
|
||
}
|
||
all = append(all, ops...)
|
||
for i := 0; i < suffix; i++ {
|
||
oi := len(a) - suffix + i
|
||
ni := len(b) - suffix + i
|
||
all = append(all, Line{Op: OpEqual, OldNum: oi + 1, NewNum: ni + 1, Text: a[oi]})
|
||
}
|
||
|
||
res := Result{Truncated: truncated}
|
||
for _, l := range all {
|
||
switch l.Op {
|
||
case OpAdd:
|
||
res.LinesAdded++
|
||
case OpRemove:
|
||
res.LinesRemoved++
|
||
}
|
||
}
|
||
res.Hunks = toHunks(all)
|
||
return res
|
||
}
|
||
|
||
// lcsDiff будує послідовність операцій через найдовшу спільну підпослідовність.
|
||
func lcsDiff(a, b []string, offset int) []Line {
|
||
n, m := len(a), len(b)
|
||
|
||
// dp[i][j] — довжина LCS для хвостів a[i:] і b[j:].
|
||
dp := make([][]int32, n+1)
|
||
for i := range dp {
|
||
dp[i] = make([]int32, m+1)
|
||
}
|
||
for i := n - 1; i >= 0; i-- {
|
||
for j := m - 1; j >= 0; j-- {
|
||
if a[i] == b[j] {
|
||
dp[i][j] = dp[i+1][j+1] + 1
|
||
} else if dp[i+1][j] >= dp[i][j+1] {
|
||
dp[i][j] = dp[i+1][j]
|
||
} else {
|
||
dp[i][j] = dp[i][j+1]
|
||
}
|
||
}
|
||
}
|
||
|
||
var out []Line
|
||
i, j := 0, 0
|
||
for i < n && j < m {
|
||
switch {
|
||
case a[i] == b[j]:
|
||
out = append(out, Line{Op: OpEqual, OldNum: offset + i + 1, NewNum: offset + j + 1, Text: a[i]})
|
||
i++
|
||
j++
|
||
case dp[i+1][j] >= dp[i][j+1]:
|
||
out = append(out, Line{Op: OpRemove, OldNum: offset + i + 1, Text: a[i]})
|
||
i++
|
||
default:
|
||
out = append(out, Line{Op: OpAdd, NewNum: offset + j + 1, Text: b[j]})
|
||
j++
|
||
}
|
||
}
|
||
for ; i < n; i++ {
|
||
out = append(out, Line{Op: OpRemove, OldNum: offset + i + 1, Text: a[i]})
|
||
}
|
||
for ; j < m; j++ {
|
||
out = append(out, Line{Op: OpAdd, NewNum: offset + j + 1, Text: b[j]})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// toHunks збирає ділянки змін із контекстом.
|
||
//
|
||
// Без цього UI отримував би весь конфіг цілком, а людина шукала б
|
||
// три змінені рядки серед тисячі однакових.
|
||
func toHunks(lines []Line) []Hunk {
|
||
// Позиції змін.
|
||
var changed []int
|
||
for i, l := range lines {
|
||
if l.Op != OpEqual {
|
||
changed = append(changed, i)
|
||
}
|
||
}
|
||
if len(changed) == 0 {
|
||
return nil
|
||
}
|
||
|
||
var hunks []Hunk
|
||
start := max(0, changed[0]-ContextLines)
|
||
end := min(len(lines), changed[0]+ContextLines+1)
|
||
|
||
for _, idx := range changed[1:] {
|
||
if idx-ContextLines <= end {
|
||
// Зміни близько — розширюємо поточну ділянку, а не
|
||
// плодимо сусідні з дубльованим контекстом.
|
||
end = min(len(lines), idx+ContextLines+1)
|
||
continue
|
||
}
|
||
hunks = append(hunks, makeHunk(lines[start:end]))
|
||
start = max(0, idx-ContextLines)
|
||
end = min(len(lines), idx+ContextLines+1)
|
||
}
|
||
hunks = append(hunks, makeHunk(lines[start:end]))
|
||
return hunks
|
||
}
|
||
|
||
func makeHunk(lines []Line) Hunk {
|
||
h := Hunk{Lines: lines}
|
||
for _, l := range lines {
|
||
if l.OldNum > 0 {
|
||
if h.OldStart == 0 {
|
||
h.OldStart = l.OldNum
|
||
}
|
||
h.OldLines++
|
||
}
|
||
if l.NewNum > 0 {
|
||
if h.NewStart == 0 {
|
||
h.NewStart = l.NewNum
|
||
}
|
||
h.NewLines++
|
||
}
|
||
}
|
||
return h
|
||
}
|
||
|
||
// splitLines ділить текст на рядки без хвостового порожнього.
|
||
func splitLines(s string) []string {
|
||
if s == "" {
|
||
return nil
|
||
}
|
||
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||
lines := strings.Split(s, "\n")
|
||
if len(lines) > 0 && lines[len(lines)-1] == "" {
|
||
lines = lines[:len(lines)-1]
|
||
}
|
||
return lines
|
||
}
|
||
|
||
func max(a, b int) int {
|
||
if a > b {
|
||
return a
|
||
}
|
||
return b
|
||
}
|
||
|
||
func min(a, b int) int {
|
||
if a < b {
|
||
return a
|
||
}
|
||
return b
|
||
}
|