A prosediff/SPIKE.md => prosediff/SPIKE.md +304 -0
@@ 0,0 1,304 @@
+# Phase 0 spike: does a prose diff read well enough to review AI-written specs in a browser?
+
+Status: **run, with a verdict.**
+
+Corpus: this repository's own `docs/DESIGN.md` — twelve revisions, `be096fc`
+… `db7d6c0`, 336 → 1139 lines of heavily reworked English prose with genuine
+reflow, written by an agent and repeatedly corrected by review. Extracted with
+`git show <rev>:docs/DESIGN.md`. Every number and excerpt below is real output
+from `prosediff/cmd/prosediff`; revisions are pinned to SHAs rather than `HEAD`
+because other agents were committing to this branch while the spike ran.
+
+## Verdict
+
+**Yes. Build the review UI on this.** The failure mode the design feared — "a
+one-word edit shows as a whole-paragraph replace" — does not occur at all, and
+the common cases are not merely tolerable but pleasant to read. Roughly 4 in 5
+real modifications read as what they are: a substitution, an added clause, a
+deleted sentence.
+
+There is one real weak spot — paragraphs that were substantively rewritten
+shred into interleaved fragments (13% of real prose modifications) — but it is
+a *presentation* problem with a fix the data structure already supports, not a
+defect in the approach. Details and the recommended fix below.
+
+Two limitations to carry into Phase 4, both honest rather than papered over:
+
+- **Move detection needs a verbatim anchor.** A section that moved *and* was
+ rewritten throughout still shows as add+remove. A section that moved with an
+ edit or two inside it is handled correctly.
+- **A changed-blocks-only view loses structural context.** The reviewer needs
+ the heading a change lives under; the plain renderer prints a
+ `@@ heading › path @@` banner as a stand-in and the web UI will need
+ something better.
+
+## The headline measurement
+
+Rewrapping the whole document from ~80 to 58 columns, changing not one word:
+
+| | line differ | prosediff |
+|---|---|---|
+| `db7d6c0` vs. rewrapped `db7d6c0` | **1563 changed lines** | **0 changes** — 307 blocks, all equal |
+
+That is the entire premise of the package, and it holds on a real 1139-line
+document containing paragraphs, nested lists, block quotes, tables, YAML and
+ASCII-art code fences. It is not an artefact of the test fixtures; the
+equivalent synthetic check is `TestWholeDocumentReflowIsSilent`.
+
+## The eleven real commit pairs
+
+Adjacent commits are what a review actually looks like, so these matter more
+than the endpoints. `git --numstat` on the same pair for comparison:
+
+```
+be096fc→c945f38 95 equal, 1 modified, 27 added, 0 removed, 0 moved | git +86 -1
+c945f38→72f5894 113 equal, 8 modified, 11 added, 2 removed, 0 moved | git +76 -26
+72f5894→fc49924 117 equal, 13 modified, 32 added, 2 removed, 0 moved | git +165 -46
+fc49924→1e7e4b1 130 equal, 23 modified, 60 added, 9 removed, 0 moved | git +296 -89
+1e7e4b1→f0dcfb4 210 equal, 2 modified, 6 added, 1 removed, 0 moved | git +35 -19
+f0dcfb4→12e043e 185 equal, 18 modified, 32 added, 15 removed, 0 moved | git +125 -101
+12e043e→a733623 223 equal, 5 modified, 53 added, 7 removed, 0 moved | git +242 -49
+a733623→ba61af7 280 equal, 1 modified, 1 added, 0 removed, 0 moved | git +11 -1
+ba61af7→7c82786 280 equal, 2 modified, 6 added, 0 removed, 0 moved | git +28 -4
+7c82786→d148a66 283 equal, 4 modified, 9 added, 1 removed, 0 moved | git +42 -11
+d148a66→db7d6c0 294 equal, 2 modified, 11 added, 0 removed, 0 moved | git +48 -4
+```
+
+The equal counts are the important column: in every pair the aligner keeps the
+untouched bulk of the document silent, including across the two commits that
+rewrote the largest sections (`fc49924→1e7e4b1`, `f0dcfb4→12e043e`). No
+content-specific heuristics or tuning were needed to get that.
+
+Zero moves across the whole history is a property of this corpus, not a broken
+detector: the document grew by insertion and rewriting rather than relocation,
+and where sections were reorganised they were also rewritten, which removes the
+verbatim anchor moves are keyed on. Move detection is therefore exercised
+against constructed moves below.
+
+## Does a reflowed paragraph with a small edit show as a small edit?
+
+Yes. This is the strongest result. Appending one clause and rewrapping the
+paragraph (a 7-line change to `git`):
+
+```
+@@ … › Merge model: no text merge, ever @@
+~ L404 paragraph modified
+ **Verified constraint:** `go-git` v5.19.1 implements only `FastForwardMerge`
+ (`repository.go:1800`; anything else returns `ErrUnsupportedMergeStrategy`). There is no
+ three-way merge available in-process{+, and none is planned upstream+}.
+
+307 blocks: 306 equal, 1 modified, 0 added, 0 removed, 0 moved (+6/-0 words)
+```
+
+Real commits look the same. From `7c82786` (agent-token rework):
+
+```
+~ L546 paragraph modified
+ [-Per-space (or per-project) tokens with a role — `reader` / `proposer` / `writer` —
+ and-]{+One agent token (no roles, no per-space scopes — see above) plus+} a **required
+ agent identity string**. Every commit records it in a way that survives clone:
+```
+
+and from the most recent pair, `d148a66→db7d6c0`:
+
+```
+~ L435→465 paragraph modified
+ **Deletion and rename need an explicit surface.** The write plane is whole-document `PUT`,
+ which gives an agent no way to express "delete this" or "move this". Two coherent answers;
+ **the second is the v1 [-recommendation-]{+decision+}**:
+```
+
+A line differ shows each of these as several deleted and several inserted
+lines, with no indication of which words actually moved.
+
+Across all 69 prose modifications in the eleven pairs:
+
+| shape of the inline diff | count | reads as |
+|---|---|---|
+| ≤ 2 changed runs | 27 (39%) | one clean substitution — ideal |
+| 3–6 changed runs | 26 (38%) | a few separate edits in one paragraph — fine |
+| 7–8 changed runs | 7 (10%) | busy, still followable |
+| ≥ 9 changed runs | 9 (13%) | shredded — see below |
+
+Similarity of paired blocks: min 0.41, p10 0.46, p25 0.63, median 0.76,
+p75 0.86. Ten further modifications were code fences, diffed line-by-line.
+
+## Where it is bad: substantively rewritten paragraphs shred
+
+The worst case in the corpus (`c945f38→72f5894`, similarity 0.56, 18 changed
+runs):
+
+```
+ **[-Verified that opting out is-]{+Opting out, if we ever do, is verified+}
+ safe.** `api.sr.ht` federates *every* config section ending in `.sr.ht` [-—
+ it does not consult an-]{+with no+} allow-list, [-so our section does get
+ added as a federated service pointed at `-]{+pointing at `api-origin` (else
+ `+}origin{+`) `+} + "/query"`[-, which we will not serve-]. `updateSchema`
+ (`api.sr.ht/main.go`) [-handles this explicitly:-]{+logs and **skips**+}
+ services that are offline or [-have-]{+serve+} an invalid schema [-are
+ logged and **skipped**-], and `BuildSchema` runs over [-only-] the healthy
+ ones[-. So the effect is-]{+only — so a non-GraphQL service costs+} one
+ `Unable to update service [-"spec.sr.ht"-]` log line, not a broken gateway.
+```
+
+This is genuinely unpleasant, and it is *not* fixable by tuning the word
+differ. The paragraph really was rewritten sentence by sentence, and
+interleaving two rewritten sentences is inherently hard to read no matter how
+the runs are computed. Absorbing tiny surviving fragments into the surrounding
+change (the package does this — `smallEqualRun`) helps, but cannot rescue a
+70%-rewritten paragraph.
+
+**The fix belongs in the web layer, and the data already carries the signal.**
+Every block with ≥ 9 changed runs has similarity ≤ 0.73; every block above 0.75
+either reads cleanly or is a handful of clearly separate small edits. So
+`BlockChange.Similarity` is a usable switch: render the inline word diff above
+~0.75 and fall back to a two-column *old block / new block* view below it,
+which is how a human wants to read a rewritten paragraph anyway. That is a
+Phase 4 rendering decision, not a redesign — the field is exported for exactly
+this.
+
+Worth noting what is *not* happening even in the bad case: the surrounding 200+
+blocks stay silent. The shredding is confined to the paragraph that was
+actually rewritten, so the reviewer's cost is bounded and local.
+
+## Does a moved section explode into add+remove noise?
+
+No, for moves with any verbatim content. Moving the whole "Open risks" section
+(6 blocks) ahead of "Repo layout" — `git` reports 34 changed lines:
+
+```
+@@ … › GraphQL: a read schema at our own `/query` in Phase 2 @@
+> L836 h2 moved here (was line 1065)
+@@ … › Open risks @@
+> L838 list item moved here (was line 1067)
+> L841 list item moved here (was line 1070)
+…
+< L1065 h2 moved away (now line 836)
+< L1067 list item moved away (now line 838)
+…
+
+313 blocks: 301 equal, 0 modified, 0 added, 0 removed, 6 moved (+0/-0 words)
+```
+
+Zero adds, zero removes. The same move with one word changed inside it also
+holds together — the move group bridges a single edited block, so the reviewer
+sees the section relocate *and* the one thing that actually changed in it:
+
+```
+> L836 h2 moved here (was line 1065)
+~ L1067→838 list item modified (moved from line 1067)
+ **Prose diff quality (highest).** Everything downstream of the [-browser-review-]{+browser
+ review+} decision assumes it reads well. No fallback is designed; Phase 0 exists to find
+ out early rather than late.
+> L841 list item moved here (was line 1070)
+
+313 blocks: 301 equal, 1 modified, 0 added, 0 removed, 5 moved (+2/-1 words)
+```
+
+**The honest limitation:** every move must be anchored by at least one block
+that is byte-identical after normalization. A section that moved and was
+rewritten throughout falls through as a delete plus an insert;
+`TestMovedAndEditedIsNotAMove` pins that behaviour so it cannot regress
+silently. This is deliberate. Matching moves by similarity alone would claim
+relationships between blocks that merely share boilerplate, and a confidently
+wrong "moved from line 1065" costs a reviewer more than an honest add+remove.
+
+## How do heavily-edited tables behave?
+
+Well, because **each table row is its own block**. From `ba61af7`, one edited
+row of the reuse-inventory table with the other rows silent:
+
+```
+~ L574 table_row modified
+ | `sr-ht-core` (fork) | config, crypto, auth, database, server, **gqlgen scaffolding +
+ `webhooks`** | Pinned to `[-git.srht.bigb.es/~-]{+sourcecraft.dev/+}bigbes/[-core-go` via
+ `replace`, as in-]{+sr-ht-core v0.0.0-20260718185800-dd418a200152`, byte-identical to+}
+ both siblings{+. **No `replace` directive** — see the note below+}. Never `go get -u`. …
+```
+
+`f0dcfb4→12e043e` adds six rows to the decisions table; they render as six
+clean `+ table_row` entries and the surrounding rows do not move. A new table
+appears as a `+ table_header` followed by `+ table_row` entries, which reads
+correctly.
+
+Two caveats:
+
+- **The alignment row is invisible.** goldmark folds `|---|---|` into the table
+ node, so changing it to `|:--|--:|` reports **no change at all** — measured,
+ not assumed. Acceptable (it is presentation, not content) but a real blind
+ spot to record.
+- **Word diffing can straddle a cell boundary** — the
+ `git.srht.bigb.es/~` → `sourcecraft.dev/` fragment above splits mid-path
+ because the row is one token stream rather than a list of cells. Legible, but
+ cell-aware diffing would be better and is cheap to add later: the block
+ already records its column count.
+
+## How do heavily-edited lists behave?
+
+Well, and this is the second-strongest result. Each list item is its own block,
+so editing one item leaves its siblings silent, adding an item is an insert
+rather than a whole-list replace, and nesting depth is part of block identity —
+re-indenting an item is reported (as a modification with no word changes,
+flagged `StructureOnly`) rather than hidden.
+
+Real evidence: `1e7e4b1→f0dcfb4` adds two list items and removes one, and the
+render is exactly two `+ list item` blocks and one `- list item`, each shown in
+full, with the rest of their lists silent. `d148a66→db7d6c0` adds a five-row
+table and six paragraphs inside existing sections without disturbing one
+neighbouring block.
+
+## Smaller findings
+
+- **Renaming a heading does not dirty its section.** `HeadingPath` is context
+ for the reviewer, never part of a block's hash. Including it would have made
+ a section rename rewrite every block underneath — a trap worth naming,
+ because the comment-anchoring design in `DESIGN.md` wants that same tuple.
+- **A soft line break is a space, and the differ is right to say so.** An early
+ rewrap run broke `logout-everywhere` across two lines; prosediff flagged it,
+ because in rendered markdown `logout-\neverywhere` really is
+ "logout- everywhere". Correct behaviour, initially mistaken for a bug.
+- **Two-word blocks below the similarity floor become add+remove.**
+ `## Old name` → `## New name` scores 0.5, under the 0.6 floor that applies to
+ short blocks, so it renders as a removal plus an addition rather than a word
+ diff. For a two-word heading both readings are equally clear, so the floor
+ stays where it is rather than being tuned to a fixture
+ (`TestShortBlockRenameFallsBackToAddRemove`).
+- **Within one changed region, entries are emitted old-side first.** A block
+ inserted at line 433 can therefore print after a modification at line 465.
+ The plain renderer accepts this; the web UI should order the new-side entries
+ by new-document position.
+- **The heading-path banner flaps** when a section heading is itself added or
+ deleted: the blocks around it briefly display the old path. Cosmetic, and it
+ disappears once blocks are rendered structurally under their headings.
+- **Fast enough to ignore.** The widest pair (336 vs 1139 lines, 307 blocks,
+ 211 of them added) compares in **12 ms** in-process. Typical adjacent-commit
+ pairs are far cheaper. No caching needed on the review page.
+
+## What would have made this a negative verdict
+
+Recorded so the verdict is falsifiable rather than a vibe. The spike would have
+failed if:
+
+- a pure rewrap had produced any change at all — it produced zero;
+- the *median* modification had been shredded rather than roughly one in eight;
+- the block aligner had needed content-specific heuristics to keep the "equal"
+ set stable across the eleven real commits — it needed none;
+- code fences had had to be diffed as prose, or prose as lines, to make either
+ read acceptably — they did not.
+
+None of those happened, so the browser-review decision and Phase 4 stand.
+
+## Recommendations for Phase 4
+
+1. **Switch presentation on `Similarity`.** Inline word diff at ≥ ~0.75;
+ two-column old/new block below it. This removes the one genuinely bad case.
+2. Render `ChangeMoveOut` as a collapsed one-line marker and `ChangeMoveIn` in
+ full at the new position.
+3. Show unchanged blocks collapsed behind an expander rather than hidden — the
+ `@@` banner is a poor substitute for structural context in a browser.
+4. Order new-side entries by new-document position (see "smaller findings").
+5. Consider cell-aware diffing for table rows before shipping, using the column
+ count already on the block.
+6. `Block.Hash` + `HeadingPath` + `Ordinal` are exactly the comment-anchor
+ tuple `DESIGN.md` specifies. Phase 5 can test anchor survival against this
+ same corpus rather than guessing.
A prosediff/align.go => prosediff/align.go +397 -0
@@ 0,0 1,397 @@
+package prosediff
+
+import "sort"
+
+// Tuning constants for block alignment. They are deliberately package-level
+// and documented rather than hidden in the code, because they are the knobs
+// that decide whether a review page reads well.
+const (
+ // modifyThreshold is the token similarity two blocks need before they
+ // are called "the same block, edited" rather than a delete plus an
+ // insert. Below it, presenting a word diff would be noise.
+ modifyThreshold = 0.40
+
+ // shortBlockTokens is the size below which a block is considered too
+ // small to judge by similarity alone.
+ shortBlockTokens = 6
+
+ // shortBlockThreshold applies instead of modifyThreshold when either
+ // block is short: pairing "yes" with "no" on a 0.4 score helps nobody.
+ shortBlockThreshold = 0.60
+
+ // moveMinTokens is the smallest block that may be reported as moved.
+ // Below it, identical content is far more likely to be coincidence
+ // (a repeated "```", a "- see above") than an actual move.
+ moveMinTokens = 5
+
+ // pairBudget caps the similarity matrix inside one changed region. Past
+ // it, only pairs within pairWindow of each other are considered; a
+ // region with hundreds of blocks on both sides has no readable pairing
+ // anyway.
+ pairBudget = 4096
+ pairWindow = 8
+)
+
+// region is one changed stretch of the alignment: blocks deleted from the old
+// revision and blocks inserted into the new one, adjacent in the edit script.
+type region struct {
+ del []int // indices into old
+ ins []int // indices into nw
+}
+
+// align turns two block sequences into the diff's change list.
+//
+// Three passes, in this order:
+// 1. Myers over block hashes finds the unchanged skeleton.
+// 2. Blocks left over from pass 1 that reappear verbatim elsewhere are
+// moves, grown outwards over their neighbours.
+// 3. Similar-enough leftovers inside one changed region are modifications.
+//
+// Anything still unmatched is a plain insert or delete.
+func align(old, nw []Block) []BlockChange {
+ in := newInterner()
+ oldIDs := make([]int, len(old))
+ for i, b := range old {
+ oldIDs[i] = in.id(b.Hash)
+ }
+ newIDs := make([]int, len(nw))
+ for i, b := range nw {
+ newIDs[i] = in.id(b.Hash)
+ }
+ script := diffInts(oldIDs, newIDs)
+
+ var (
+ out []BlockChange
+ regions []region
+ // slots[i] is where region i's entries go in the output.
+ slots []int
+ )
+
+ i, j := 0, 0
+ for k := 0; k < len(script); {
+ if script[k].op == OpEqual {
+ for n := 0; n < script[k].n; n++ {
+ o, w := old[i+n], nw[j+n]
+ out = append(out, BlockChange{Kind: ChangeEqual, Old: &o, New: &w})
+ }
+ i += script[k].n
+ j += script[k].n
+ k++
+ continue
+ }
+ var r region
+ for ; k < len(script) && script[k].op != OpEqual; k++ {
+ if script[k].op == OpDelete {
+ for n := 0; n < script[k].n; n++ {
+ r.del = append(r.del, i+n)
+ }
+ i += script[k].n
+ } else {
+ for n := 0; n < script[k].n; n++ {
+ r.ins = append(r.ins, j+n)
+ }
+ j += script[k].n
+ }
+ }
+ regions = append(regions, r)
+ slots = append(slots, len(out))
+ out = append(out, BlockChange{}) // placeholder, expanded below
+ }
+
+ moveOf, editedInMove := detectMoves(old, nw, regions)
+
+ // Build each region's entries, then splice them in at their slot.
+ expanded := make([][]BlockChange, len(regions))
+ for ri, r := range regions {
+ expanded[ri] = expandRegion(old, nw, r.del, r.ins, moveOf, editedInMove)
+ }
+ final := make([]BlockChange, 0, len(out)+len(regions))
+ next := 0
+ for idx := 0; idx < len(out); idx++ {
+ if next < len(slots) && slots[next] == idx {
+ final = append(final, expanded[next]...)
+ next++
+ continue
+ }
+ final = append(final, out[idx])
+ }
+ return final
+}
+
+// movePair records that old block o and new block n are the same content in a
+// different place.
+type movePair struct {
+ oldIdx int
+ newIdx int
+}
+
+// detectMoves matches leftover blocks across regions, seeding on exact hash
+// equality and then growing each seed over its neighbours.
+//
+// Deliberate limitation: every move must be anchored by at least one block
+// whose content is byte-identical after normalization. A block that moved and
+// was edited is recognised only when it sits *between* two such anchors; a
+// section that moved and was rewritten throughout falls through as a delete
+// plus an insert. Matching moves by similarity alone would claim
+// relationships between blocks that merely share boilerplate, and a wrong
+// "moved from" costs a reviewer more than an honest add+remove.
+func detectMoves(old, nw []Block, regions []region) (moves, edited map[int]movePair) {
+ freeOld := map[int]bool{}
+ freeNew := map[int]bool{}
+ byHash := map[string][]int{}
+ for _, r := range regions {
+ for _, oi := range r.del {
+ freeOld[oi] = true
+ if len(Tokenize(old[oi].Text)) >= moveMinTokens {
+ byHash[old[oi].Hash] = append(byHash[old[oi].Hash], oi)
+ }
+ }
+ for _, ni := range r.ins {
+ freeNew[ni] = true
+ }
+ }
+
+ out := map[int]movePair{}
+ edited = map[int]movePair{}
+ pair := func(oi, ni int) {
+ p := movePair{oldIdx: oi, newIdx: ni}
+ out[oldKey(oi)] = p
+ out[newKey(ni)] = p
+ delete(freeOld, oi)
+ delete(freeNew, ni)
+ }
+
+ // Seed: blocks big enough that identical content cannot be coincidence.
+ var seeds []movePair
+ for _, r := range regions {
+ for _, ni := range r.ins {
+ if len(Tokenize(nw[ni].Text)) < moveMinTokens {
+ continue
+ }
+ for _, oi := range byHash[nw[ni].Hash] {
+ if !freeOld[oi] || !freeNew[ni] {
+ continue
+ }
+ pair(oi, ni)
+ seeds = append(seeds, movePair{oi, ni})
+ break
+ }
+ }
+ }
+
+ // Grow each seed outwards while the neighbouring blocks are also
+ // unmatched and identical. This is what keeps a moved section's heading
+ // and its short trailing blocks attached to the move, instead of
+ // stranding them as a delete plus an insert either side of it.
+ //
+ // Growth also bridges a single edited block, but only when the block
+ // *past* it matches exactly — "a section was moved and one paragraph in
+ // it was touched" is common, while a lone similar block at the edge of a
+ // move is just as likely to be coincidence.
+ for _, s := range seeds {
+ for step := -1; step <= 1; step += 2 {
+ oi, ni := s.oldIdx+step, s.newIdx+step
+ for freeOld[oi] && freeNew[ni] {
+ if old[oi].Hash == nw[ni].Hash {
+ pair(oi, ni)
+ oi += step
+ ni += step
+ continue
+ }
+ if !bridgeable(old, nw, oi, ni, step, freeOld, freeNew, out) {
+ break
+ }
+ pair(oi, ni)
+ edited[oldKey(oi)] = movePair{oi, ni}
+ edited[newKey(ni)] = movePair{oi, ni}
+ oi += step
+ ni += step
+ }
+ }
+ }
+ return out, edited
+}
+
+// bridgeable reports whether old[oi] and nw[ni] are an edited version of one
+// another sitting inside a run of moved blocks. The anchor past the gap may
+// be either still unclaimed or already paired to its counterpart by an
+// earlier seed — both mean "the move continues on the far side".
+func bridgeable(old, nw []Block, oi, ni, step int, freeOld, freeNew map[int]bool, moves map[int]movePair) bool {
+ if old[oi].Kind != nw[ni].Kind {
+ return false
+ }
+ no, nn := oi+step, ni+step
+ if no < 0 || no >= len(old) || nn < 0 || nn >= len(nw) {
+ return false
+ }
+ if old[no].Hash != nw[nn].Hash {
+ return false
+ }
+ anchored := freeOld[no] && freeNew[nn]
+ if p, ok := moves[oldKey(no)]; ok && p.newIdx == nn {
+ anchored = true
+ }
+ if !anchored {
+ return false
+ }
+ return blockSimilarity(old[oi], nw[ni]) >= thresholdFor(old[oi], nw[ni])
+}
+
+// Move bookkeeping keys old and new indices into one map without colliding.
+func oldKey(i int) int { return i * 2 }
+func newKey(i int) int { return i*2 + 1 }
+
+func expandRegion(old, nw []Block, del, ins []int, moveOf, editedInMove map[int]movePair) []BlockChange {
+ pairs := pairModified(old, nw, del, ins, moveOf)
+ pairedOld := map[int]int{} // old index -> new index
+ pairedNew := map[int]bool{}
+ for _, p := range pairs {
+ pairedOld[p.oldIdx] = p.newIdx
+ pairedNew[p.newIdx] = true
+ }
+
+ var out []BlockChange
+ for _, oi := range del {
+ o := old[oi]
+ if mp, ok := moveOf[oldKey(oi)]; ok {
+ // A block that moved and was edited is announced here and
+ // shown in full at its new position, where the reviewer
+ // reads the section it now belongs to.
+ n := nw[mp.newIdx]
+ out = append(out, BlockChange{Kind: ChangeMoveOut, Old: &o, New: &n})
+ continue
+ }
+ if ni, ok := pairedOld[oi]; ok {
+ n := nw[ni]
+ out = append(out, modifyChange(o, n))
+ continue
+ }
+ out = append(out, BlockChange{Kind: ChangeDelete, Old: &o})
+ }
+ for _, ni := range ins {
+ n := nw[ni]
+ if mp, ok := editedInMove[newKey(ni)]; ok {
+ c := modifyChange(old[mp.oldIdx], n)
+ c.Moved = true
+ out = append(out, c)
+ continue
+ }
+ if mp, ok := moveOf[newKey(ni)]; ok {
+ o := old[mp.oldIdx]
+ out = append(out, BlockChange{Kind: ChangeMoveIn, Old: &o, New: &n})
+ continue
+ }
+ if pairedNew[ni] {
+ continue // already emitted as a modification
+ }
+ out = append(out, BlockChange{Kind: ChangeInsert, New: &n})
+ }
+ return out
+}
+
+func modifyChange(o, n Block) BlockChange {
+ c := BlockChange{Kind: ChangeModify, Old: &o, New: &n}
+ if o.Kind.Prose() {
+ c.Words = DiffWords(o.Text, n.Text)
+ c.StructureOnly = !hasChange(c.Words)
+ } else {
+ c.Lines = DiffLines(o.Lines, n.Lines)
+ c.StructureOnly = !hasChange(c.Lines)
+ }
+ c.Similarity = blockSimilarity(o, n)
+ return c
+}
+
+func hasChange(spans []Span) bool {
+ for _, s := range spans {
+ if s.Op != OpEqual {
+ return true
+ }
+ }
+ return false
+}
+
+// pairModified greedily matches the most similar delete/insert pairs left in
+// one changed region, best first.
+func pairModified(old, nw []Block, del, ins []int, moveOf map[int]movePair) []movePair {
+ var cand []int
+ for _, oi := range del {
+ if _, moved := moveOf[oldKey(oi)]; !moved {
+ cand = append(cand, oi)
+ }
+ }
+ var cins []int
+ for _, ni := range ins {
+ if _, moved := moveOf[newKey(ni)]; !moved {
+ cins = append(cins, ni)
+ }
+ }
+ if len(cand) == 0 || len(cins) == 0 {
+ return nil
+ }
+ windowed := len(cand)*len(cins) > pairBudget
+
+ type scored struct {
+ p movePair
+ s float64
+ }
+ var all []scored
+ for a, oi := range cand {
+ for b, ni := range cins {
+ if windowed && abs(a-b) > pairWindow {
+ continue
+ }
+ o, n := old[oi], nw[ni]
+ if o.Kind != n.Kind {
+ continue
+ }
+ s := blockSimilarity(o, n)
+ if s < thresholdFor(o, n) {
+ continue
+ }
+ all = append(all, scored{movePair{oi, ni}, s})
+ }
+ }
+ sort.SliceStable(all, func(i, j int) bool {
+ if all[i].s != all[j].s {
+ return all[i].s > all[j].s
+ }
+ return all[i].p.oldIdx < all[j].p.oldIdx
+ })
+
+ usedOld := map[int]bool{}
+ usedNew := map[int]bool{}
+ var out []movePair
+ for _, c := range all {
+ if usedOld[c.p.oldIdx] || usedNew[c.p.newIdx] {
+ continue
+ }
+ usedOld[c.p.oldIdx] = true
+ usedNew[c.p.newIdx] = true
+ out = append(out, c.p)
+ }
+ return out
+}
+
+func thresholdFor(a, b Block) float64 {
+ if len(Tokenize(a.Text)) < shortBlockTokens || len(Tokenize(b.Text)) < shortBlockTokens {
+ return shortBlockThreshold
+ }
+ return modifyThreshold
+}
+
+// blockSimilarity is token similarity for prose and line similarity for code.
+func blockSimilarity(a, b Block) float64 {
+ in := newInterner()
+ if a.Kind.Prose() {
+ return ratio(in.all(TokenTexts(Tokenize(a.Text))), in.all(TokenTexts(Tokenize(b.Text))))
+ }
+ return ratio(in.all(a.Lines), in.all(b.Lines))
+}
+
+func abs(i int) int {
+ if i < 0 {
+ return -i
+ }
+ return i
+}
A prosediff/cmd/prosediff/main.go => prosediff/cmd/prosediff/main.go +55 -0
@@ 0,0 1,55 @@
+// Command prosediff prints the prose diff between two markdown files.
+//
+// It exists so the differ can be judged the way a reviewer would judge it —
+// by reading its output on real documents — without a web layer.
+//
+// prosediff old.md new.md
+// prosediff -stats -width 100 old.md new.md
+package main
+
+import (
+ "flag"
+ "fmt"
+ "os"
+
+ "sourcecraft.dev/bigbes/sr-ht-spec/prosediff"
+)
+
+func main() {
+ var (
+ width = flag.Int("width", 92, "wrap prose at this column; 0 disables wrapping")
+ context = flag.Int("context", 0, "unchanged blocks to show around each change")
+ showEqual = flag.Bool("all", false, "print unchanged blocks too")
+ statsOnly = flag.Bool("stats", false, "print only the summary line")
+ )
+ flag.Parse()
+ if flag.NArg() != 2 {
+ fmt.Fprintln(os.Stderr, "usage: prosediff [flags] <old.md> <new.md>")
+ flag.PrintDefaults()
+ os.Exit(2)
+ }
+
+ oldSrc, err := os.ReadFile(flag.Arg(0))
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ newSrc, err := os.ReadFile(flag.Arg(1))
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+
+ d := prosediff.Compare(oldSrc, newSrc)
+ if !*statsOnly {
+ fmt.Print(prosediff.RenderText(d, prosediff.RenderOptions{
+ Width: *width,
+ Context: *context,
+ ShowEqual: *showEqual,
+ }))
+ }
+ s := d.Stats
+ fmt.Printf("\n%d blocks: %d equal, %d modified, %d added, %d removed, %d moved (+%d/-%d words)\n",
+ len(d.Changes), s.BlocksEqual, s.BlocksModified, s.BlocksInserted,
+ s.BlocksDeleted, s.BlocksMoved, s.WordsInserted, s.WordsDeleted)
+}
A prosediff/myers.go => prosediff/myers.go +234 -0
@@ 0,0 1,234 @@
+package prosediff
+
+// Myers O(ND) sequence diff, used at three levels: blocks, words and code
+// lines. Sequences are always interned to []int first, so equality is exact
+// integer equality and there is no hash-collision risk.
+
+// Op is the role of a run in an edit script.
+type Op uint8
+
+const (
+ // OpEqual marks content present, unchanged, in both revisions.
+ OpEqual Op = iota
+ // OpDelete marks content present only in the old revision.
+ OpDelete
+ // OpInsert marks content present only in the new revision.
+ OpInsert
+)
+
+func (o Op) String() string {
+ switch o {
+ case OpEqual:
+ return "equal"
+ case OpDelete:
+ return "delete"
+ case OpInsert:
+ return "insert"
+ }
+ return "unknown"
+}
+
+// edit is one run of an edit script: n consecutive elements sharing an Op.
+type edit struct {
+ op Op
+ n int
+}
+
+// interner maps arbitrary strings to dense ints so sequence comparison is
+// integer equality.
+type interner struct {
+ ids map[string]int
+}
+
+func newInterner() *interner { return &interner{ids: make(map[string]int)} }
+
+func (in *interner) id(s string) int {
+ if v, ok := in.ids[s]; ok {
+ return v
+ }
+ v := len(in.ids) + 1
+ in.ids[s] = v
+ return v
+}
+
+func (in *interner) all(ss []string) []int {
+ out := make([]int, len(ss))
+ for i, s := range ss {
+ out[i] = in.id(s)
+ }
+ return out
+}
+
+// diffInts returns the edit script turning a into b, as a coalesced run list.
+// The script is a minimal edit script (Myers), with common prefix and suffix
+// trimmed first so the expensive part only runs over the changed middle.
+func diffInts(a, b []int) []edit {
+ 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++
+ }
+ var out []edit
+ if pre > 0 {
+ out = append(out, edit{OpEqual, pre})
+ }
+ out = append(out, myers(a[pre:len(a)-suf], b[pre:len(b)-suf])...)
+ if suf > 0 {
+ out = append(out, edit{OpEqual, suf})
+ }
+ return coalesce(out)
+}
+
+// vsnap is the V array of one Myers iteration, stored only over the diagonals
+// [-d, d] that iteration can reach.
+type vsnap struct {
+ d int
+ vals []int32
+}
+
+func (s vsnap) get(k int) int { return int(s.vals[k+s.d]) }
+
+func myers(a, b []int) []edit {
+ n, m := len(a), len(b)
+ switch {
+ case n == 0 && m == 0:
+ return nil
+ case n == 0:
+ return []edit{{OpInsert, m}}
+ case m == 0:
+ return []edit{{OpDelete, n}}
+ }
+
+ maxD := n + m
+ v := make([]int32, 2*maxD+1)
+ off := maxD
+ trace := make([]vsnap, 0, 16)
+
+ for d := 0; d <= maxD; d++ {
+ done := false
+ for k := -d; k <= d; k += 2 {
+ var x int
+ if k == -d || (k != d && v[off+k-1] < v[off+k+1]) {
+ x = int(v[off+k+1]) // move down: consume one element of b
+ } else {
+ x = int(v[off+k-1]) + 1 // move right: consume one element of a
+ }
+ y := x - k
+ for x < n && y < m && a[x] == b[y] {
+ x++
+ y++
+ }
+ v[off+k] = int32(x)
+ if x >= n && y >= m {
+ done = true
+ break
+ }
+ }
+ snap := vsnap{d: d, vals: make([]int32, 2*d+1)}
+ for k := -d; k <= d; k += 2 {
+ snap.vals[k+d] = v[off+k]
+ }
+ trace = append(trace, snap)
+ if done {
+ return backtrack(trace, n, m)
+ }
+ }
+ panic("prosediff: myers did not converge")
+}
+
+// backtrack walks the saved V arrays from the end point back to the origin,
+// emitting the edit script in reverse and then flipping it.
+func backtrack(trace []vsnap, n, m int) []edit {
+ var rev []edit
+ push := func(op Op) {
+ if len(rev) > 0 && rev[len(rev)-1].op == op {
+ rev[len(rev)-1].n++
+ return
+ }
+ rev = append(rev, edit{op, 1})
+ }
+
+ x, y := n, m
+ for d := len(trace) - 1; d > 0; d-- {
+ prev := trace[d-1]
+ k := x - y
+ var prevK int
+ if k == -d || (k != d && prev.get(k-1) < prev.get(k+1)) {
+ prevK = k + 1
+ } else {
+ prevK = k - 1
+ }
+ prevX := prev.get(prevK)
+ prevY := prevX - prevK
+ for x > prevX && y > prevY {
+ push(OpEqual)
+ x--
+ y--
+ }
+ if x == prevX {
+ push(OpInsert)
+ y--
+ } else {
+ push(OpDelete)
+ x--
+ }
+ x, y = prevX, prevY
+ }
+ for x > 0 && y > 0 {
+ push(OpEqual)
+ x--
+ y--
+ }
+ // d == 0 leaves at most one of x, y non-zero only when the other
+ // sequence was fully consumed on the diagonal, which the prefix trim
+ // already handled; keep the guard rather than assume.
+ for ; x > 0; x-- {
+ push(OpDelete)
+ }
+ for ; y > 0; y-- {
+ push(OpInsert)
+ }
+
+ out := make([]edit, 0, len(rev))
+ for i := len(rev) - 1; i >= 0; i-- {
+ out = append(out, rev[i])
+ }
+ return coalesce(out)
+}
+
+func coalesce(in []edit) []edit {
+ out := in[:0:0]
+ for _, e := range in {
+ if e.n == 0 {
+ continue
+ }
+ if len(out) > 0 && out[len(out)-1].op == e.op {
+ out[len(out)-1].n += e.n
+ continue
+ }
+ out = append(out, e)
+ }
+ return out
+}
+
+// commonCount reports how many elements the edit script keeps equal.
+func commonCount(script []edit) int {
+ n := 0
+ for _, e := range script {
+ if e.op == OpEqual {
+ n += e.n
+ }
+ }
+ return n
+}
+
+// ratio is the classic 2*common/(len(a)+len(b)) similarity in [0,1].
+func ratio(a, b []int) float64 {
+ if len(a) == 0 && len(b) == 0 {
+ return 1
+ }
+ return 2 * float64(commonCount(diffInts(a, b))) / float64(len(a)+len(b))
+}
A prosediff/myers_test.go => prosediff/myers_test.go +126 -0
@@ 0,0 1,126 @@
+package prosediff
+
+import (
+ "math/rand"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestDiffIntsScripts(t *testing.T) {
+ tests := []struct {
+ name string
+ a, b []int
+ want []edit
+ }{
+ {"both empty", nil, nil, nil},
+ {"identical", []int{1, 2, 3}, []int{1, 2, 3}, []edit{{OpEqual, 3}}},
+ {"all inserted", nil, []int{1, 2}, []edit{{OpInsert, 2}}},
+ {"all deleted", []int{1, 2}, nil, []edit{{OpDelete, 2}}},
+ {"middle replaced", []int{1, 2, 3}, []int{1, 9, 3},
+ []edit{{OpEqual, 1}, {OpDelete, 1}, {OpInsert, 1}, {OpEqual, 1}}},
+ {"suffix appended", []int{1, 2}, []int{1, 2, 3}, []edit{{OpEqual, 2}, {OpInsert, 1}}},
+ {"prefix removed", []int{0, 1, 2}, []int{1, 2}, []edit{{OpDelete, 1}, {OpEqual, 2}}},
+ {"nothing in common", []int{1, 2}, []int{3, 4},
+ []edit{{OpDelete, 2}, {OpInsert, 2}}},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := diffInts(tc.a, tc.b)
+ assert.Equal(t, tc.want, got)
+ assertScriptApplies(t, tc.a, tc.b, got)
+ })
+ }
+}
+
+// TestDiffIntsMinimalRandom is the real correctness check: on random inputs
+// the script must both reconstruct b from a and keep exactly as many elements
+// equal as the longest common subsequence.
+func TestDiffIntsMinimalRandom(t *testing.T) {
+ rng := rand.New(rand.NewSource(1))
+ for iter := 0; iter < 400; iter++ {
+ a := randSeq(rng, rng.Intn(14), 5)
+ b := randSeq(rng, rng.Intn(14), 5)
+ script := diffInts(a, b)
+ assertScriptApplies(t, a, b, script)
+ require.Equal(t, lcsLen(a, b), commonCount(script),
+ "non-minimal script for a=%v b=%v: %v", a, b, script)
+ for i := 1; i < len(script); i++ {
+ require.NotEqual(t, script[i-1].op, script[i].op, "runs not coalesced: %v", script)
+ }
+ }
+}
+
+func TestRatio(t *testing.T) {
+ assert.Equal(t, 1.0, ratio(nil, nil))
+ assert.Equal(t, 1.0, ratio([]int{1, 2}, []int{1, 2}))
+ assert.Equal(t, 0.0, ratio([]int{1, 2}, []int{3, 4}))
+ assert.Equal(t, 0.5, ratio([]int{1, 2}, []int{1, 3}))
+}
+
+func randSeq(rng *rand.Rand, n, alphabet int) []int {
+ if n == 0 {
+ return nil
+ }
+ out := make([]int, n)
+ for i := range out {
+ out[i] = rng.Intn(alphabet)
+ }
+ return out
+}
+
+func assertScriptApplies(t *testing.T, a, b []int, script []edit) {
+ t.Helper()
+ var gotA, gotB []int
+ i, j := 0, 0
+ for _, e := range script {
+ switch e.op {
+ case OpEqual:
+ require.LessOrEqual(t, i+e.n, len(a))
+ require.LessOrEqual(t, j+e.n, len(b))
+ for n := 0; n < e.n; n++ {
+ require.Equal(t, a[i+n], b[j+n], "equal run over differing elements")
+ }
+ gotA = append(gotA, a[i:i+e.n]...)
+ gotB = append(gotB, b[j:j+e.n]...)
+ i += e.n
+ j += e.n
+ case OpDelete:
+ gotA = append(gotA, a[i:i+e.n]...)
+ i += e.n
+ case OpInsert:
+ gotB = append(gotB, b[j:j+e.n]...)
+ j += e.n
+ }
+ }
+ require.Equal(t, len(a), i)
+ require.Equal(t, len(b), j)
+ require.Equal(t, a, sliceOrNil(gotA))
+ require.Equal(t, b, sliceOrNil(gotB))
+}
+
+func sliceOrNil(s []int) []int {
+ if len(s) == 0 {
+ return nil
+ }
+ return s
+}
+
+// lcsLen is the textbook O(nm) LCS, used only as the oracle.
+func lcsLen(a, b []int) int {
+ dp := make([][]int, len(a)+1)
+ for i := range dp {
+ dp[i] = make([]int, len(b)+1)
+ }
+ for i := 1; i <= len(a); i++ {
+ for j := 1; j <= len(b); j++ {
+ if a[i-1] == b[j-1] {
+ dp[i][j] = dp[i-1][j-1] + 1
+ continue
+ }
+ dp[i][j] = max(dp[i-1][j], dp[i][j-1])
+ }
+ }
+ return dp[len(a)][len(b)]
+}
A prosediff/prosediff.go => prosediff/prosediff.go +140 -0
@@ 0,0 1,140 @@
+// Package prosediff diffs two revisions of a markdown document the way a
+// human reads it: as blocks of prose, not as lines of text.
+//
+// Markdown reflows. A one-word edit rewraps a paragraph, and a line-oriented
+// differ then reports the whole paragraph as replaced — useless for reviewing
+// written text. This package instead:
+//
+// 1. segments each revision into blocks (headings, paragraphs, list items,
+// code fences, table rows, block quotes) using goldmark's parser;
+// 2. aligns the two block sequences, recognising unchanged, added, removed,
+// modified and (exactly) moved blocks;
+// 3. diffs word-by-word inside a modified prose block, and line-by-line
+// inside a modified code fence.
+//
+// Whitespace and line wrapping alone never produce a diff in prose. They
+// always do in code fences, which is the point of separating the two.
+//
+// The package renders plain text only. Rendering to HTML is the web layer's
+// job; Diff is the data structure it consumes.
+package prosediff
+
+// ChangeKind classifies what happened to one block.
+type ChangeKind string
+
+const (
+ // ChangeEqual: the block is present unchanged in both revisions.
+ ChangeEqual ChangeKind = "equal"
+ // ChangeInsert: the block exists only in the new revision.
+ ChangeInsert ChangeKind = "insert"
+ // ChangeDelete: the block exists only in the old revision.
+ ChangeDelete ChangeKind = "delete"
+ // ChangeModify: the same block, edited. Words (or Lines) carry the detail.
+ ChangeModify ChangeKind = "modify"
+ // ChangeMoveOut marks, at its old position, a block that moved elsewhere.
+ ChangeMoveOut ChangeKind = "move_out"
+ // ChangeMoveIn marks, at its new position, a block that moved from elsewhere.
+ ChangeMoveIn ChangeKind = "move_in"
+)
+
+// BlockChange is one entry of a diff, in reading order: for each changed
+// region, the old blocks first and then the new ones.
+type BlockChange struct {
+ Kind ChangeKind
+
+ // Old is the block in the old revision; nil for ChangeInsert.
+ Old *Block
+ // New is the block in the new revision; nil for ChangeDelete.
+ New *Block
+
+ // Words is the inline edit script for a modified prose block.
+ Words []Span
+ // Lines is the line-oriented edit script for a modified code fence,
+ // frontmatter block or HTML block.
+ Lines []Span
+
+ // Similarity is the token-level similarity that justified pairing a
+ // ChangeModify, in [0,1]. Zero for every other kind.
+ Similarity float64
+
+ // StructureOnly marks a modification whose content is untouched: only
+ // the heading level, list depth or quote depth changed.
+ StructureOnly bool
+
+ // Moved marks a ChangeModify whose block also changed position: it sits
+ // inside a run of moved blocks. Its ChangeMoveOut counterpart appears at
+ // the old position.
+ Moved bool
+}
+
+// Stats summarizes a diff, cheap enough for a listing page.
+type Stats struct {
+ BlocksEqual int
+ BlocksInserted int
+ BlocksDeleted int
+ BlocksModified int
+ BlocksMoved int
+ WordsInserted int
+ WordsDeleted int
+}
+
+// Changed reports whether the two revisions differ at all.
+func (s Stats) Changed() bool {
+ return s.BlocksInserted+s.BlocksDeleted+s.BlocksModified+s.BlocksMoved > 0
+}
+
+// Diff is the whole comparison of two document revisions.
+type Diff struct {
+ OldBlocks []Block
+ NewBlocks []Block
+ Changes []BlockChange
+ Stats Stats
+}
+
+// Compare segments both revisions and aligns them. It has no failure mode:
+// any byte slice is a parseable markdown document.
+func Compare(oldSrc, newSrc []byte) *Diff {
+ old := Segment(oldSrc)
+ nw := Segment(newSrc)
+ d := &Diff{OldBlocks: old, NewBlocks: nw}
+ d.Changes = align(old, nw)
+ d.Stats = computeStats(d.Changes)
+ return d
+}
+
+func computeStats(changes []BlockChange) Stats {
+ var s Stats
+ for _, c := range changes {
+ switch c.Kind {
+ case ChangeEqual:
+ s.BlocksEqual++
+ case ChangeInsert:
+ s.BlocksInserted++
+ s.WordsInserted += len(Tokenize(c.New.Text))
+ case ChangeDelete:
+ s.BlocksDeleted++
+ s.WordsDeleted += len(Tokenize(c.Old.Text))
+ case ChangeModify:
+ s.BlocksModified++
+ for _, sp := range c.Words {
+ switch sp.Op {
+ case OpInsert:
+ s.WordsInserted += len(Tokenize(sp.Text))
+ case OpDelete:
+ s.WordsDeleted += len(Tokenize(sp.Text))
+ }
+ }
+ for _, sp := range c.Lines {
+ switch sp.Op {
+ case OpInsert:
+ s.WordsInserted += len(Tokenize(sp.Text))
+ case OpDelete:
+ s.WordsDeleted += len(Tokenize(sp.Text))
+ }
+ }
+ case ChangeMoveIn:
+ s.BlocksMoved++
+ }
+ }
+ return s
+}
A prosediff/prosediff_test.go => prosediff/prosediff_test.go +417 -0
@@ 0,0 1,417 @@
+package prosediff
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// changeSummary is a compact, readable expectation: one string per change.
+func changeSummary(d *Diff) []string {
+ var out []string
+ for _, c := range d.Changes {
+ if c.Kind == ChangeEqual {
+ continue
+ }
+ b := c.New
+ if b == nil {
+ b = c.Old
+ }
+ out = append(out, string(c.Kind)+" "+string(b.Kind))
+ }
+ return out
+}
+
+// inlineOf returns the word-level diff of the n-th modified block, rendered
+// with markers, so tests can assert on what the reviewer would actually see.
+func inlineOf(t *testing.T, d *Diff, n int) string {
+ t.Helper()
+ seen := 0
+ for _, c := range d.Changes {
+ if c.Kind != ChangeModify {
+ continue
+ }
+ if seen != n {
+ seen++
+ continue
+ }
+ var sb strings.Builder
+ for _, s := range c.Words {
+ if s.Space && sb.Len() > 0 {
+ sb.WriteByte(' ')
+ }
+ switch s.Op {
+ case OpEqual:
+ sb.WriteString(s.Text)
+ case OpDelete:
+ sb.WriteString("[-" + s.Text + "-]")
+ case OpInsert:
+ sb.WriteString("{+" + s.Text + "+}")
+ }
+ }
+ return sb.String()
+ }
+ t.Fatalf("no modified block #%d in diff", n)
+ return ""
+}
+
+func TestCompare(t *testing.T) {
+ tests := []struct {
+ name string
+ old, new string
+ want []string // non-equal changes, "kind blockkind"
+ inline string // expected rendering of the first modification
+ stats func(t *testing.T, s Stats)
+ }{
+ {
+ name: "pure reflow is a no-op",
+ old: "# Doc\n\nMarkdown reflows. A one-word edit renders as a whole-paragraph\n" +
+ "replace under a line-oriented differ, which makes reviewing agent\noutput miserable.\n",
+ new: "# Doc\n\nMarkdown reflows. A one-word edit renders as a\nwhole-paragraph replace under a line-oriented differ,\nwhich makes reviewing agent output miserable.\n",
+ want: nil,
+ stats: func(t *testing.T, s Stats) {
+ assert.False(t, s.Changed())
+ assert.Equal(t, 2, s.BlocksEqual)
+ },
+ },
+ {
+ name: "single word edit inside a long reflowed paragraph",
+ old: "Markdown reflows. A one-word edit renders as a whole-paragraph replace\n" +
+ "under a line-oriented differ, which makes reviewing agent output\nmiserable.\n",
+ new: "Markdown reflows. A one-word edit renders as a\nwhole-paragraph replace under a line-oriented differ, which makes\n" +
+ "reviewing agent output unbearable.\n",
+ want: []string{"modify paragraph"},
+ inline: "Markdown reflows. A one-word edit renders as a whole-paragraph replace under a line-oriented differ, which makes reviewing agent output [-miserable-]{+unbearable+}.",
+ stats: func(t *testing.T, s Stats) {
+ assert.Equal(t, 1, s.BlocksModified)
+ assert.Equal(t, 1, s.WordsInserted)
+ assert.Equal(t, 1, s.WordsDeleted)
+ },
+ },
+ {
+ name: "paragraph added",
+ old: "One.\n\nThree.\n",
+ new: "One.\n\nA brand new second paragraph goes here.\n\nThree.\n",
+ want: []string{"insert paragraph"},
+ stats: func(t *testing.T, s Stats) {
+ assert.Equal(t, 1, s.BlocksInserted)
+ assert.Equal(t, 2, s.BlocksEqual)
+ },
+ },
+ {
+ name: "paragraph removed",
+ old: "One.\n\nA whole paragraph that is going away entirely.\n\nThree.\n",
+ new: "One.\n\nThree.\n",
+ want: []string{"delete paragraph"},
+ stats: func(t *testing.T, s Stats) {
+ assert.Equal(t, 1, s.BlocksDeleted)
+ },
+ },
+ {
+ name: "heading text changed",
+ old: "## Prose diff, not line diff\n\nbody\n",
+ new: "## Prose diff, never line diff\n\nbody\n",
+ want: []string{"modify heading"},
+ inline: "Prose diff, [-not-]{+never+} line diff",
+ },
+ {
+ name: "heading level changed only",
+ old: "## The two hard parts\n\nbody\n",
+ new: "### The two hard parts\n\nbody\n",
+ want: []string{"modify heading"},
+ },
+ {
+ name: "list item edited, siblings untouched",
+ old: "- alpha stays exactly the same\n- beta gets a small correction here\n- gamma stays too\n",
+ new: "- alpha stays exactly the same\n- beta gets a large correction here\n- gamma stays too\n",
+ want: []string{"modify list_item"},
+ inline: "beta gets a [-small-]{+large+} correction here",
+ stats: func(t *testing.T, s Stats) {
+ assert.Equal(t, 2, s.BlocksEqual)
+ assert.Equal(t, 1, s.BlocksModified)
+ },
+ },
+ {
+ name: "list item added",
+ old: "- alpha stays the same\n- gamma stays the same\n",
+ new: "- alpha stays the same\n- beta is entirely new here\n- gamma stays the same\n",
+ want: []string{"insert list_item"},
+ },
+ {
+ name: "list nesting change is structural",
+ old: "- alpha the first item\n- beta the second item\n",
+ new: "- alpha the first item\n - beta the second item\n",
+ want: []string{"modify list_item"},
+ },
+ {
+ name: "code fence line edited",
+ old: "```go\nx := 1\ny := 2\nz := 3\n```\n",
+ new: "```go\nx := 1\ny := 22\nz := 3\n```\n",
+ want: []string{"modify code"},
+ },
+ {
+ name: "code fence indentation matters",
+ old: "```py\nif x:\n y()\n```\n",
+ new: "```py\nif x:\n\ty()\n```\n",
+ want: []string{"modify code"},
+ },
+ {
+ name: "code fence added",
+ old: "Some prose here.\n",
+ new: "Some prose here.\n\n```sh\nmake build\n```\n",
+ want: []string{"insert code"},
+ },
+ {
+ name: "table row edited",
+ old: "| Decision | Choice |\n|---|---|\n| Review gate | Proposal-first |\n| Storage | Own bare git repos |\n",
+ new: "| Decision | Choice |\n|---|---|\n| Review gate | Proposal-first |\n| Storage | Own bare git repos, service-owned |\n",
+ want: []string{"modify table_row"},
+ inline: "| Storage | Own bare git repos{+, service-owned+} |",
+ },
+ {
+ name: "table row added",
+ old: "| a | b |\n|---|---|\n| one | two |\n",
+ new: "| a | b |\n|---|---|\n| one | two |\n| three | four |\n",
+ want: []string{"insert table_row"},
+ },
+ {
+ name: "block quote edited",
+ old: "> bot produces, human curates, bots consume.\n",
+ new: "> bot produces, human reviews, bots consume.\n",
+ want: []string{"modify paragraph"},
+ inline: "bot produces, human [-curates-]{+reviews+}, bots consume.",
+ },
+ {
+ name: "frontmatter edited",
+ old: "---\nid: SPEC-0007\nstatus: draft\n---\n\nBody text.\n",
+ new: "---\nid: SPEC-0007\nstatus: review\n---\n\nBody text.\n",
+ want: []string{"modify frontmatter"},
+ },
+ {
+ name: "unrelated replacement is not a modification",
+ old: "The quick brown fox jumps over the lazy dog.\n",
+ new: "Consistency and recovery is the section that follows.\n",
+ want: []string{"delete paragraph", "insert paragraph"},
+ },
+ {
+ name: "empty to content",
+ old: "",
+ new: "# New\n\nBody.\n",
+ want: []string{"insert heading", "insert paragraph"},
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ d := Compare([]byte(tc.old), []byte(tc.new))
+ assert.Equal(t, tc.want, changeSummary(d))
+ if tc.inline != "" {
+ assert.Equal(t, tc.inline, inlineOf(t, d, 0))
+ }
+ if tc.stats != nil {
+ tc.stats(t, d.Stats)
+ }
+ // The renderer must survive every case.
+ assert.NotPanics(t, func() { RenderText(d, DefaultRenderOptions()) })
+ })
+ }
+}
+
+// TestWholeDocumentReflowIsSilent is the design's central claim, checked over
+// a document with every block kind in it.
+func TestWholeDocumentReflowIsSilent(t *testing.T) {
+ src := `# Title
+
+A paragraph that is wrapped at some particular width and says several
+things across more than one line of source text.
+
+- a list item long enough to be rewrapped by an editor at some point
+- another list item
+
+> a block quote that also happens to be wrapped across two source
+> lines here
+
+| a | b |
+|---|---|
+| 1 | 2 |
+
+` + "```go\nkeep := \"this exactly\"\n```\n"
+
+ rewrapped := rewrapProse(src, 40)
+ require.NotEqual(t, src, rewrapped, "test fixture did not actually rewrap")
+
+ d := Compare([]byte(src), []byte(rewrapped))
+ assert.False(t, d.Stats.Changed(), "rewrapping produced: %s", RenderText(d, DefaultRenderOptions()))
+}
+
+// rewrapProse rewraps paragraph, list and quote lines to width, leaving fenced
+// code and table rows alone.
+func rewrapProse(src string, width int) string {
+ var out []string
+ inFence := false
+ var para []string
+ var prefix string
+
+ flush := func() {
+ if len(para) == 0 {
+ return
+ }
+ cont := prefix
+ if prefix == "- " {
+ cont = " " // continuation of a list item, not a new one
+ }
+ for i, l := range wrap(strings.Join(para, " "), width) {
+ if i == 0 {
+ out = append(out, prefix+l)
+ continue
+ }
+ out = append(out, cont+l)
+ }
+ para = nil
+ prefix = ""
+ }
+
+ for _, line := range strings.Split(src, "\n") {
+ switch {
+ case strings.HasPrefix(line, "```"):
+ flush()
+ inFence = !inFence
+ out = append(out, line)
+ case inFence, strings.HasPrefix(line, "|"), strings.HasPrefix(line, "#"), strings.TrimSpace(line) == "":
+ flush()
+ out = append(out, line)
+ case strings.HasPrefix(line, "> "):
+ prefix = "> "
+ para = append(para, strings.TrimPrefix(line, "> "))
+ case strings.HasPrefix(line, "- "):
+ flush()
+ prefix = "- "
+ para = append(para, strings.TrimPrefix(line, "- "))
+ default:
+ para = append(para, strings.TrimSpace(line))
+ }
+ }
+ flush()
+ return strings.Join(out, "\n")
+}
+
+func TestMoveDetection(t *testing.T) {
+ a := "# Doc\n\n## Alpha\n\nThe alpha section body, long enough to be recognised.\n\n## Beta\n\nThe beta section body, also long enough to be recognised.\n"
+ b := "# Doc\n\n## Beta\n\nThe beta section body, also long enough to be recognised.\n\n## Alpha\n\nThe alpha section body, long enough to be recognised.\n"
+
+ d := Compare([]byte(a), []byte(b))
+ var kinds []ChangeKind
+ for _, c := range d.Changes {
+ if c.Kind != ChangeEqual {
+ kinds = append(kinds, c.Kind)
+ }
+ }
+ require.NotEmpty(t, kinds)
+ for _, k := range kinds {
+ assert.Contains(t, []ChangeKind{ChangeMoveIn, ChangeMoveOut}, k,
+ "a pure reorder should be moves only, got %v\n%s", kinds, RenderText(d, DefaultRenderOptions()))
+ }
+ assert.Equal(t, 2, d.Stats.BlocksMoved)
+}
+
+// TestMoveGroupBridgesOneEditedBlock covers the common real case: a section
+// is moved and one paragraph inside it is touched. The untouched blocks must
+// stay moves and the touched one must be a modification, not four inserts.
+func TestMoveGroupBridgesOneEditedBlock(t *testing.T) {
+ // Alpha is the section that moves; Beta is deliberately the larger one,
+ // so the alignment keeps Beta in place and Alpha is what has to be
+ // recognised as moved.
+ sectionA := "## Alpha\n\nAlpha intro paragraph, long enough to anchor a move.\n\n" +
+ "Alpha middle paragraph that will be edited slightly.\n\n" +
+ "Alpha closing paragraph, also long enough to anchor a move.\n"
+ sectionB := "## Beta\n\nBeta first paragraph, long enough to anchor a move too.\n\n" +
+ "Beta second paragraph, long enough to anchor a move too.\n\n" +
+ "Beta third paragraph, long enough to anchor a move too.\n\n" +
+ "Beta fourth paragraph, long enough to anchor a move too.\n\n" +
+ "Beta fifth paragraph, long enough to anchor a move too.\n"
+
+ a := "# Doc\n\n" + sectionA + "\n" + sectionB
+ b := "# Doc\n\n" + sectionB + "\n" +
+ strings.Replace(sectionA, "edited slightly", "edited a little", 1)
+
+ d := Compare([]byte(a), []byte(b))
+ var modified []BlockChange
+ for _, c := range d.Changes {
+ switch c.Kind {
+ case ChangeModify:
+ modified = append(modified, c)
+ case ChangeInsert, ChangeDelete:
+ t.Fatalf("unexpected %s in a move+edit:\n%s", c.Kind, RenderText(d, DefaultRenderOptions()))
+ }
+ }
+ require.Len(t, modified, 1)
+ assert.True(t, modified[0].Moved, "the edited block should be marked as moved too")
+ assert.Equal(t, 3, d.Stats.BlocksMoved)
+}
+
+// TestMovedAndEditedIsNotAMove documents the honest limitation: content that
+// moved *and* changed, with no verbatim block left to anchor it, shows up as
+// a delete plus an insert.
+func TestMovedAndEditedIsNotAMove(t *testing.T) {
+ a := "## Alpha\n\nThe alpha body here.\n\n## Beta\n\nThe beta body here.\n"
+ b := "## Beta\n\nThe beta body here.\n\n## Alpha\n\nThe alpha body here, now with a tail.\n"
+
+ d := Compare([]byte(a), []byte(b))
+ assert.Equal(t, 0, d.Stats.BlocksMoved,
+ "edited-while-moved must not be claimed as a move:\n%s", RenderText(d, DefaultRenderOptions()))
+}
+
+// TestHeadingRenameDoesNotDirtyItsSection: HeadingPath is context for the
+// reviewer, never part of a block's identity. Including it would make
+// renaming a section rewrite every block underneath it.
+func TestHeadingRenameDoesNotDirtyItsSection(t *testing.T) {
+ old := "## The old section name\n\nFirst paragraph of the section.\n\nSecond paragraph of the section.\n"
+ nw := "## The new section name\n\nFirst paragraph of the section.\n\nSecond paragraph of the section.\n"
+ d := Compare([]byte(old), []byte(nw))
+ assert.Equal(t, 2, d.Stats.BlocksEqual)
+ assert.Equal(t, 1, d.Stats.BlocksModified)
+}
+
+// TestShortBlockRenameFallsBackToAddRemove pins the other side of the
+// similarity floor: two blocks too short to judge are reported as a removal
+// and an addition rather than paired on a coin-flip score.
+func TestShortBlockRenameFallsBackToAddRemove(t *testing.T) {
+ d := Compare([]byte("## Old name\n\nbody\n"), []byte("## New name\n\nbody\n"))
+ assert.Equal(t, 0, d.Stats.BlocksModified)
+ assert.Equal(t, 1, d.Stats.BlocksInserted)
+ assert.Equal(t, 1, d.Stats.BlocksDeleted)
+}
+
+func TestHeadingPathIsCarried(t *testing.T) {
+ src := "# Top\n\n## Middle\n\n### Leaf\n\nbody\n"
+ blocks := Segment([]byte(src))
+ last := blocks[len(blocks)-1]
+ assert.Equal(t, []string{"Top", "Middle", "Leaf"}, last.HeadingPath)
+}
+
+func TestRenderText(t *testing.T) {
+ d := Compare(
+ []byte("# Title\n\nThe quick brown fox jumps over the lazy dog every single day.\n"),
+ []byte("# Title\n\nThe quick red fox jumps over the lazy dog every single day.\n"),
+ )
+ out := RenderText(d, DefaultRenderOptions())
+ assert.Contains(t, out, "@@ Title @@")
+ assert.Contains(t, out, "[-brown-]")
+ assert.Contains(t, out, "{+red+}")
+ assert.NotContains(t, out, "\n L1 h1") // equal blocks hidden by default
+
+ full := RenderText(d, RenderOptions{Width: 80, ShowEqual: true})
+ assert.Contains(t, full, "Title")
+}
+
+func TestRenderTextCodeIsLineOriented(t *testing.T) {
+ d := Compare(
+ []byte("```go\na := 1\nb := 2\n```\n"),
+ []byte("```go\na := 1\nb := 3\n```\n"),
+ )
+ out := RenderText(d, DefaultRenderOptions())
+ assert.Contains(t, out, " - b := 2")
+ assert.Contains(t, out, " + b := 3")
+}
A prosediff/render.go => prosediff/render.go +216 -0
@@ 0,0 1,216 @@
+package prosediff
+
+import (
+ "fmt"
+ "strings"
+ "unicode/utf8"
+)
+
+// RenderOptions controls the plain-text renderer. It exists for tests and for
+// reading a diff in a terminal; the web layer walks Diff.Changes itself.
+type RenderOptions struct {
+ // Width wraps rendered prose. 0 means do not wrap.
+ Width int
+ // ShowEqual prints unchanged blocks too, instead of collapsing them.
+ ShowEqual bool
+ // Context is how many unchanged blocks to keep around a change when
+ // ShowEqual is false. 0 keeps none.
+ Context int
+ // Markers are the inline delete/insert brackets. Empty uses the
+ // defaults "[-", "-]", "{+", "+}".
+ DelOpen, DelClose, InsOpen, InsClose string
+}
+
+// DefaultRenderOptions wraps at 80 columns and hides unchanged blocks.
+func DefaultRenderOptions() RenderOptions {
+ return RenderOptions{Width: 80, Context: 0}
+}
+
+func (o RenderOptions) markers() (string, string, string, string) {
+ d0, d1, i0, i1 := o.DelOpen, o.DelClose, o.InsOpen, o.InsClose
+ if d0 == "" && d1 == "" && i0 == "" && i1 == "" {
+ return "[-", "-]", "{+", "+}"
+ }
+ return d0, d1, i0, i1
+}
+
+// RenderText renders a diff as plain text.
+func RenderText(d *Diff, opts RenderOptions) string {
+ var sb strings.Builder
+ keep := visible(d.Changes, opts)
+
+ lastPath := "\x00"
+ skipped := 0
+ flushSkipped := func() {
+ if skipped > 0 {
+ fmt.Fprintf(&sb, " … %d unchanged block(s)\n", skipped)
+ skipped = 0
+ }
+ }
+
+ for i, c := range d.Changes {
+ if !keep[i] {
+ if c.Kind == ChangeEqual {
+ skipped++
+ }
+ continue
+ }
+ flushSkipped()
+ b := c.New
+ if b == nil {
+ b = c.Old
+ }
+ if p := strings.Join(b.HeadingPath, " › "); p != lastPath {
+ if p == "" {
+ p = "(document preamble)"
+ }
+ fmt.Fprintf(&sb, "\n@@ %s @@\n", p)
+ lastPath = p
+ }
+ sb.WriteString(renderChange(c, opts))
+ }
+ flushSkipped()
+ return sb.String()
+}
+
+func visible(changes []BlockChange, opts RenderOptions) []bool {
+ keep := make([]bool, len(changes))
+ for i, c := range changes {
+ if c.Kind != ChangeEqual || opts.ShowEqual {
+ keep[i] = true
+ }
+ }
+ if opts.Context > 0 && !opts.ShowEqual {
+ orig := append([]bool(nil), keep...)
+ for i := range changes {
+ if !orig[i] {
+ continue
+ }
+ for j := i - opts.Context; j <= i+opts.Context; j++ {
+ if j >= 0 && j < len(keep) {
+ keep[j] = true
+ }
+ }
+ }
+ }
+ return keep
+}
+
+func renderChange(c BlockChange, opts RenderOptions) string {
+ var sb strings.Builder
+ d0, d1, i0, i1 := opts.markers()
+
+ switch c.Kind {
+ case ChangeEqual:
+ fmt.Fprintf(&sb, " %s %s\n", loc(c.Old, c.New), c.New.Label())
+ writeBody(&sb, " ", c.New.Text, opts.Width, c.New.Kind.Prose())
+
+ case ChangeInsert:
+ fmt.Fprintf(&sb, "+ %s %s\n", loc(nil, c.New), c.New.Label())
+ writeBody(&sb, "+ ", c.New.Text, opts.Width, c.New.Kind.Prose())
+
+ case ChangeDelete:
+ fmt.Fprintf(&sb, "- %s %s\n", loc(c.Old, nil), c.Old.Label())
+ writeBody(&sb, "- ", c.Old.Text, opts.Width, c.Old.Kind.Prose())
+
+ case ChangeMoveOut:
+ fmt.Fprintf(&sb, "< %s %s moved away (now line %d)\n",
+ loc(c.Old, nil), c.Old.Label(), c.New.StartLine)
+
+ case ChangeMoveIn:
+ fmt.Fprintf(&sb, "> %s %s moved here (was line %d)\n",
+ loc(nil, c.New), c.New.Label(), c.Old.StartLine)
+
+ case ChangeModify:
+ note := ""
+ if c.StructureOnly {
+ note = fmt.Sprintf(" (structure only: %s → %s)", c.Old.Label(), c.New.Label())
+ }
+ if c.Moved {
+ note += fmt.Sprintf(" (moved from line %d)", c.Old.StartLine)
+ }
+ fmt.Fprintf(&sb, "~ %s %s modified%s\n", loc(c.Old, c.New), c.New.Label(), note)
+ if len(c.Lines) > 0 {
+ for _, s := range c.Lines {
+ switch s.Op {
+ case OpEqual:
+ fmt.Fprintf(&sb, " %s\n", s.Text)
+ case OpDelete:
+ fmt.Fprintf(&sb, " - %s\n", s.Text)
+ case OpInsert:
+ fmt.Fprintf(&sb, " + %s\n", s.Text)
+ }
+ }
+ break
+ }
+ var body strings.Builder
+ for _, s := range c.Words {
+ if s.Space && body.Len() > 0 {
+ body.WriteByte(' ')
+ }
+ switch s.Op {
+ case OpEqual:
+ body.WriteString(s.Text)
+ case OpDelete:
+ body.WriteString(d0 + s.Text + d1)
+ case OpInsert:
+ body.WriteString(i0 + s.Text + i1)
+ }
+ }
+ writeBody(&sb, " ", body.String(), opts.Width, true)
+ }
+ return sb.String()
+}
+
+func loc(old, nw *Block) string {
+ switch {
+ case old != nil && nw != nil:
+ if old.StartLine == nw.StartLine {
+ return fmt.Sprintf("L%d", old.StartLine)
+ }
+ return fmt.Sprintf("L%d→%d", old.StartLine, nw.StartLine)
+ case old != nil:
+ return fmt.Sprintf("L%d", old.StartLine)
+ case nw != nil:
+ return fmt.Sprintf("L%d", nw.StartLine)
+ }
+ return "L?"
+}
+
+func writeBody(sb *strings.Builder, prefix, body string, width int, reflow bool) {
+ var lines []string
+ if reflow {
+ lines = wrap(body, width-len(prefix))
+ } else {
+ lines = strings.Split(body, "\n")
+ }
+ for _, line := range lines {
+ sb.WriteString(prefix)
+ sb.WriteString(line)
+ sb.WriteByte('\n')
+ }
+}
+
+// wrap reflows prose to width columns. Prose is always rewrapped: a diff that
+// preserved the source wrapping would put the reviewer back where a line
+// differ left them.
+func wrap(s string, width int) []string {
+ fields := strings.Fields(s)
+ if len(fields) == 0 {
+ return []string{""}
+ }
+ if width <= 0 {
+ return []string{strings.Join(fields, " ")}
+ }
+ var out []string
+ line := fields[0]
+ for _, f := range fields[1:] {
+ if utf8.RuneCountInString(line)+1+utf8.RuneCountInString(f) > width {
+ out = append(out, line)
+ line = f
+ continue
+ }
+ line += " " + f
+ }
+ return append(out, line)
+}
A prosediff/segment.go => prosediff/segment.go +457 -0
@@ 0,0 1,457 @@
+package prosediff
+
+import (
+ "fmt"
+ "hash/fnv"
+ "sort"
+ "strings"
+
+ "github.com/yuin/goldmark"
+ "github.com/yuin/goldmark/ast"
+ "github.com/yuin/goldmark/extension"
+ extast "github.com/yuin/goldmark/extension/ast"
+ "github.com/yuin/goldmark/text"
+)
+
+// BlockKind is the structural role of a block. It is part of a block's
+// identity: a paragraph that becomes a list item is not a modified paragraph.
+type BlockKind string
+
+const (
+ KindFrontmatter BlockKind = "frontmatter"
+ KindHeading BlockKind = "heading"
+ KindParagraph BlockKind = "paragraph"
+ KindListItem BlockKind = "list_item"
+ KindCode BlockKind = "code"
+ KindTableHeader BlockKind = "table_header"
+ KindTableRow BlockKind = "table_row"
+ KindThematicBreak BlockKind = "rule"
+ KindHTML BlockKind = "html"
+)
+
+// Prose reports whether this kind diffs word-by-word. Everything else diffs
+// line-by-line, which is the code-fence distinction the design calls for.
+func (k BlockKind) Prose() bool {
+ switch k {
+ case KindCode, KindFrontmatter, KindHTML:
+ return false
+ }
+ return true
+}
+
+// Block is one addressable unit of a document: a paragraph, a heading, a
+// single list item, a whole code fence, one table row.
+//
+// The tuple (HeadingPath, Kind, Ordinal, Hash) is deliberately the anchor
+// tuple the design names for inline comments; nothing here is diff-only.
+type Block struct {
+ Kind BlockKind
+ Ordinal int // 0-based position in the document
+ Level int // heading level, or list nesting depth for list items
+ QuoteDepth int // blockquote nesting, 0 outside any quote
+ HeadingPath []string // enclosing headings, outermost first
+ Text string // source text of the block, as written
+ Lines []string // source lines; the diff unit for non-prose kinds
+ Info string // code fence language, list marker, table column count
+ StartLine int // 1-based, inclusive
+ EndLine int // 1-based, inclusive
+ Hash string // structure + normalized content
+}
+
+// Empty reports whether the block carries no content at all.
+func (b Block) Empty() bool { return strings.TrimSpace(b.Text) == "" }
+
+// Label is a short human description, used by the text renderer and usable
+// as-is by a future web layer.
+func (b Block) Label() string {
+ s := string(b.Kind)
+ switch b.Kind {
+ case KindHeading:
+ s = fmt.Sprintf("h%d", b.Level)
+ case KindListItem:
+ if b.Level > 1 {
+ s = fmt.Sprintf("list item (depth %d)", b.Level)
+ } else {
+ s = "list item"
+ }
+ case KindCode:
+ if b.Info != "" {
+ s = "code:" + b.Info
+ }
+ }
+ if b.QuoteDepth > 0 {
+ s = strings.Repeat("quoted ", b.QuoteDepth) + s
+ }
+ return s
+}
+
+// Segment splits a markdown document into blocks in document order.
+func Segment(src []byte) []Block {
+ body, fm, lineOffset := splitFrontmatter(src)
+
+ s := &segmenter{src: body, lineStarts: lineStarts(body), lineOffset: lineOffset}
+ if fm != nil {
+ s.blocks = append(s.blocks, finishBlock(Block{
+ Kind: KindFrontmatter,
+ Lines: fm,
+ Text: strings.Join(fm, "\n"),
+ StartLine: 1,
+ EndLine: len(fm),
+ }))
+ }
+
+ md := goldmark.New(goldmark.WithExtensions(extension.Table))
+ doc := md.Parser().Parse(text.NewReader(body))
+ s.walk(doc, ctx{})
+
+ for i := range s.blocks {
+ s.blocks[i].Ordinal = i
+ }
+ return s.blocks
+}
+
+// splitFrontmatter peels a leading YAML frontmatter fence off the document.
+// goldmark would otherwise parse "---" as a thematic break and the keys as a
+// paragraph, which diffs badly and is not what the block is.
+func splitFrontmatter(src []byte) (body []byte, fm []string, lineOffset int) {
+ s := string(src)
+ if !strings.HasPrefix(s, "---\n") && s != "---" {
+ return src, nil, 0
+ }
+ rest := s[4:]
+ end := strings.Index(rest, "\n---")
+ if end < 0 {
+ return src, nil, 0
+ }
+ tail := rest[end+4:]
+ if tail != "" && !strings.HasPrefix(tail, "\n") {
+ return src, nil, 0
+ }
+ fm = strings.Split(s[:end+8], "\n")
+ if tail != "" {
+ tail = tail[1:]
+ }
+ return []byte(tail), fm, len(fm)
+}
+
+type ctx struct {
+ headings []string
+ quoteDepth int
+ listDepth int
+ marker string
+}
+
+type segmenter struct {
+ src []byte
+ lineStarts []int
+ lineOffset int
+ blocks []Block
+ // headingStack holds (level, text) of the currently open headings.
+ headingStack []headingEntry
+}
+
+type headingEntry struct {
+ level int
+ text string
+}
+
+func (s *segmenter) walk(n ast.Node, c ctx) {
+ for child := n.FirstChild(); child != nil; child = child.NextSibling() {
+ s.node(child, c)
+ }
+}
+
+func (s *segmenter) node(n ast.Node, c ctx) {
+ switch v := n.(type) {
+ case *ast.Heading:
+ txt := s.linesText(v.Lines())
+ start, end := s.segLines(v.Lines())
+ s.emit(Block{
+ Kind: KindHeading,
+ Level: v.Level,
+ QuoteDepth: c.quoteDepth,
+ HeadingPath: s.currentPath(),
+ Text: txt,
+ Lines: splitLines(txt),
+ StartLine: start,
+ EndLine: end,
+ })
+ s.pushHeading(v.Level, txt)
+
+ case *ast.Paragraph, *ast.TextBlock:
+ lines := n.Lines()
+ txt := s.linesText(lines)
+ if strings.TrimSpace(txt) == "" {
+ return
+ }
+ start, end := s.segLines(lines)
+ kind := KindParagraph
+ level := 0
+ if c.listDepth > 0 {
+ kind = KindListItem
+ level = c.listDepth
+ }
+ s.emit(Block{
+ Kind: kind,
+ Level: level,
+ QuoteDepth: c.quoteDepth,
+ HeadingPath: s.currentPath(),
+ Text: txt,
+ Lines: splitLines(txt),
+ Info: c.marker,
+ StartLine: start,
+ EndLine: end,
+ })
+
+ case *ast.FencedCodeBlock:
+ txt := s.linesText(v.Lines())
+ start, end := s.segLines(v.Lines())
+ info := ""
+ if v.Info != nil {
+ seg := v.Info.Segment
+ info = string(seg.Value(s.src))
+ }
+ s.emit(Block{
+ Kind: KindCode,
+ QuoteDepth: c.quoteDepth,
+ Level: c.listDepth,
+ HeadingPath: s.currentPath(),
+ Text: txt,
+ Lines: splitLines(txt),
+ Info: info,
+ StartLine: start,
+ EndLine: end,
+ })
+
+ case *ast.CodeBlock:
+ txt := s.linesText(v.Lines())
+ start, end := s.segLines(v.Lines())
+ s.emit(Block{
+ Kind: KindCode,
+ QuoteDepth: c.quoteDepth,
+ Level: c.listDepth,
+ HeadingPath: s.currentPath(),
+ Text: txt,
+ Lines: splitLines(txt),
+ Info: "indented",
+ StartLine: start,
+ EndLine: end,
+ })
+
+ case *ast.HTMLBlock:
+ txt := s.linesText(v.Lines())
+ start, end := s.segLines(v.Lines())
+ s.emit(Block{
+ Kind: KindHTML,
+ QuoteDepth: c.quoteDepth,
+ HeadingPath: s.currentPath(),
+ Text: txt,
+ Lines: splitLines(txt),
+ StartLine: start,
+ EndLine: end,
+ })
+
+ case *ast.ThematicBreak:
+ line := s.lineOf(nodeStart(n))
+ s.emit(Block{
+ Kind: KindThematicBreak,
+ QuoteDepth: c.quoteDepth,
+ HeadingPath: s.currentPath(),
+ Text: "---",
+ Lines: []string{"---"},
+ StartLine: line,
+ EndLine: line,
+ })
+
+ case *ast.List:
+ inner := c
+ inner.listDepth = c.listDepth + 1
+ inner.marker = string(rune(v.Marker))
+ if v.IsOrdered() {
+ inner.marker = "ordered"
+ }
+ s.walk(v, inner)
+
+ case *ast.ListItem:
+ s.walk(v, c)
+
+ case *ast.Blockquote:
+ inner := c
+ inner.quoteDepth = c.quoteDepth + 1
+ s.walk(v, inner)
+
+ case *extast.Table:
+ s.table(v, c)
+
+ default:
+ // Containers we do not model explicitly still get descended into,
+ // so no content is silently dropped.
+ if n.Type() == ast.TypeBlock && n.HasChildren() {
+ s.walk(n, c)
+ }
+ }
+}
+
+func (s *segmenter) table(t *extast.Table, c ctx) {
+ cols := len(t.Alignments)
+ for row := t.FirstChild(); row != nil; row = row.NextSibling() {
+ kind := KindTableRow
+ if row.Kind() == extast.KindTableHeader {
+ kind = KindTableHeader
+ }
+ start, stop := nodeSpan(row)
+ if start < 0 {
+ continue
+ }
+ txt := strings.TrimRight(s.sourceLineRange(start, stop), "\n")
+ line := s.lineOf(start)
+ s.emit(Block{
+ Kind: kind,
+ QuoteDepth: c.quoteDepth,
+ HeadingPath: s.currentPath(),
+ Text: txt,
+ Lines: splitLines(txt),
+ Info: fmt.Sprintf("%d cols", cols),
+ StartLine: line,
+ EndLine: s.lineOf(stop),
+ })
+ }
+}
+
+func (s *segmenter) emit(b Block) {
+ s.blocks = append(s.blocks, finishBlock(b))
+}
+
+// finishBlock computes the identity hash: structure plus normalized content.
+// Prose normalizes through the tokenizer (so wrapping does not count); code
+// and frontmatter keep their lines verbatim (so whitespace does count).
+func finishBlock(b Block) Block {
+ h := fnv.New64a()
+ fmt.Fprintf(h, "%s\x00%d\x00%d\x00", b.Kind, b.Level, b.QuoteDepth)
+ if b.Kind.Prose() {
+ h.Write([]byte(Normalize(b.Text)))
+ } else {
+ h.Write([]byte(b.Info))
+ h.Write([]byte{0})
+ h.Write([]byte(strings.Join(b.Lines, "\n")))
+ }
+ b.Hash = fmt.Sprintf("%016x", h.Sum64())
+ return b
+}
+
+func (s *segmenter) pushHeading(level int, txt string) {
+ for len(s.headingStack) > 0 && s.headingStack[len(s.headingStack)-1].level >= level {
+ s.headingStack = s.headingStack[:len(s.headingStack)-1]
+ }
+ s.headingStack = append(s.headingStack, headingEntry{level: level, text: strings.TrimSpace(txt)})
+}
+
+func (s *segmenter) currentPath() []string {
+ if len(s.headingStack) == 0 {
+ return nil
+ }
+ out := make([]string, len(s.headingStack))
+ for i, e := range s.headingStack {
+ out[i] = e.text
+ }
+ return out
+}
+
+func (s *segmenter) linesText(segs *text.Segments) string {
+ if segs == nil || segs.Len() == 0 {
+ return ""
+ }
+ var sb strings.Builder
+ for i := 0; i < segs.Len(); i++ {
+ seg := segs.At(i)
+ sb.Write(seg.Value(s.src))
+ }
+ return strings.TrimRight(sb.String(), "\n")
+}
+
+func (s *segmenter) segLines(segs *text.Segments) (int, int) {
+ if segs == nil || segs.Len() == 0 {
+ return 0, 0
+ }
+ return s.lineOf(segs.At(0).Start), s.lineOf(segs.At(segs.Len()-1).Stop - 1)
+}
+
+// sourceLineRange expands a byte span to whole source lines, which is how a
+// table row (whose AST node carries only inline segments) recovers the pipe
+// syntax the reviewer actually wrote.
+func (s *segmenter) sourceLineRange(start, stop int) string {
+ if start < 0 || stop > len(s.src) || start > stop {
+ return ""
+ }
+ for start > 0 && s.src[start-1] != '\n' {
+ start--
+ }
+ for stop < len(s.src) && s.src[stop] != '\n' {
+ stop++
+ }
+ return string(s.src[start:stop])
+}
+
+func (s *segmenter) lineOf(off int) int {
+ i := sort.SearchInts(s.lineStarts, off+1) - 1
+ if i < 0 {
+ i = 0
+ }
+ return i + 1 + s.lineOffset
+}
+
+func lineStarts(src []byte) []int {
+ out := []int{0}
+ for i, c := range src {
+ if c == '\n' {
+ out = append(out, i+1)
+ }
+ }
+ return out
+}
+
+func splitLines(s string) []string {
+ if s == "" {
+ return nil
+ }
+ return strings.Split(s, "\n")
+}
+
+// nodeSpan returns the byte range covered by a node's descendant text
+// segments, or (-1, -1) when the node carries none.
+func nodeSpan(n ast.Node) (int, int) {
+ start, stop := -1, -1
+ consider := func(a, b int) {
+ if start < 0 || a < start {
+ start = a
+ }
+ if b > stop {
+ stop = b
+ }
+ }
+ var visit func(ast.Node)
+ visit = func(n ast.Node) {
+ if t, ok := n.(*ast.Text); ok {
+ consider(t.Segment.Start, t.Segment.Stop)
+ }
+ // Lines() panics on inline nodes, so it is only asked of blocks.
+ if n.Type() == ast.TypeBlock {
+ if lines := n.Lines(); lines != nil && lines.Len() > 0 {
+ consider(lines.At(0).Start, lines.At(lines.Len()-1).Stop)
+ }
+ }
+ for c := n.FirstChild(); c != nil; c = c.NextSibling() {
+ visit(c)
+ }
+ }
+ visit(n)
+ return start, stop
+}
+
+func nodeStart(n ast.Node) int {
+ start, _ := nodeSpan(n)
+ if start < 0 {
+ return 0
+ }
+ return start
+}
A prosediff/segment_test.go => prosediff/segment_test.go +150 -0
@@ 0,0 1,150 @@
+package prosediff
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+type wantBlock struct {
+ kind BlockKind
+ level int
+ quote int
+ text string
+ path []string
+}
+
+func TestSegment(t *testing.T) {
+ tests := []struct {
+ name string
+ src string
+ want []wantBlock
+ }{
+ {
+ name: "headings and paragraphs",
+ src: "# Title\n\nFirst para\nwrapped over lines.\n\n## Sub\n\nSecond para.\n",
+ want: []wantBlock{
+ {kind: KindHeading, level: 1, text: "Title"},
+ {kind: KindParagraph, text: "First para\nwrapped over lines.", path: []string{"Title"}},
+ {kind: KindHeading, level: 2, text: "Sub", path: []string{"Title"}},
+ {kind: KindParagraph, text: "Second para.", path: []string{"Title", "Sub"}},
+ },
+ },
+ {
+ name: "list items are separate blocks",
+ src: "- alpha\n- beta\n - nested\n",
+ want: []wantBlock{
+ {kind: KindListItem, level: 1, text: "alpha"},
+ {kind: KindListItem, level: 1, text: "beta"},
+ {kind: KindListItem, level: 2, text: "nested"},
+ },
+ },
+ {
+ name: "fenced code is one block",
+ src: "text\n\n```go\na := 1\nb := 2\n```\n",
+ want: []wantBlock{
+ {kind: KindParagraph, text: "text"},
+ {kind: KindCode, text: "a := 1\nb := 2"},
+ },
+ },
+ {
+ name: "table rows are separate blocks",
+ src: "| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n",
+ want: []wantBlock{
+ {kind: KindTableHeader, text: "| a | b |"},
+ {kind: KindTableRow, text: "| 1 | 2 |"},
+ {kind: KindTableRow, text: "| 3 | 4 |"},
+ },
+ },
+ {
+ name: "block quote carries depth",
+ src: "> quoted text\n> continues\n",
+ want: []wantBlock{
+ {kind: KindParagraph, quote: 1, text: "quoted text\ncontinues"},
+ },
+ },
+ {
+ name: "frontmatter is its own block",
+ src: "---\nid: SPEC-0007\ntitle: x\n---\n\nBody.\n",
+ want: []wantBlock{
+ {kind: KindFrontmatter, text: "---\nid: SPEC-0007\ntitle: x\n---"},
+ {kind: KindParagraph, text: "Body."},
+ },
+ },
+ {
+ name: "empty document",
+ src: "",
+ want: nil,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := Segment([]byte(tc.src))
+ require.Len(t, got, len(tc.want))
+ for i, w := range tc.want {
+ b := got[i]
+ assert.Equal(t, w.kind, b.Kind, "block %d kind", i)
+ assert.Equal(t, w.level, b.Level, "block %d level", i)
+ assert.Equal(t, w.quote, b.QuoteDepth, "block %d quote depth", i)
+ assert.Equal(t, w.text, b.Text, "block %d text", i)
+ assert.Equal(t, w.path, b.HeadingPath, "block %d heading path", i)
+ assert.Equal(t, i, b.Ordinal)
+ assert.NotEmpty(t, b.Hash)
+ }
+ })
+ }
+}
+
+func TestSegmentLineNumbers(t *testing.T) {
+ src := "# Title\n\npara one\n\n```\ncode\n```\n"
+ got := Segment([]byte(src))
+ require.Len(t, got, 3)
+ assert.Equal(t, 1, got[0].StartLine)
+ assert.Equal(t, 3, got[1].StartLine)
+ assert.Equal(t, 6, got[2].StartLine)
+}
+
+func TestSegmentFrontmatterLineOffset(t *testing.T) {
+ src := "---\nid: X\n---\n\n# Title\n"
+ got := Segment([]byte(src))
+ require.Len(t, got, 2)
+ assert.Equal(t, KindFrontmatter, got[0].Kind)
+ assert.Equal(t, 5, got[1].StartLine)
+}
+
+// TestHashIgnoresWrappingForProse and its code counterpart are the two halves
+// of the design's "code fences diff as code, prose diffs as prose" rule.
+func TestHashIgnoresWrappingForProse(t *testing.T) {
+ a := Segment([]byte("one two three four\nfive six\n"))
+ b := Segment([]byte("one two\nthree four five six\n"))
+ require.Len(t, a, 1)
+ require.Len(t, b, 1)
+ assert.Equal(t, a[0].Hash, b[0].Hash)
+}
+
+func TestHashRespectsWhitespaceForCode(t *testing.T) {
+ a := Segment([]byte("```\nif x:\n y()\n```\n"))
+ b := Segment([]byte("```\nif x:\n\ty()\n```\n"))
+ require.Len(t, a, 1)
+ require.Len(t, b, 1)
+ assert.NotEqual(t, a[0].Hash, b[0].Hash)
+}
+
+func TestHashRespectsStructure(t *testing.T) {
+ para := Segment([]byte("same words here\n"))
+ item := Segment([]byte("- same words here\n"))
+ require.Len(t, para, 1)
+ require.Len(t, item, 1)
+ assert.NotEqual(t, para[0].Hash, item[0].Hash)
+}
+
+func TestKindProse(t *testing.T) {
+ for _, k := range []BlockKind{KindHeading, KindParagraph, KindListItem, KindTableRow, KindTableHeader, KindThematicBreak} {
+ assert.True(t, k.Prose(), "%s should diff as prose", k)
+ }
+ for _, k := range []BlockKind{KindCode, KindFrontmatter, KindHTML} {
+ assert.False(t, k.Prose(), "%s should diff as code", k)
+ }
+}
A prosediff/token.go => prosediff/token.go +273 -0
@@ 0,0 1,273 @@
+package prosediff
+
+import (
+ "strings"
+ "unicode"
+ "unicode/utf8"
+)
+
+// Token is one unit of the word-level diff.
+//
+// Whitespace is *not* a token. It survives only as Space, which records that
+// some run of whitespace separated this token from the previous one. That is
+// what makes reflow invisible: a paragraph rewrapped from 72 to 80 columns
+// produces byte-for-byte the same token stream, because "\n" and " " both
+// collapse to Space=true.
+type Token struct {
+ Text string
+ Space bool
+}
+
+// Tokenize splits prose into words and punctuation.
+//
+// A word is a run of letters/digits, optionally joined by an internal
+// apostrophe or hyphen ("don't", "word-level") or an internal dot between
+// digits ("v5.19.1"). Every other non-space rune forms a token from its own
+// repeated run, so "**" and "---" stay single tokens rather than exploding
+// into markup noise.
+func Tokenize(s string) []Token {
+ var out []Token
+ space := false
+ for i := 0; i < len(s); {
+ r, sz := utf8.DecodeRuneInString(s[i:])
+ switch {
+ case unicode.IsSpace(r):
+ space = true
+ i += sz
+ case isWordRune(r):
+ j := i
+ for j < len(s) {
+ r2, sz2 := utf8.DecodeRuneInString(s[j:])
+ if isWordRune(r2) {
+ j += sz2
+ continue
+ }
+ if isJoinRune(r2) && continuesWord(s, j+sz2, r2) {
+ j += sz2
+ continue
+ }
+ break
+ }
+ out = append(out, Token{Text: s[i:j], Space: space})
+ space = false
+ i = j
+ default:
+ j := i
+ for j < len(s) {
+ r2, sz2 := utf8.DecodeRuneInString(s[j:])
+ if r2 != r {
+ break
+ }
+ j += sz2
+ }
+ out = append(out, Token{Text: s[i:j], Space: space})
+ space = false
+ i = j
+ }
+ }
+ return out
+}
+
+func isWordRune(r rune) bool {
+ return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_'
+}
+
+func isJoinRune(r rune) bool { return r == '\'' || r == '-' || r == '.' || r == '’' }
+
+// continuesWord reports whether the join rune at the previous position is
+// followed by more word material (and, for a dot, by a digit — so "end." does
+// not swallow the sentence terminator).
+func continuesWord(s string, next int, join rune) bool {
+ if next >= len(s) {
+ return false
+ }
+ r, _ := utf8.DecodeRuneInString(s[next:])
+ if join == '.' {
+ return unicode.IsDigit(r)
+ }
+ return isWordRune(r)
+}
+
+// TokenTexts drops the spacing, leaving the comparable content.
+func TokenTexts(toks []Token) []string {
+ out := make([]string, len(toks))
+ for i, t := range toks {
+ out[i] = t.Text
+ }
+ return out
+}
+
+// Normalize collapses a block's source text to its token stream joined by
+// single spaces. Two blocks with the same Normalize are the same prose,
+// however they were wrapped.
+func Normalize(s string) string {
+ return strings.Join(TokenTexts(Tokenize(s)), " ")
+}
+
+// Span is a run of word-level tokens sharing one Op, ready to render.
+//
+// Space says whether to emit a separating space before this span when the
+// spans are rendered in order. An insertion that directly replaces a deletion
+// carries Space=false, because the deletion before it already carried the
+// separator; otherwise every one-word substitution would render as
+// "[-old-] {+new+}".
+type Span struct {
+ Op Op
+ Text string // tokens joined with single spaces
+ Space bool
+}
+
+// smallEqualRun is the character budget below which an unchanged run wedged
+// between two changed runs is absorbed into the change. Without it, a rewrite
+// that happens to keep a comma or an "a" produces shredded output like
+// "[-x-]{+y+} , [-z-]{+w+}".
+const smallEqualRun = 4
+
+// DiffWords produces the inline edit script between two pieces of prose.
+// Whitespace differences alone yield a single OpEqual span.
+func DiffWords(oldText, newText string) []Span {
+ a := Tokenize(oldText)
+ b := Tokenize(newText)
+ in := newInterner()
+ script := diffInts(in.all(TokenTexts(a)), in.all(TokenTexts(b)))
+ script = absorbSmallEqualRuns(script, a)
+ return spans(deletesFirst(script), a, b)
+}
+
+// absorbSmallEqualRuns rewrites tiny equal runs that sit between two changed
+// runs into delete+insert, so the surrounding change reads as one edit.
+func absorbSmallEqualRuns(script []edit, a []Token) []edit {
+ if len(script) < 3 {
+ return script
+ }
+ // Position of each run in a, needed to measure the equal run's length.
+ posA := make([]int, len(script))
+ x := 0
+ for i, e := range script {
+ posA[i] = x
+ if e.op != OpInsert {
+ x += e.n
+ }
+ }
+ out := make([]edit, 0, len(script)+4)
+ for i, e := range script {
+ if e.op != OpEqual || i == 0 || i == len(script)-1 {
+ out = append(out, e)
+ continue
+ }
+ n := 0
+ for _, t := range a[posA[i] : posA[i]+e.n] {
+ n += len(t.Text)
+ }
+ if n >= smallEqualRun {
+ out = append(out, e)
+ continue
+ }
+ out = append(out, edit{OpDelete, e.n}, edit{OpInsert, e.n})
+ }
+ return coalesce(out)
+}
+
+// deletesFirst rewrites each changed region so all deletions precede all
+// insertions, whichever order Myers happened to emit them in.
+func deletesFirst(script []edit) []edit {
+ out := make([]edit, 0, len(script))
+ for i := 0; i < len(script); {
+ if script[i].op == OpEqual {
+ out = append(out, script[i])
+ i++
+ continue
+ }
+ del, ins := 0, 0
+ for ; i < len(script) && script[i].op != OpEqual; i++ {
+ if script[i].op == OpDelete {
+ del += script[i].n
+ } else {
+ ins += script[i].n
+ }
+ }
+ if del > 0 {
+ out = append(out, edit{OpDelete, del})
+ }
+ if ins > 0 {
+ out = append(out, edit{OpInsert, ins})
+ }
+ }
+ return out
+}
+
+// spans walks an edit script and materializes it into renderable runs.
+func spans(script []edit, a, b []Token) []Span {
+ var out []Span
+ emit := func(op Op, toks []Token) {
+ if len(toks) == 0 {
+ return
+ }
+ var sb strings.Builder
+ for i, t := range toks {
+ if i > 0 && t.Space {
+ sb.WriteByte(' ')
+ }
+ sb.WriteString(t.Text)
+ }
+ out = append(out, Span{Op: op, Text: sb.String(), Space: toks[0].Space})
+ }
+
+ i, j := 0, 0
+ for k := 0; k < len(script); k++ {
+ e := script[k]
+ // An insertion directly replacing a deletion must not re-announce
+ // the whitespace the deletion already carried, or every one-word
+ // substitution renders as "[-old-] {+new+}".
+ if e.op == OpInsert && k > 0 && script[k-1].op == OpDelete {
+ emit(OpInsert, b[j:j+e.n])
+ if len(out) > 0 {
+ out[len(out)-1].Space = false
+ }
+ j += e.n
+ continue
+ }
+ switch e.op {
+ case OpEqual:
+ emit(OpEqual, a[i:i+e.n])
+ i += e.n
+ j += e.n
+ case OpDelete:
+ emit(OpDelete, a[i:i+e.n])
+ i += e.n
+ case OpInsert:
+ emit(OpInsert, b[j:j+e.n])
+ j += e.n
+ }
+ }
+ return out
+}
+
+// DiffLines is the code-fence path: line-oriented, whitespace-significant.
+func DiffLines(oldLines, newLines []string) []Span {
+ in := newInterner()
+ script := diffInts(in.all(oldLines), in.all(newLines))
+ var out []Span
+ i, j := 0, 0
+ for _, e := range script {
+ switch e.op {
+ case OpEqual:
+ for n := 0; n < e.n; n++ {
+ out = append(out, Span{Op: OpEqual, Text: oldLines[i+n]})
+ }
+ i += e.n
+ j += e.n
+ case OpDelete:
+ for n := 0; n < e.n; n++ {
+ out = append(out, Span{Op: OpDelete, Text: oldLines[i+n]})
+ }
+ i += e.n
+ case OpInsert:
+ for n := 0; n < e.n; n++ {
+ out = append(out, Span{Op: OpInsert, Text: newLines[j+n]})
+ }
+ j += e.n
+ }
+ }
+ return out
+}
A prosediff/token_test.go => prosediff/token_test.go +140 -0
@@ 0,0 1,140 @@
+package prosediff
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestTokenize(t *testing.T) {
+ tests := []struct {
+ name string
+ in string
+ want []string
+ space []bool
+ }{
+ {"plain words", "one two", []string{"one", "two"}, []bool{false, true}},
+ {"punctuation splits", "end.", []string{"end", "."}, []bool{false, false}},
+ {"apostrophe joins", "don't stop", []string{"don't", "stop"}, []bool{false, true}},
+ {"hyphen joins", "word-level diff", []string{"word-level", "diff"}, []bool{false, true}},
+ {"version numbers stay whole", "v5.19.1", []string{"v5.19.1"}, nil},
+ {"sentence dot still splits", "done. next", []string{"done", ".", "next"}, nil},
+ {"emphasis is one token", "**bold**", []string{"**", "bold", "**"}, nil},
+ {"em dash stands alone", "a — b", []string{"a", "—", "b"}, []bool{false, true, true}},
+ {"newline is just space", "a\nb", []string{"a", "b"}, []bool{false, true}},
+ {"cyrillic is a word", "спецификация готова", []string{"спецификация", "готова"}, nil},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ toks := Tokenize(tc.in)
+ assert.Equal(t, tc.want, TokenTexts(toks))
+ if tc.space != nil {
+ got := make([]bool, len(toks))
+ for i, tk := range toks {
+ got[i] = tk.Space
+ }
+ assert.Equal(t, tc.space, got)
+ }
+ })
+ }
+}
+
+// TestNormalizeIgnoresWrapping is the load-bearing property of the whole
+// package: rewrapping prose must not change its normalized form.
+func TestNormalizeIgnoresWrapping(t *testing.T) {
+ narrow := "Markdown reflows. A one-word edit renders as a\nwhole-paragraph replace under a\nline-oriented differ."
+ wide := "Markdown reflows. A one-word edit renders as a whole-paragraph replace\nunder a line-oriented differ."
+ assert.Equal(t, Normalize(narrow), Normalize(wide))
+ assert.Equal(t, Normalize(narrow), Normalize(strings.ReplaceAll(narrow, "\n", " ")))
+}
+
+func TestDiffWords(t *testing.T) {
+ tests := []struct {
+ name string
+ old, new string
+ want []Span
+ }{
+ {
+ name: "reflow only is a single equal span",
+ old: "the quick brown fox\njumps over the lazy dog",
+ new: "the quick\nbrown fox jumps over\nthe lazy dog",
+ want: []Span{{Op: OpEqual, Text: "the quick brown fox jumps over the lazy dog"}},
+ },
+ {
+ name: "one word replaced",
+ old: "a quick brown fox",
+ new: "a quick red fox",
+ want: []Span{
+ {Op: OpEqual, Text: "a quick"},
+ {Op: OpDelete, Text: "brown", Space: true},
+ // Space is false: the deletion it replaces already carried it.
+ {Op: OpInsert, Text: "red"},
+ {Op: OpEqual, Text: "fox", Space: true},
+ },
+ },
+ {
+ name: "word appended",
+ old: "one two",
+ new: "one two three",
+ want: []Span{
+ {Op: OpEqual, Text: "one two"},
+ {Op: OpInsert, Text: "three", Space: true},
+ },
+ },
+ {
+ name: "punctuation alone",
+ old: "yes, always",
+ new: "yes; always",
+ want: []Span{
+ {Op: OpEqual, Text: "yes"},
+ {Op: OpDelete, Text: ","},
+ {Op: OpInsert, Text: ";"},
+ {Op: OpEqual, Text: "always", Space: true},
+ },
+ },
+ {
+ name: "identical text",
+ old: "nothing changed here",
+ new: "nothing changed here",
+ want: []Span{{Op: OpEqual, Text: "nothing changed here"}},
+ },
+ {
+ name: "emptied",
+ old: "gone",
+ new: "",
+ want: []Span{{Op: OpDelete, Text: "gone"}},
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ assert.Equal(t, tc.want, DiffWords(tc.old, tc.new))
+ })
+ }
+}
+
+// TestDiffWordsAbsorbsShredding checks the cleanup pass: a rewrite that
+// coincidentally keeps a comma must not be reported as three tiny edits.
+func TestDiffWordsAbsorbsShredding(t *testing.T) {
+ spans := DiffWords(
+ "bots produce, humans curate",
+ "agents emit, operators approve",
+ )
+ require.Len(t, spans, 2)
+ assert.Equal(t, OpDelete, spans[0].Op)
+ assert.Equal(t, "bots produce, humans curate", spans[0].Text)
+ assert.Equal(t, OpInsert, spans[1].Op)
+ assert.Equal(t, "agents emit, operators approve", spans[1].Text)
+}
+
+func TestDiffLinesIsWhitespaceSensitive(t *testing.T) {
+ old := []string{"func main() {", "\tfmt.Println(1)", "}"}
+ nw := []string{"func main() {", " fmt.Println(1)", "}"}
+ spans := DiffLines(old, nw)
+ require.Len(t, spans, 4)
+ assert.Equal(t, OpEqual, spans[0].Op)
+ assert.Equal(t, OpDelete, spans[1].Op)
+ assert.Equal(t, OpInsert, spans[2].Op)
+ assert.Equal(t, OpEqual, spans[3].Op)
+}