Netpulse_SasS/server/internal/difftext/diff.go
byrsapty cdf5f1fb54 Візуальний diff конфігів
Збір працював, але подивитись на зібране було ніде: жодної сторінки, а
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>
2026-08-24 12:30:30 +03:00

255 lines
7.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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
}