~bigbes/sr-ht-spec

ref: 394285f4cb998359c0a127454de4d32284a3ef22 sr-ht-spec/web/diffrows.go -rw-r--r-- 23.8 KiB
394285f4 — Eugene Blikh chore(beads): file spec-rsb and spec-ovo, the two admin commands 13 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
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 append(infoRows(c, blk), lineScriptRows(c, blk)...)
	}
	if c.Similarity >= inlineSimilarityThreshold {
		if old, nw, ok := prosediff.WordsByLine(c); ok {
			return mergeLineWords(old, nw, blk)
		}
	}
	return regionRows(c, blk)
}

// infoRows is the one marker a modified code fence may need: the language on its
// opening delimiter changed.
//
// prosediff hashes a block's Info, so ```go becoming ```python pairs the two
// fences as a modification — but Block.Lines holds the fence's contents without
// its delimiters, so the line script is entirely equal and every row renders as
// context. The page would then say the document changed and show nothing that
// did. The fence delimiters are not rows of this table and inventing a number
// for one would be a guess, so the change is stated as a marker instead.
//
// Only a code fence is covered. Info also carries a list item's marker and a
// table's column count, and neither is a language: "- became *" is noise, and a
// table whose column count changed already differs in its cells.
func infoRows(c prosediff.BlockChange, blk *rowBlock) []diffRow {
	if c.Old.Kind != prosediff.KindCode || c.Old.Info == c.New.Info {
		return nil
	}
	return []diffRow{{
		Kind:  rowMove,
		Block: blk,
		Note:  fmt.Sprintf("code block: %s → %s", infoLabel(c.Old.Info), infoLabel(c.New.Info)),
	}}
}

// infoLabel spells the two Info values that are states rather than languages: a
// bare ``` fence, and an indented block, which the segmenter records as
// "indented" and which has no delimiter line at all.
func infoLabel(info string) string {
	switch info {
	case "":
		return "no language"
	case "indented":
		return "indented, unfenced"
	}
	return info
}

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