package web
import (
"fmt"
"strings"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
"sourcecraft.dev/bigbes/sr-ht-spec/prosediff"
)
// This file is the row model of the unified diff: the arithmetic of which line
// number goes in which gutter track, and nothing else. It emits no HTML on
// purpose. Line attribution is the part of this renderer that can be quietly
// wrong — a number off by one invites a reviewer to comment on text that was
// never there — and a model built out of structs can be tested by reading its
// fields instead of by matching substrings of markup.
// rowKind is what one row says happened to its line. The values are the
// suffixes of the ph-r-* classes the markup contract pins, so the HTML writer
// concatenates rather than translates.
type rowKind string
const (
rowEqual rowKind = "eq"
rowInsert rowKind = "ins"
rowDelete rowKind = "del"
rowMove rowKind = "move"
// rowNotes is not a line. It marks the place in the stream where a block's
// threads and its compose form belong. It lives in the model rather than in
// the writer because where it goes — after the last row of its block, before
// the first row of the next — is a statement about order, and because the
// folder has to know a block's comment UI is there before it hides the block.
rowNotes rowKind = "notes"
)
// Folding thresholds. A run of unchanged lines is collapsed so a screenful of
// context never competes with what changed, but collapsing is not free: it
// costs a click and it hides text a reviewer may want to point at.
//
// foldMinRun and foldMinHidden are two gates, not one. A run of exactly
// foldMinRun rows keeps foldKeepEdge rows at each end and would therefore hide
// two, which is a bad trade — one toggle row replacing two lines of prose — so
// the second gate rejects it and folding effectively starts at seven rows. The
// markup contract states both numbers and they do not quite agree at the
// boundary; the resolution here is the conservative one, because a fold that
// saves nothing is a click that buys nothing.
const (
foldMinRun = 6
foldKeepEdge = 2
foldMinHidden = 3
)
// rowBlock is what every row of one block shares. Rows point at it rather than
// copying it so that the folder can ask a question about the *block* — does
// anyone have a comment on it — while walking rows.
type rowBlock struct {
// Anchor and Key are the block's comment identity; both are zero when
// Commentable is false, which is the move-out marker and nothing else today.
Anchor core.CommentAnchor
Key blockKey
Commentable bool
// Notes says a notes row was emitted for this block: it has threads, or the
// viewer is the owner and gets a compose form. A block with neither gets no
// row, because an empty one would be a gap in the table for no reason.
Notes bool
// HasThreads is only about folding: an unchanged block someone has already
// commented on is no longer merely context.
HasThreads bool
// Context marks a block that is unchanged on both sides. It, and not the row
// kind, is what the folder groups by — a block's notes row is not a ph-r-eq
// row but belongs inside the fold with the lines it hangs off.
Context bool
// Heading drives the sticky section readout; Mono drives the monospaced text
// cell of a code fence, frontmatter or HTML block.
Heading bool
Mono bool
}
// diffRow is one <tr> of the unified diff, before it is one.
//
// The two number fields are independent on purpose: a row states a number for
// the side it came from and leaves the other side empty. That is what makes it
// impossible to attribute a line number to the wrong revision — the alternative,
// carrying one number plus a side flag, puts the decision in the writer, where
// a rewrapped block would have to guess.
type diffRow struct {
Kind rowKind
Block *rowBlock
// OldNum and NewNum are 1-based source line numbers, or zero for "this side
// has no number for this row". Zero renders as an empty cell and never as a
// 0: an honest blank beats a number nobody can defend.
OldNum, NewNum int
// OldEnd and NewEnd close a line range on a region row, and are zero
// everywhere else.
OldEnd, NewEnd int
// Region marks the fallback row that stands for a whole block rather than
// for one line — see regionRows.
Region bool
// Spans is the row's content as an edit script: one equal span for an
// untouched line, several for a line carrying word-level marks. Empty when
// Note is set.
Spans []prosediff.Span
// Note is renderer-authored text rather than document content — the move
// markers. It is still escaped on the way out, because a block label can
// carry a code fence's info string, which the document wrote.
Note string
// Start marks the first row of a block: the row that carries the id a
// comment link scrolls to.
Start bool
// Folded marks a row hidden until its fold is opened.
Folded bool
}
// rowGroup is one <tbody>. Grouping exists only to make folding a pure CSS
// affordance: a fold group holds the rows a toggle hides, and everything else
// accumulates into plain groups. Block identity is never carried by a group —
// a fold boundary can and does cut a block in half.
type rowGroup struct {
Fold bool
// Index numbers the fold groups of one document, so their checkboxes get
// distinct ids on a page that renders several documents.
Index int
// Hidden is how many *lines* the fold hides, which is what its label says.
// Notes rows are hidden with them but are not lines and are not counted.
Hidden int
Rows []diffRow
}
// blockInfo is what the row builder cannot work out for itself: the comment
// identity of a change and whether anything has been said about it. It is
// passed in as a function so the model can be built — and tested — without a
// thread store, a document path or a template behind it.
type blockInfo struct {
Anchor core.CommentAnchor
Key blockKey
Commentable bool
HasThreads bool
Notes bool
}
// buildRows turns a document's block changes into the unified row stream, in
// document order.
//
// Every change contributes at least one row. A block that produced none would
// be document content that silently left the page, which is worse than a row
// that only says the block is empty.
func buildRows(changes []prosediff.BlockChange, info func(prosediff.BlockChange) blockInfo) []diffRow {
var rows []diffRow
for _, c := range changes {
blk := newRowBlock(c, info(c))
at := len(rows)
rows = append(rows, changeRows(c, blk)...)
if len(rows) == at {
continue
}
rows[at].Start = true
if blk.Notes {
rows = append(rows, diffRow{Kind: rowNotes, Block: blk})
}
}
return rows
}
// newRowBlock derives the per-block facts every row of a change shares.
//
// The structural facts come from the side the rows are drawn from — the old
// block for a deletion and for a move-out marker, the new one otherwise — so a
// block that changed kind (a paragraph promoted to a heading) is described by
// the revision the reader is looking at.
func newRowBlock(c prosediff.BlockChange, info blockInfo) *rowBlock {
src := c.New
if c.Kind == prosediff.ChangeDelete || c.Kind == prosediff.ChangeMoveOut {
src = c.Old
}
blk := &rowBlock{
Anchor: info.Anchor,
Key: info.Key,
Commentable: info.Commentable,
Notes: info.Notes,
HasThreads: info.HasThreads,
Context: c.Kind == prosediff.ChangeEqual,
}
if src != nil {
blk.Heading = src.Kind == prosediff.KindHeading
blk.Mono = !src.Kind.Prose()
}
return blk
}
// changeRows renders one block change into rows. The five kinds that carry a
// whole block map onto their lines directly and exactly; only a modification
// has to recover which line a word edit fell on, which is modifyRows' problem.
func changeRows(c prosediff.BlockChange, blk *rowBlock) []diffRow {
switch c.Kind {
case prosediff.ChangeEqual:
return equalRows(c, blk)
case prosediff.ChangeInsert:
return wholeBlockRows(c.New, rowInsert, false, blk)
case prosediff.ChangeDelete:
return wholeBlockRows(c.Old, rowDelete, true, blk)
case prosediff.ChangeMoveIn:
// The marker first, then the text. A move-in is commentable — the block
// is at its new position and this is where a reviewer objects to it — so
// it shows its lines; a comment control on text the reviewer cannot see
// is a control on nothing.
note := diffRow{
Kind: rowMove,
Block: blk,
Note: fmt.Sprintf("%s moved here (was line %d)", c.New.Label(), c.Old.StartLine),
}
return append([]diffRow{note}, wholeBlockRows(c.New, rowMove, false, blk)...)
case prosediff.ChangeMoveOut:
// One marker and no text: the block is rendered in full at its new
// position, and showing it twice would give one paragraph two places to
// be commented on.
return []diffRow{{
Kind: rowMove,
Block: blk,
OldNum: c.Old.StartLine,
Note: fmt.Sprintf("%s moved away (now line %d)", c.Old.Label(), c.New.StartLine),
}}
case prosediff.ChangeModify:
return modifyRows(c, blk)
}
return nil
}
// equalRows renders an unchanged block as context.
//
// A row states an old line number only when the old revision really does hold
// this text on that line. The rule used to be that the two sides' line *counts*
// agreeing was proof enough of a 1:1 correspondence, and it is not: the block is
// equal at the token level, which is what makes a rewrap invisible to the
// differ, so words can move across the line breaks while the count stays the
// same. "alpha beta / gamma delta" rewrapped to "alpha / beta gamma delta" is
// two lines before and after, and pairing them by position numbered a row 2
// whose text was never on old line 2 — a reviewer selecting it would have
// commented on text that does not exist in that revision, which is the exact
// failure prosediff.WordsByLine refuses to risk.
//
// So each row is checked on its own, and an unpaired row leaves the old cell
// empty exactly as the count-mismatch case already did. Blank beats fabricated.
func equalRows(c prosediff.BlockChange, blk *rowBlock) []diffRow {
nw := blockLines(c.New)
old := blockLines(c.Old)
prose := c.New.Kind.Prose()
rows := make([]diffRow, len(nw))
for i, ln := range nw {
rows[i] = diffRow{
Kind: rowEqual,
Block: blk,
NewNum: c.New.StartLine + i,
Spans: plainSpans(ln),
}
if i < len(old) && sameSourceLine(old[i], ln, prose) {
rows[i].OldNum = c.Old.StartLine + i
}
}
return rows
}
// sameSourceLine reports whether two revisions' copies of a line hold the same
// text, by the same yardstick finishBlock uses to hash the block: prose
// compares normalized, because the tokenizer is what the differ ran on and the
// space between two words is not a difference anyone can see; everything else
// compares verbatim, because in a code fence it is.
func sameSourceLine(old, nw string, prose bool) bool {
if !prose {
return old == nw
}
return prosediff.Normalize(old) == prosediff.Normalize(nw)
}
// wholeBlockRows renders every line of a block on one side of the diff: an
// insertion, a deletion, or the body of a move-in.
func wholeBlockRows(src *prosediff.Block, kind rowKind, old bool, blk *rowBlock) []diffRow {
lines := blockLines(src)
rows := make([]diffRow, len(lines))
for i, ln := range lines {
rows[i] = diffRow{Kind: kind, Block: blk, Spans: plainSpans(ln)}
if old {
rows[i].OldNum = src.StartLine + i
} else {
rows[i].NewNum = src.StartLine + i
}
}
return rows
}
// modifyRows renders an edited block, choosing among the three presentations
// the design pins.
//
// A code fence, frontmatter or HTML block already has a line-oriented script
// and needs no recovery. A prose block that stayed similar enough to follow has
// its word script spread back over its source lines. A prose block rewritten
// past that point — or one whose lines and text disagree, so the spreading
// cannot be trusted — falls back to a pair of region rows.
func modifyRows(c prosediff.BlockChange, blk *rowBlock) []diffRow {
if len(c.Lines) > 0 {
return lineScriptRows(c, blk)
}
if c.Similarity >= inlineSimilarityThreshold {
if old, nw, ok := prosediff.WordsByLine(c); ok {
return mergeLineWords(old, nw, blk)
}
}
return regionRows(c, blk)
}
// lineScriptRows renders a modified non-prose block. prosediff.DiffLines emits
// exactly one span per line, so the two counters walk the two revisions in step
// and every row's number is read off, not derived.
func lineScriptRows(c prosediff.BlockChange, blk *rowBlock) []diffRow {
oldNo, newNo := c.Old.StartLine, c.New.StartLine
rows := make([]diffRow, 0, len(c.Lines))
for _, s := range c.Lines {
row := diffRow{Block: blk, Spans: plainSpans(s.Text)}
switch s.Op {
case prosediff.OpDelete:
row.Kind, row.OldNum = rowDelete, oldNo
oldNo++
case prosediff.OpInsert:
row.Kind, row.NewNum = rowInsert, newNo
newNo++
default:
row.Kind, row.OldNum, row.NewNum = rowEqual, oldNo, newNo
oldNo++
newNo++
}
rows = append(rows, row)
}
return rows
}
// mergeLineWords interleaves the two sides of a spread word script into one
// unified column.
//
// The two sides are separate sequences of lines with no correspondence stored
// between them — prosediff.WordsByLine hands back the old block's lines and the
// new block's lines, each carrying its own share of the script — so the order
// they appear in is this function's choice. Two cursors walk them:
//
// 1. a line neither side marked, with the same text on both, is one context
// row carrying both numbers;
// 2. otherwise an old line carrying a deletion is emitted alone, old track only;
// 3. otherwise a new line carrying an insertion is emitted alone, new track only;
// 4. otherwise the two lines differ without either being marked, which is a
// rewrap: the words did not change but the lines did, so the pair is emitted
// as a removed line followed by an added one, adjacent.
//
// Case 4 is the judgement call. The alternative was to emit the pair as one
// context row and let the gutter show both numbers, which reads better but says
// two lines are the same line when their text differs; in a table whose whole
// contract is "the number in the gutter is the number in the file", that is the
// wrong lie to tell. The alternative to case 2 before 3 — pairing a marked old
// line with a marked new line on one row — was rejected because it re-invents a
// correspondence the differ deliberately did not compute.
//
// Whatever the interleaving does, a row's number always comes from its own
// side's LineWords, so an imperfect order costs readability and never
// correctness.
func mergeLineWords(old, nw []prosediff.LineWords, blk *rowBlock) []diffRow {
var rows []diffRow
delRow := func(l prosediff.LineWords) diffRow {
return diffRow{Kind: rowDelete, Block: blk, OldNum: l.Line, Spans: l.Spans}
}
insRow := func(l prosediff.LineWords) diffRow {
return diffRow{Kind: rowInsert, Block: blk, NewNum: l.Line, Spans: l.Spans}
}
i, j := 0, 0
for i < len(old) && j < len(nw) {
o, n := old[i], nw[j]
switch {
case !marked(o.Spans, prosediff.OpDelete) && !marked(n.Spans, prosediff.OpInsert) &&
lineText(o) == lineText(n):
rows = append(rows, diffRow{
Kind: rowEqual, Block: blk,
OldNum: o.Line, NewNum: n.Line, Spans: n.Spans,
})
i++
j++
case marked(o.Spans, prosediff.OpDelete):
rows = append(rows, delRow(o))
i++
case marked(n.Spans, prosediff.OpInsert):
rows = append(rows, insRow(n))
j++
default:
rows = append(rows, delRow(o), insRow(n))
i++
j++
}
}
for ; i < len(old); i++ {
rows = append(rows, delRow(old[i]))
}
for ; j < len(nw); j++ {
rows = append(rows, insRow(nw[j]))
}
return rows
}
// regionRows is the honest fallback: one row for the old side of the block and
// one for the new, each labelled by the line range it covers rather than by a
// line number.
//
// It fires for a block rewritten past the point where inline marks stay
// readable — the Phase 0 verdict's one review in eight — and for a block whose
// word script could not be spread back over its lines. In both cases a per-line
// number would be a guess, and the design's rule is that a range the reader can
// check beats a number they cannot.
func regionRows(c prosediff.BlockChange, blk *rowBlock) []diffRow {
return []diffRow{
{
Kind: rowDelete, Block: blk, Region: true,
OldNum: c.Old.StartLine, OldEnd: c.Old.EndLine,
Spans: sideSpans(c.Words, true),
},
{
Kind: rowInsert, Block: blk, Region: true,
NewNum: c.New.StartLine, NewEnd: c.New.EndLine,
Spans: sideSpans(c.Words, false),
},
}
}
// sideSpans keeps one side of a word script: the old side keeps equal and
// deleted words, the new side keeps equal and inserted ones. Both keep their
// marks, so each region row is a readable paragraph that also shows what moved.
//
// The separator of a dropped span moves onto the next kept one. Span.Space says
// a space preceded that span *in the combined rendering*, and an insertion that
// directly replaces a deletion carries Space=false because the deletion in front
// of it already carried the space — prosediff's own note on the matter. Split
// onto one side that deletion is gone, and without this the row would read "the
// committeerejected the budget".
func sideSpans(spans []prosediff.Span, old bool) []prosediff.Span {
out := make([]prosediff.Span, 0, len(spans))
space := false
for _, s := range spans {
switch {
case s.Op == prosediff.OpEqual,
old && s.Op == prosediff.OpDelete,
!old && s.Op == prosediff.OpInsert:
default:
space = space || s.Space
continue
}
s.Space = s.Space || space
space = false
out = append(out, s)
}
return out
}
// groupRows cuts the row stream into <tbody> groups, collapsing long runs of
// context.
//
// A run is delimited by blocks, not by rows: a block is foldable when it is
// unchanged and carries no threads, and then all of its rows fold, its notes
// row included. Keying on the row kind instead would let a changed block's
// compose form — which is not a ph-r-eq row but sits between two of them —
// either break every run or be swallowed into a fold it does not belong to.
func groupRows(rows []diffRow) []rowGroup {
var groups []rowGroup
var plain []diffRow
folds := 0
flush := func() {
if len(plain) > 0 {
groups = append(groups, rowGroup{Rows: plain})
plain = nil
}
}
for i := 0; i < len(rows); {
if !foldable(rows[i]) {
plain = append(plain, rows[i])
i++
continue
}
j := i
for j < len(rows) && foldable(rows[j]) {
j++
}
run := rows[i:j]
i = j
lo, hi, ok := foldWindow(run)
if !ok {
plain = append(plain, run...)
continue
}
plain = append(plain, run[:lo]...)
flush()
hidden := make([]diffRow, hi-lo)
lines := 0
for k, row := range run[lo:hi] {
row.Folded = true
hidden[k] = row
if row.Kind != rowNotes {
lines++
}
}
groups = append(groups, rowGroup{Fold: true, Index: folds, Hidden: lines, Rows: hidden})
folds++
plain = append(plain, run[hi:]...)
}
flush()
return groups
}
// foldable reports whether a row may be hidden inside a fold. A block someone
// has commented on never is: it stopped being context the moment somebody had
// something to say about it, and a comment behind a closed fold is a comment
// nobody reads.
func foldable(row diffRow) bool {
return row.Block.Context && !row.Block.HasThreads
}
// foldWindow picks the half-open range of a context run to hide: everything
// between the first foldKeepEdge lines and the last foldKeepEdge lines, which
// keeps a couple of lines of orientation on each side of the gap. The window is
// measured in rows so that a notes row falling inside the gap is hidden with
// it, and counted in lines so that both gates judge the same thing the label
// will report.
//
// The rule the window has to respect is that a block's notes row is hidden
// exactly when the whole block is. A compose form for lines nobody can see is a
// control on nothing — the same reason a move-in renders its text — and a
// compose form hidden away from lines that *are* visible is worse still,
// because the block looks uncommentable. Only the opening edge can break it: a
// window that starts inside a block would keep that block's first lines visible
// and swallow the trailer that follows its last one, so in that case the window
// opens after the trailer instead. The closing edge cannot break it, because hi
// is a line row by construction and a trailer always directly follows its
// block's last line.
func foldWindow(run []diffRow) (lo, hi int, ok bool) {
var lines []int
for i, row := range run {
if row.Kind != rowNotes {
lines = append(lines, i)
}
}
if len(lines) < foldMinRun || len(lines)-2*foldKeepEdge < foldMinHidden {
return 0, 0, false
}
lo, hi = lines[foldKeepEdge], lines[len(lines)-foldKeepEdge]
if !run[lo].Start {
for k := lo; k < hi && !run[k].Start; k++ {
if run[k].Kind == rowNotes {
lo = k + 1
break
}
}
}
// Giving that trailer back can leave too little to be worth a click, so the
// gate is asked again about what is actually left.
hidden := 0
for _, i := range lines {
if i >= lo && i < hi {
hidden++
}
}
if hidden < foldMinHidden {
return 0, 0, false
}
return lo, hi, true
}
// blockLines is a block's source lines, with the block's whole text as the one
// line of a block that records none. Nothing in the segmenter produces such a
// block today; this is what keeps that assumption from silently deleting a
// block from the page if one ever does.
func blockLines(src *prosediff.Block) []string {
if len(src.Lines) > 0 {
return src.Lines
}
return []string{src.Text}
}
// plainSpans is a line with no word-level marks on it, as a one-span script, so
// every row's content has the same shape whatever produced it.
func plainSpans(text string) []prosediff.Span {
return []prosediff.Span{{Op: prosediff.OpEqual, Text: text}}
}
// marked reports whether a line's share of a word script carries an op.
func marked(spans []prosediff.Span, op prosediff.Op) bool {
for _, s := range spans {
if s.Op == op {
return true
}
}
return false
}
// lineText rebuilds a spread line's text for comparison.
//
// It compares reconstructed tokens rather than the source lines because that is
// what "the same line" has to mean here: the tokenizer is what the diff ran on,
// so two lines differing only in how much whitespace separates their words are
// the same line to every part of this package.
func lineText(l prosediff.LineWords) string {
var b strings.Builder
for i, s := range l.Spans {
if s.Space && i > 0 {
b.WriteByte(' ')
}
b.WriteString(s.Text)
}
return b.String()
}