coverage~bigbes/sr-ht-spec64cae3afweb/diffrows.go

Coverage
96.6% 173/179 statements
Δ
+0.0
Blob
1280fc0
1 package web
2
3 import (
4 "fmt"
5 "strings"
6
7 "sourcecraft.dev/bigbes/sr-ht-spec/core"
8 "sourcecraft.dev/bigbes/sr-ht-spec/prosediff"
9 )
10
11 // This file is the row model of the unified diff: the arithmetic of which line
12 // number goes in which gutter track, and nothing else. It emits no HTML on
13 // purpose. Line attribution is the part of this renderer that can be quietly
14 // wrong — a number off by one invites a reviewer to comment on text that was
15 // never there — and a model built out of structs can be tested by reading its
16 // fields instead of by matching substrings of markup.
17
18 // rowKind is what one row says happened to its line. The values are the
19 // suffixes of the ph-r-* classes the markup contract pins, so the HTML writer
20 // concatenates rather than translates.
21 type rowKind string
22
23 const (
24 rowEqual rowKind = "eq"
25 rowInsert rowKind = "ins"
26 rowDelete rowKind = "del"
27 rowMove rowKind = "move"
28 // rowNotes is not a line. It marks the place in the stream where a block's
29 // threads and its compose form belong. It lives in the model rather than in
30 // the writer because where it goes — after the last row of its block, before
31 // the first row of the next — is a statement about order, and because the
32 // folder has to know a block's comment UI is there before it hides the block.
33 rowNotes rowKind = "notes"
34 )
35
36 // Folding thresholds. A run of unchanged lines is collapsed so a screenful of
37 // context never competes with what changed, but collapsing is not free: it
38 // costs a click and it hides text a reviewer may want to point at.
39 //
40 // foldMinRun and foldMinHidden are two gates, not one. A run of exactly
41 // foldMinRun rows keeps foldKeepEdge rows at each end and would therefore hide
42 // two, which is a bad trade — one toggle row replacing two lines of prose — so
43 // the second gate rejects it and folding effectively starts at seven rows. The
44 // markup contract states both numbers and they do not quite agree at the
45 // boundary; the resolution here is the conservative one, because a fold that
46 // saves nothing is a click that buys nothing.
47 const (
48 foldMinRun = 6
49 foldKeepEdge = 2
50 foldMinHidden = 3
51 )
52
53 // rowBlock is what every row of one block shares. Rows point at it rather than
54 // copying it so that the folder can ask a question about the *block* — does
55 // anyone have a comment on it — while walking rows.
56 type rowBlock struct {
57 // Anchor and Key are the block's comment identity; both are zero when
58 // Commentable is false, which is the move-out marker and nothing else today.
59 Anchor core.CommentAnchor
60 Key blockKey
61 Commentable bool
62
63 // Notes says a notes row was emitted for this block: it has threads, or the
64 // viewer is the owner and gets a compose form. A block with neither gets no
65 // row, because an empty one would be a gap in the table for no reason.
66 Notes bool
67 // HasThreads is only about folding: an unchanged block someone has already
68 // commented on is no longer merely context.
69 HasThreads bool
70
71 // Context marks a block that is unchanged on both sides. It, and not the row
72 // kind, is what the folder groups by — a block's notes row is not a ph-r-eq
73 // row but belongs inside the fold with the lines it hangs off.
74 Context bool
75 // Heading drives the sticky section readout; Mono drives the monospaced text
76 // cell of a code fence, frontmatter or HTML block.
77 Heading bool
78 Mono bool
79 }
80
81 // diffRow is one <tr> of the unified diff, before it is one.
82 //
83 // The two number fields are independent on purpose: a row states a number for
84 // the side it came from and leaves the other side empty. That is what makes it
85 // impossible to attribute a line number to the wrong revision — the alternative,
86 // carrying one number plus a side flag, puts the decision in the writer, where
87 // a rewrapped block would have to guess.
88 type diffRow struct {
89 Kind rowKind
90 Block *rowBlock
91
92 // OldNum and NewNum are 1-based source line numbers, or zero for "this side
93 // has no number for this row". Zero renders as an empty cell and never as a
94 // 0: an honest blank beats a number nobody can defend.
95 OldNum, NewNum int
96 // OldEnd and NewEnd close a line range on a region row, and are zero
97 // everywhere else.
98 OldEnd, NewEnd int
99 // Region marks the fallback row that stands for a whole block rather than
100 // for one line — see regionRows.
101 Region bool
102
103 // Spans is the row's content as an edit script: one equal span for an
104 // untouched line, several for a line carrying word-level marks. Empty when
105 // Note is set.
106 Spans []prosediff.Span
107 // Note is renderer-authored text rather than document content — the move
108 // markers. It is still escaped on the way out, because a block label can
109 // carry a code fence's info string, which the document wrote.
110 Note string
111
112 // Start marks the first row of a block: the row that carries the id a
113 // comment link scrolls to.
114 Start bool
115 // Folded marks a row hidden until its fold is opened.
116 Folded bool
117 }
118
119 // rowGroup is one <tbody>. Grouping exists only to make folding a pure CSS
120 // affordance: a fold group holds the rows a toggle hides, and everything else
121 // accumulates into plain groups. Block identity is never carried by a group —
122 // a fold boundary can and does cut a block in half.
123 type rowGroup struct {
124 Fold bool
125 // Index numbers the fold groups of one document, so their checkboxes get
126 // distinct ids on a page that renders several documents.
127 Index int
128 // Hidden is how many *lines* the fold hides, which is what its label says.
129 // Notes rows are hidden with them but are not lines and are not counted.
130 Hidden int
131 Rows []diffRow
132 }
133
134 // blockInfo is what the row builder cannot work out for itself: the comment
135 // identity of a change and whether anything has been said about it. It is
136 // passed in as a function so the model can be built — and tested — without a
137 // thread store, a document path or a template behind it.
138 type blockInfo struct {
139 Anchor core.CommentAnchor
140 Key blockKey
141 Commentable bool
142 HasThreads bool
143 Notes bool
144 }
145
146 // buildRows turns a document's block changes into the unified row stream, in
147 // document order.
148 //
149 // Every change contributes at least one row. A block that produced none would
150 // be document content that silently left the page, which is worse than a row
151 // that only says the block is empty.
152 2848 func buildRows(changes []prosediff.BlockChange, info func(prosediff.BlockChange) blockInfo) []diffRow {
153 2848 var rows []diffRow
154 10098 for _, c := range changes {
155 10098 blk := newRowBlock(c, info(c))
156 10098 at := len(rows)
157 10098 rows = append(rows, changeRows(c, blk)...)
158 10098 if len(rows) == at {
159 0 continue
160 }
161 10098 rows[at].Start = true
162 10098 if blk.Notes {
163 53 rows = append(rows, diffRow{Kind: rowNotes, Block: blk})
164 53 }
165 }
166 2848 return rows
167 }
168
169 // newRowBlock derives the per-block facts every row of a change shares.
170 //
171 // The structural facts come from the side the rows are drawn from — the old
172 // block for a deletion and for a move-out marker, the new one otherwise — so a
173 // block that changed kind (a paragraph promoted to a heading) is described by
174 // the revision the reader is looking at.
175 10098 func newRowBlock(c prosediff.BlockChange, info blockInfo) *rowBlock {
176 10098 src := c.New
177 10098 if c.Kind == prosediff.ChangeDelete || c.Kind == prosediff.ChangeMoveOut {
178 1579 src = c.Old
179 1579 }
180 10098 blk := &rowBlock{
181 10098 Anchor: info.Anchor,
182 10098 Key: info.Key,
183 10098 Commentable: info.Commentable,
184 10098 Notes: info.Notes,
185 10098 HasThreads: info.HasThreads,
186 10098 Context: c.Kind == prosediff.ChangeEqual,
187 10098 }
188 10098 if src != nil {
189 10098 blk.Heading = src.Kind == prosediff.KindHeading
190 10098 blk.Mono = !src.Kind.Prose()
191 10098 }
192 10098 return blk
193 }
194
195 // changeRows renders one block change into rows. The five kinds that carry a
196 // whole block map onto their lines directly and exactly; only a modification
197 // has to recover which line a word edit fell on, which is modifyRows' problem.
198 10098 func changeRows(c prosediff.BlockChange, blk *rowBlock) []diffRow {
199 10098 switch c.Kind {
200 5948 case prosediff.ChangeEqual:
201 5948 return equalRows(c, blk)
202 1434 case prosediff.ChangeInsert:
203 1434 return wholeBlockRows(c.New, rowInsert, false, blk)
204 1437 case prosediff.ChangeDelete:
205 1437 return wholeBlockRows(c.Old, rowDelete, true, blk)
206 142 case prosediff.ChangeMoveIn:
207 142 // The marker first, then the text. A move-in is commentable — the block
208 142 // is at its new position and this is where a reviewer objects to it — so
209 142 // it shows its lines; a comment control on text the reviewer cannot see
210 142 // is a control on nothing.
211 142 note := diffRow{
212 142 Kind: rowMove,
213 142 Block: blk,
214 142 Note: fmt.Sprintf("%s moved here (was line %d)", c.New.Label(), c.Old.StartLine),
215 142 }
216 142 return append([]diffRow{note}, wholeBlockRows(c.New, rowMove, false, blk)...)
217 142 case prosediff.ChangeMoveOut:
218 142 // One marker and no text: the block is rendered in full at its new
219 142 // position, and showing it twice would give one paragraph two places to
220 142 // be commented on.
221 142 return []diffRow{{
222 142 Kind: rowMove,
223 142 Block: blk,
224 142 OldNum: c.Old.StartLine,
225 142 Note: fmt.Sprintf("%s moved away (now line %d)", c.Old.Label(), c.New.StartLine),
226 142 }}
227 995 case prosediff.ChangeModify:
228 995 return modifyRows(c, blk)
229 }
230 0 return nil
231 }
232
233 // equalRows renders an unchanged block as context.
234 //
235 // A row states an old line number only when the old revision really does hold
236 // this text on that line. The rule used to be that the two sides' line *counts*
237 // agreeing was proof enough of a 1:1 correspondence, and it is not: the block is
238 // equal at the token level, which is what makes a rewrap invisible to the
239 // differ, so words can move across the line breaks while the count stays the
240 // same. "alpha beta / gamma delta" rewrapped to "alpha / beta gamma delta" is
241 // two lines before and after, and pairing them by position numbered a row 2
242 // whose text was never on old line 2 — a reviewer selecting it would have
243 // commented on text that does not exist in that revision, which is the exact
244 // failure prosediff.WordsByLine refuses to risk.
245 //
246 // So each row is checked on its own, and an unpaired row leaves the old cell
247 // empty exactly as the count-mismatch case already did. Blank beats fabricated.
248 5948 func equalRows(c prosediff.BlockChange, blk *rowBlock) []diffRow {
249 5948 nw := blockLines(c.New)
250 5948 old := blockLines(c.Old)
251 5948 prose := c.New.Kind.Prose()
252 5948
253 5948 rows := make([]diffRow, len(nw))
254 12153 for i, ln := range nw {
255 12153 rows[i] = diffRow{
256 12153 Kind: rowEqual,
257 12153 Block: blk,
258 12153 NewNum: c.New.StartLine + i,
259 12153 Spans: plainSpans(ln),
260 12153 }
261 12153 if i < len(old) && sameSourceLine(old[i], ln, prose) {
262 11477 rows[i].OldNum = c.Old.StartLine + i
263 11477 }
264 }
265 5948 return rows
266 }
267
268 // sameSourceLine reports whether two revisions' copies of a line hold the same
269 // text, by the same yardstick finishBlock uses to hash the block: prose
270 // compares normalized, because the tokenizer is what the differ ran on and the
271 // space between two words is not a difference anyone can see; everything else
272 // compares verbatim, because in a code fence it is.
273 12022 func sameSourceLine(old, nw string, prose bool) bool {
274 12022 if !prose {
275 2346 return old == nw
276 2346 }
277 9676 return prosediff.Normalize(old) == prosediff.Normalize(nw)
278 }
279
280 // wholeBlockRows renders every line of a block on one side of the diff: an
281 // insertion, a deletion, or the body of a move-in.
282 3013 func wholeBlockRows(src *prosediff.Block, kind rowKind, old bool, blk *rowBlock) []diffRow {
283 3013 lines := blockLines(src)
284 3013 rows := make([]diffRow, len(lines))
285 5914 for i, ln := range lines {
286 5914 rows[i] = diffRow{Kind: kind, Block: blk, Spans: plainSpans(ln)}
287 5914 if old {
288 2691 rows[i].OldNum = src.StartLine + i
289 3223 } else {
290 3223 rows[i].NewNum = src.StartLine + i
291 3223 }
292 }
293 3013 return rows
294 }
295
296 // modifyRows renders an edited block, choosing among the three presentations
297 // the design pins.
298 //
299 // A code fence, frontmatter or HTML block already has a line-oriented script
300 // and needs no recovery. A prose block that stayed similar enough to follow has
301 // its word script spread back over its source lines. A prose block rewritten
302 // past that point — or one whose lines and text disagree, so the spreading
303 // cannot be trusted — falls back to a pair of region rows.
304 995 func modifyRows(c prosediff.BlockChange, blk *rowBlock) []diffRow {
305 995 if len(c.Lines) > 0 {
306 194 return append(infoRows(c, blk), lineScriptRows(c, blk)...)
307 194 }
308 801 if c.Similarity >= inlineSimilarityThreshold {
309 652 if old, nw, ok := prosediff.WordsByLine(c); ok {
310 652 return mergeLineWords(old, nw, blk)
311 652 }
312 }
313 149 return regionRows(c, blk)
314 }
315
316 // infoRows is the one marker a modified code fence may need: the language on its
317 // opening delimiter changed.
318 //
319 // prosediff hashes a block's Info, so ```go becoming ```python pairs the two
320 // fences as a modification — but Block.Lines holds the fence's contents without
321 // its delimiters, so the line script is entirely equal and every row renders as
322 // context. The page would then say the document changed and show nothing that
323 // did. The fence delimiters are not rows of this table and inventing a number
324 // for one would be a guess, so the change is stated as a marker instead.
325 //
326 // Only a code fence is covered. Info also carries a list item's marker and a
327 // table's column count, and neither is a language: "- became *" is noise, and a
328 // table whose column count changed already differs in its cells.
329 194 func infoRows(c prosediff.BlockChange, blk *rowBlock) []diffRow {
330 194 if c.Old.Kind != prosediff.KindCode || c.Old.Info == c.New.Info {
331 192 return nil
332 192 }
333 2 return []diffRow{{
334 2 Kind: rowMove,
335 2 Block: blk,
336 2 Note: fmt.Sprintf("code block: %s → %s", infoLabel(c.Old.Info), infoLabel(c.New.Info)),
337 2 }}
338 }
339
340 // infoLabel spells the two Info values that are states rather than languages: a
341 // bare ``` fence, and an indented block, which the segmenter records as
342 // "indented" and which has no delimiter line at all.
343 4 func infoLabel(info string) string {
344 4 switch info {
345 1 case "":
346 1 return "no language"
347 0 case "indented":
348 0 return "indented, unfenced"
349 }
350 3 return info
351 }
352
353 // lineScriptRows renders a modified non-prose block. prosediff.DiffLines emits
354 // exactly one span per line, so the two counters walk the two revisions in step
355 // and every row's number is read off, not derived.
356 194 func lineScriptRows(c prosediff.BlockChange, blk *rowBlock) []diffRow {
357 194 oldNo, newNo := c.Old.StartLine, c.New.StartLine
358 194 rows := make([]diffRow, 0, len(c.Lines))
359 784 for _, s := range c.Lines {
360 784 row := diffRow{Block: blk, Spans: plainSpans(s.Text)}
361 784 switch s.Op {
362 80 case prosediff.OpDelete:
363 80 row.Kind, row.OldNum = rowDelete, oldNo
364 80 oldNo++
365 219 case prosediff.OpInsert:
366 219 row.Kind, row.NewNum = rowInsert, newNo
367 219 newNo++
368 485 default:
369 485 row.Kind, row.OldNum, row.NewNum = rowEqual, oldNo, newNo
370 485 oldNo++
371 485 newNo++
372 }
373 784 rows = append(rows, row)
374 }
375 194 return rows
376 }
377
378 // mergeLineWords interleaves the two sides of a spread word script into one
379 // unified column.
380 //
381 // The two sides are separate sequences of lines with no correspondence stored
382 // between them — prosediff.WordsByLine hands back the old block's lines and the
383 // new block's lines, each carrying its own share of the script — so the order
384 // they appear in is this function's choice. Two cursors walk them:
385 //
386 // 1. a line neither side marked, with the same text on both, is one context
387 // row carrying both numbers;
388 // 2. otherwise an old line carrying a deletion is emitted alone, old track only;
389 // 3. otherwise a new line carrying an insertion is emitted alone, new track only;
390 // 4. otherwise the two lines differ without either being marked, which is a
391 // rewrap: the words did not change but the lines did, so the pair is emitted
392 // as a removed line followed by an added one, adjacent.
393 //
394 // Case 4 is the judgement call. The alternative was to emit the pair as one
395 // context row and let the gutter show both numbers, which reads better but says
396 // two lines are the same line when their text differs; in a table whose whole
397 // contract is "the number in the gutter is the number in the file", that is the
398 // wrong lie to tell. The alternative to case 2 before 3 — pairing a marked old
399 // line with a marked new line on one row — was rejected because it re-invents a
400 // correspondence the differ deliberately did not compute.
401 //
402 // Whatever the interleaving does, a row's number always comes from its own
403 // side's LineWords, so an imperfect order costs readability and never
404 // correctness.
405 652 func mergeLineWords(old, nw []prosediff.LineWords, blk *rowBlock) []diffRow {
406 652 var rows []diffRow
407 652 delRow := func(l prosediff.LineWords) diffRow {
408 523 return diffRow{Kind: rowDelete, Block: blk, OldNum: l.Line, Spans: l.Spans}
409 523 }
410 800 insRow := func(l prosediff.LineWords) diffRow {
411 800 return diffRow{Kind: rowInsert, Block: blk, NewNum: l.Line, Spans: l.Spans}
412 800 }
413
414 652 i, j := 0, 0
415 2216 for i < len(old) && j < len(nw) {
416 2216 o, n := old[i], nw[j]
417 2216 switch {
418 case !marked(o.Spans, prosediff.OpDelete) && !marked(n.Spans, prosediff.OpInsert) &&
419 1351 lineText(o) == lineText(n):
420 1351 rows = append(rows, diffRow{
421 1351 Kind: rowEqual, Block: blk,
422 1351 OldNum: o.Line, NewNum: n.Line, Spans: n.Spans,
423 1351 })
424 1351 i++
425 1351 j++
426 316 case marked(o.Spans, prosediff.OpDelete):
427 316 rows = append(rows, delRow(o))
428 316 i++
429 454 case marked(n.Spans, prosediff.OpInsert):
430 454 rows = append(rows, insRow(n))
431 454 j++
432 95 default:
433 95 rows = append(rows, delRow(o), insRow(n))
434 95 i++
435 95 j++
436 }
437 }
438 652 for ; i < len(old); i++ {
439 112 rows = append(rows, delRow(old[i]))
440 112 }
441 652 for ; j < len(nw); j++ {
442 251 rows = append(rows, insRow(nw[j]))
443 251 }
444 652 return rows
445 }
446
447 // regionRows is the honest fallback: one row for the old side of the block and
448 // one for the new, each labelled by the line range it covers rather than by a
449 // line number.
450 //
451 // It fires for a block rewritten past the point where inline marks stay
452 // readable — the Phase 0 verdict's one review in eight — and for a block whose
453 // word script could not be spread back over its lines. In both cases a per-line
454 // number would be a guess, and the design's rule is that a range the reader can
455 // check beats a number they cannot.
456 149 func regionRows(c prosediff.BlockChange, blk *rowBlock) []diffRow {
457 149 return []diffRow{
458 149 {
459 149 Kind: rowDelete, Block: blk, Region: true,
460 149 OldNum: c.Old.StartLine, OldEnd: c.Old.EndLine,
461 149 Spans: sideSpans(c.Words, true),
462 149 },
463 149 {
464 149 Kind: rowInsert, Block: blk, Region: true,
465 149 NewNum: c.New.StartLine, NewEnd: c.New.EndLine,
466 149 Spans: sideSpans(c.Words, false),
467 149 },
468 149 }
469 149 }
470
471 // sideSpans keeps one side of a word script: the old side keeps equal and
472 // deleted words, the new side keeps equal and inserted ones. Both keep their
473 // marks, so each region row is a readable paragraph that also shows what moved.
474 //
475 // The separator of a dropped span moves onto the next kept one. Span.Space says
476 // a space preceded that span *in the combined rendering*, and an insertion that
477 // directly replaces a deletion carries Space=false because the deletion in front
478 // of it already carried the space — prosediff's own note on the matter. Split
479 // onto one side that deletion is gone, and without this the row would read "the
480 // committeerejected the budget".
481 298 func sideSpans(spans []prosediff.Span, old bool) []prosediff.Span {
482 298 out := make([]prosediff.Span, 0, len(spans))
483 298 space := false
484 876 for _, s := range spans {
485 876 switch {
486 case s.Op == prosediff.OpEqual,
487 old && s.Op == prosediff.OpDelete,
488 !old && s.Op == prosediff.OpInsert:
489 239 default:
490 239 space = space || s.Space
491 239 continue
492 }
493 637 s.Space = s.Space || space
494 637 space = false
495 637 out = append(out, s)
496 }
497 298 return out
498 }
499
500 // groupRows cuts the row stream into <tbody> groups, collapsing long runs of
501 // context.
502 //
503 // A run is delimited by blocks, not by rows: a block is foldable when it is
504 // unchanged and carries no threads, and then all of its rows fold, its notes
505 // row included. Keying on the row kind instead would let a changed block's
506 // compose form — which is not a ph-r-eq row but sits between two of them —
507 // either break every run or be swallowed into a fold it does not belong to.
508 34 func groupRows(rows []diffRow) []rowGroup {
509 34 var groups []rowGroup
510 34 var plain []diffRow
511 34 folds := 0
512 34
513 43 flush := func() {
514 43 if len(plain) > 0 {
515 43 groups = append(groups, rowGroup{Rows: plain})
516 43 plain = nil
517 43 }
518 }
519
520 103 for i := 0; i < len(rows); {
521 103 if !foldable(rows[i]) {
522 68 plain = append(plain, rows[i])
523 68 i++
524 68 continue
525 }
526 35 j := i
527 194 for j < len(rows) && foldable(rows[j]) {
528 194 j++
529 194 }
530 35 run := rows[i:j]
531 35 i = j
532 35
533 35 lo, hi, ok := foldWindow(run)
534 35 if !ok {
535 26 plain = append(plain, run...)
536 26 continue
537 }
538 9 plain = append(plain, run[:lo]...)
539 9 flush()
540 9
541 9 hidden := make([]diffRow, hi-lo)
542 9 lines := 0
543 73 for k, row := range run[lo:hi] {
544 73 row.Folded = true
545 73 hidden[k] = row
546 73 if row.Kind != rowNotes {
547 59 lines++
548 59 }
549 }
550 9 groups = append(groups, rowGroup{Fold: true, Index: folds, Hidden: lines, Rows: hidden})
551 9 folds++
552 9 plain = append(plain, run[hi:]...)
553 }
554 34 flush()
555 34 return groups
556 }
557
558 // foldable reports whether a row may be hidden inside a fold. A block someone
559 // has commented on never is: it stopped being context the moment somebody had
560 // something to say about it, and a comment behind a closed fold is a comment
561 // nobody reads.
562 324 func foldable(row diffRow) bool {
563 324 return row.Block.Context && !row.Block.HasThreads
564 324 }
565
566 // foldWindow picks the half-open range of a context run to hide: everything
567 // between the first foldKeepEdge lines and the last foldKeepEdge lines, which
568 // keeps a couple of lines of orientation on each side of the gap. The window is
569 // measured in rows so that a notes row falling inside the gap is hidden with
570 // it, and counted in lines so that both gates judge the same thing the label
571 // will report.
572 //
573 // The rule the window has to respect is that a block's notes row is hidden
574 // exactly when the whole block is. A compose form for lines nobody can see is a
575 // control on nothing — the same reason a move-in renders its text — and a
576 // compose form hidden away from lines that *are* visible is worse still,
577 // because the block looks uncommentable. Only the opening edge can break it: a
578 // window that starts inside a block would keep that block's first lines visible
579 // and swallow the trailer that follows its last one, so in that case the window
580 // opens after the trailer instead. The closing edge cannot break it, because hi
581 // is a line row by construction and a trailer always directly follows its
582 // block's last line.
583 35 func foldWindow(run []diffRow) (lo, hi int, ok bool) {
584 35 var lines []int
585 194 for i, row := range run {
586 194 if row.Kind != rowNotes {
587 157 lines = append(lines, i)
588 157 }
589 }
590 35 if len(lines) < foldMinRun || len(lines)-2*foldKeepEdge < foldMinHidden {
591 26 return 0, 0, false
592 26 }
593 9 lo, hi = lines[foldKeepEdge], lines[len(lines)-foldKeepEdge]
594 9
595 9 if !run[lo].Start {
596 8 for k := lo; k < hi && !run[k].Start; k++ {
597 8 if run[k].Kind == rowNotes {
598 2 lo = k + 1
599 2 break
600 }
601 }
602 }
603 // Giving that trailer back can leave too little to be worth a click, so the
604 // gate is asked again about what is actually left.
605 9 hidden := 0
606 101 for _, i := range lines {
607 101 if i >= lo && i < hi {
608 59 hidden++
609 59 }
610 }
611 9 if hidden < foldMinHidden {
612 0 return 0, 0, false
613 0 }
614 9 return lo, hi, true
615 }
616
617 // blockLines is a block's source lines, with the block's whole text as the one
618 // line of a block that records none. Nothing in the segmenter produces such a
619 // block today; this is what keeps that assumption from silently deleting a
620 // block from the page if one ever does.
621 14909 func blockLines(src *prosediff.Block) []string {
622 14909 if len(src.Lines) > 0 {
623 14909 return src.Lines
624 14909 }
625 0 return []string{src.Text}
626 }
627
628 // plainSpans is a line with no word-level marks on it, as a one-span script, so
629 // every row's content has the same shape whatever produced it.
630 18851 func plainSpans(text string) []prosediff.Span {
631 18851 return []prosediff.Span{{Op: prosediff.OpEqual, Text: text}}
632 18851 }
633
634 // marked reports whether a line's share of a word script carries an op.
635 5530 func marked(spans []prosediff.Span, op prosediff.Op) bool {
636 6048 for _, s := range spans {
637 6048 if s.Op == op {
638 1540 return true
639 1540 }
640 }
641 3990 return false
642 }
643
644 // lineText rebuilds a spread line's text for comparison.
645 //
646 // It compares reconstructed tokens rather than the source lines because that is
647 // what "the same line" has to mean here: the tokenizer is what the diff ran on,
648 // so two lines differing only in how much whitespace separates their words are
649 // the same line to every part of this package.
650 2892 func lineText(l prosediff.LineWords) string {
651 2892 var b strings.Builder
652 2892 for i, s := range l.Spans {
653 2892 if s.Space && i > 0 {
654 0 b.WriteByte(' ')
655 0 }
656 2892 b.WriteString(s.Text)
657 }
658 2892 return b.String()
659 }