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 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 . 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 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() }