feat(backend): Zeilen-Diff als reine Funktionen (Live-Editing, Schritt 1)
Anwenden, Berechnen, Rebasen und Pruefsumme in de.werkbaum.diff — ohne Spring, damit die Regeln ohne Kontext pruefbar sind. Grundlage fuer PATCH /content und den Aenderungsfeed (D76). Beim Bauen entschieden, was das Konzept offenliess: Eine Einfuegung ist ein Punkt ZWISCHEN den Zeilen und kollidiert nur mit dem Inneren eines fremden Bereichs (start < index < end). Beide im Konzept genannten Folgen gelten damit weiter — zwei Einfuegungen an derselben Stelle vertragen sich, eine Einfuegung in einen geloeschten Bereich nicht —, aber die Raender bleiben konfliktfrei: Wer eine Zeile ueber einer gerade geaenderten einfuegt, bekommt keinen 409. 49 Tests. Gegenprobe: Rand-Regel auf halboffen mutiert -> genau die zwei danach benannten Zusicherungen fallen; fremde Einfuegung an gleicher Stelle nicht mitgezaehlt -> genau die eine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9e5f546d76
commit
0f3bd8f5b8
@@ -0,0 +1,242 @@
|
||||
package de.werkbaum.diff
|
||||
|
||||
import java.security.MessageDigest
|
||||
|
||||
/** Ein Diff ist nicht anwendbar (Index außerhalb, Überschneidung, Prüfsummenfehler). */
|
||||
class DiffNotApplicableException(message: String) : RuntimeException(message)
|
||||
|
||||
/**
|
||||
* Zeilen-Diffs: anwenden, berechnen, rebasen — reine Funktionen ohne Spring.
|
||||
*
|
||||
* Grundlage des Live-Editings (D76, `backend/docs/live-editing-proposal.md`).
|
||||
* Das Backend parst die Notation dabei **nicht** (D14): Zeilen sind opake
|
||||
* Strings, Fortsetzungszeilen und Beschreibungsblöcke sind schlicht Zeilen.
|
||||
*/
|
||||
object LineDiff {
|
||||
|
||||
const val CHECKSUM_PREFIX = "sha256:"
|
||||
|
||||
/**
|
||||
* Obergrenze für die LCS-Tabelle. Darüber wird der abweichende Abschnitt in
|
||||
* einem einzigen `replace` zusammengefasst — bei einem so großen
|
||||
* Unterschied ist das ohnehin die ehrliche Beschreibung, und die Tabelle
|
||||
* bliebe sonst ein Ausfall-Vektor (Konzept: „Grenzen").
|
||||
*/
|
||||
const val LCS_LIMIT = 1_000_000L
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Text ↔ Zeilen
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Zeilenenden auf LF (SPEC §12). Der **Server** normalisiert autoritativ
|
||||
* beim Speichern, der Client beim Laden — nur so hashen beide denselben
|
||||
* Text.
|
||||
*/
|
||||
fun normalize(text: String): String = text.replace("\r\n", "\n").replace('\r', '\n')
|
||||
|
||||
/**
|
||||
* Zerlegt in physische Zeilen. Es gilt `text(lines(t)) == normalize(t)`;
|
||||
* ein abschließendes LF ergibt also eine leere letzte Zeile, und der leere
|
||||
* Text ist genau eine leere Zeile. Der Client muss identisch zerlegen,
|
||||
* sonst zeigen die Indizes auseinander.
|
||||
*/
|
||||
fun lines(text: String): List<String> = normalize(text).split("\n")
|
||||
|
||||
fun text(lines: List<String>): String = lines.joinToString("\n")
|
||||
|
||||
/** Prüfsumme des Basistexts, Format `sha256:<hex>`. */
|
||||
fun checksum(text: String): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest(normalize(text).toByteArray(Charsets.UTF_8))
|
||||
return CHECKSUM_PREFIX + digest.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Anwenden
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Wendet [ops] auf [base] an. Erwartet aufsteigend sortierte,
|
||||
* überschneidungsfreie Operationen innerhalb der Dokumentgrenzen —
|
||||
* sonst [DiffNotApplicableException] (im Protokoll: 422).
|
||||
*/
|
||||
fun apply(base: List<String>, ops: List<LineOp>): List<String> {
|
||||
validate(base.size, ops)
|
||||
val out = ArrayList<String>(base.size)
|
||||
var cursor = 0
|
||||
for (op in ops) {
|
||||
out.addAll(base.subList(cursor, op.index))
|
||||
out.addAll(op.insertedLines)
|
||||
cursor = op.endExclusive
|
||||
}
|
||||
out.addAll(base.subList(cursor, base.size))
|
||||
return out
|
||||
}
|
||||
|
||||
private fun validate(size: Int, ops: List<LineOp>) {
|
||||
var prevEnd = 0
|
||||
for (op in ops) {
|
||||
if (op.removedCount < 0) {
|
||||
throw DiffNotApplicableException("count darf nicht negativ sein: $op")
|
||||
}
|
||||
if (op.index < 0 || op.index > size) {
|
||||
throw DiffNotApplicableException("Index ${op.index} liegt außerhalb von 0..$size")
|
||||
}
|
||||
if (op.endExclusive > size) {
|
||||
throw DiffNotApplicableException(
|
||||
"Operation reicht über das Dokumentende hinaus (${op.endExclusive} > $size): $op"
|
||||
)
|
||||
}
|
||||
if (op.index < prevEnd) {
|
||||
throw DiffNotApplicableException(
|
||||
"Operationen müssen aufsteigend und überschneidungsfrei sein: $op"
|
||||
)
|
||||
}
|
||||
prevEnd = op.endExclusive
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Überschneidung
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Überschneiden sich zwei Operationen derselben Basis? Das ist die
|
||||
* Konfliktdefinition des Protokolls.
|
||||
*
|
||||
* `replace`/`delete` belegen den halboffenen Bereich
|
||||
* `[index, index+count)`. `insert` ist ein **Punkt**, und zwar zwischen
|
||||
* den Zeilen: Er kollidiert nur mit einem Bereich, in dessen **Innerem**
|
||||
* er liegt (`start < index < end`).
|
||||
*
|
||||
* Daraus folgt, was das Konzept fordert:
|
||||
* - Zwei Einfügungen an derselben Stelle sind **kein** Konflikt.
|
||||
* - Eine Einfügung **in** einen gelöschten Bereich hinein ist einer.
|
||||
*
|
||||
* An den Rändern ist die Einfügung dagegen eindeutig — vor bzw. hinter dem
|
||||
* fremden Block —, und Ränder sind der häufige Fall: Wer eine Zeile über
|
||||
* einer gerade geänderten einfügt, soll keinen 409 bekommen.
|
||||
*/
|
||||
fun conflicts(a: LineOp, b: LineOp): Boolean = when {
|
||||
a is LineOp.Insert && b is LineOp.Insert -> false
|
||||
a is LineOp.Insert -> b.index < a.index && a.index < b.endExclusive
|
||||
b is LineOp.Insert -> a.index < b.index && b.index < a.endExclusive
|
||||
else -> a.index < b.endExclusive && b.index < a.endExclusive
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Rebasen
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Verschiebt [ops] (gegen eine veraltete Basis gebildet) auf den Stand,
|
||||
* der durch [onto] daraus entstanden ist.
|
||||
*
|
||||
* Ergebnis `null` heißt **echter Konflikt** (im Protokoll: 409) — nur dann
|
||||
* muss der Client entscheiden. Ohne Überschneidung rebased der Server
|
||||
* selbst und akzeptiert; ohne das verhungerte ein Client mit hoher Latenz
|
||||
* bei fleißigen Mitschreibern (D76).
|
||||
*/
|
||||
fun rebase(ops: List<LineOp>, onto: List<LineOp>): List<LineOp>? {
|
||||
if (onto.isEmpty() || ops.isEmpty()) return ops
|
||||
for (mine in ops) {
|
||||
for (theirs in onto) {
|
||||
if (conflicts(mine, theirs)) return null
|
||||
}
|
||||
}
|
||||
return ops.map { shift(it, onto) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Verschiebt eine einzelne Operation um die Zeilenänderung aller fremden
|
||||
* Operationen, die **vor** ihr liegen. Eine fremde Einfügung an derselben
|
||||
* Stelle zählt dazu: Sie ist bereits bestätigt und steht deshalb oben.
|
||||
*/
|
||||
private fun shift(op: LineOp, onto: List<LineOp>): LineOp {
|
||||
val delta = onto.filter { it.endExclusive <= op.index }.sumOf { it.lineDelta }
|
||||
if (delta == 0) return op
|
||||
return when (op) {
|
||||
is LineOp.Insert -> op.copy(index = op.index + delta)
|
||||
is LineOp.Delete -> op.copy(index = op.index + delta)
|
||||
is LineOp.Replace -> op.copy(index = op.index + delta)
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Berechnen
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Zeilen-Diff zwischen zwei Ständen; es gilt `apply(from, compute(from, to)) == to`.
|
||||
*
|
||||
* Gemeinsamer Anfang und gemeinsames Ende fallen zuerst weg — der übliche
|
||||
* Fall (ein paar geänderte Zeilen in einem großen Plan) kostet danach fast
|
||||
* nichts. Erst der Rest geht durch die LCS-Tabelle.
|
||||
*/
|
||||
fun compute(from: List<String>, to: List<String>): List<LineOp> {
|
||||
var head = 0
|
||||
val shortest = minOf(from.size, to.size)
|
||||
while (head < shortest && from[head] == to[head]) head++
|
||||
var tail = 0
|
||||
while (tail < shortest - head && from[from.size - 1 - tail] == to[to.size - 1 - tail]) tail++
|
||||
|
||||
val a = from.subList(head, from.size - tail)
|
||||
val b = to.subList(head, to.size - tail)
|
||||
|
||||
return when {
|
||||
a.isEmpty() && b.isEmpty() -> emptyList()
|
||||
a.isEmpty() -> listOf(LineOp.Insert(head, b.toList()))
|
||||
b.isEmpty() -> listOf(LineOp.Delete(head, a.size))
|
||||
a.size.toLong() * b.size.toLong() > LCS_LIMIT ->
|
||||
listOf(LineOp.Replace(head, a.size, b.toList()))
|
||||
else -> lcsOps(a, b, head)
|
||||
}
|
||||
}
|
||||
|
||||
private fun lcsOps(a: List<String>, b: List<String>, offset: Int): List<LineOp> {
|
||||
val n = a.size
|
||||
val m = b.size
|
||||
// lcs[i][j] = Länge der längsten gemeinsamen Teilfolge von a[i..] und b[j..]
|
||||
val lcs = Array(n + 1) { IntArray(m + 1) }
|
||||
for (i in n - 1 downTo 0) {
|
||||
for (j in m - 1 downTo 0) {
|
||||
lcs[i][j] = if (a[i] == b[j]) lcs[i + 1][j + 1] + 1
|
||||
else maxOf(lcs[i + 1][j], lcs[i][j + 1])
|
||||
}
|
||||
}
|
||||
|
||||
val ops = mutableListOf<LineOp>()
|
||||
var i = 0
|
||||
var j = 0
|
||||
while (i < n || j < m) {
|
||||
if (i < n && j < m && a[i] == b[j]) {
|
||||
i++
|
||||
j++
|
||||
continue
|
||||
}
|
||||
// Ein zusammenhängender Unterschied: alles bis zur nächsten
|
||||
// gemeinsamen Zeile wird zu einer Operation zusammengefasst.
|
||||
val removedFrom = i
|
||||
val inserted = mutableListOf<String>()
|
||||
while (i < n || j < m) {
|
||||
if (i < n && j < m && a[i] == b[j]) break
|
||||
if (j < m && (i == n || lcs[i][j + 1] >= lcs[i + 1][j])) {
|
||||
inserted += b[j]
|
||||
j++
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
val removed = i - removedFrom
|
||||
ops += when {
|
||||
removed > 0 && inserted.isNotEmpty() ->
|
||||
LineOp.Replace(offset + removedFrom, removed, inserted.toList())
|
||||
|
||||
removed > 0 -> LineOp.Delete(offset + removedFrom, removed)
|
||||
else -> LineOp.Insert(offset + removedFrom, inserted.toList())
|
||||
}
|
||||
}
|
||||
return ops
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package de.werkbaum.diff
|
||||
|
||||
/**
|
||||
* Eine Zeilen-Operation relativ zu einer Basisversion (0-basierter Index).
|
||||
*
|
||||
* Die drei Formen entsprechen 1:1 dem Draht-Format aus dem Live-Editing-Konzept
|
||||
* (`backend/docs/live-editing-proposal.md`, Abschnitt „Datenmodell"):
|
||||
*
|
||||
* - [Insert] – [lines] werden **vor** [index] eingefügt; `index == Zeilenzahl`
|
||||
* hängt an.
|
||||
* - [Delete] – [count] Zeilen ab [index] entfallen.
|
||||
* - [Replace] – [count] Zeilen ab [index] werden durch [lines] ersetzt.
|
||||
*
|
||||
* Bewusst ein eigenes, Spring-freies Modell: Anwenden, Berechnen und Rebasen
|
||||
* sind reine Funktionen und damit ohne Kontext testbar (D54-Nachtrag 3 zieht
|
||||
* dieselbe Grenze im Frontend).
|
||||
*/
|
||||
sealed interface LineOp {
|
||||
val index: Int
|
||||
|
||||
data class Insert(override val index: Int, val lines: List<String>) : LineOp
|
||||
data class Delete(override val index: Int, val count: Int) : LineOp
|
||||
data class Replace(override val index: Int, val count: Int, val lines: List<String>) : LineOp
|
||||
}
|
||||
|
||||
/** Zahl der Zeilen, die diese Operation aus der Basis entfernt. */
|
||||
val LineOp.removedCount: Int
|
||||
get() = when (this) {
|
||||
is LineOp.Insert -> 0
|
||||
is LineOp.Delete -> count
|
||||
is LineOp.Replace -> count
|
||||
}
|
||||
|
||||
/** Zeilen, die diese Operation einsetzt. */
|
||||
val LineOp.insertedLines: List<String>
|
||||
get() = when (this) {
|
||||
is LineOp.Insert -> lines
|
||||
is LineOp.Delete -> emptyList()
|
||||
is LineOp.Replace -> lines
|
||||
}
|
||||
|
||||
/** Ende des betroffenen Basisbereichs, exklusiv. Bei [LineOp.Insert] gleich [LineOp.index]. */
|
||||
val LineOp.endExclusive: Int
|
||||
get() = index + removedCount
|
||||
|
||||
/** Wie viele Zeilen das Dokument durch diese Operation länger (positiv) oder kürzer wird. */
|
||||
val LineOp.lineDelta: Int
|
||||
get() = insertedLines.size - removedCount
|
||||
Reference in New Issue
Block a user