scrive_core/highlight.rs
1//! Syntax highlighting — a GUI-free public surface over a private syntect
2//! engine. **Syntect is named only inside this module**; nothing from it
3//! crosses the `pub` line, so the GUI crate never has to pin syntect's version.
4//!
5//! The core is **language-agnostic**: the grammar is an app-supplied
6//! `.sublime-syntax`, injected as text (`SyntaxDef::from_sublime_syntax`), and
7//! the theme an app-supplied `.tmTheme` (`TokenTheme::from_tm_theme`);
8//! scrive-core ships neither.
9//!
10//! # Two entry points
11//!
12//! [`Highlighter::highlight`] tokenizes the whole document top-to-bottom on each
13//! call (state carried line to line) — correct but O(lines), used as the
14//! convergence oracle in tests. Production reads go through [`HighlightCache`],
15//! the incremental engine: geometric shift on edit ([`HighlightCache::on_commit`]),
16//! lazy end-state convergence ([`HighlightCache::tokenize_until`]), and full
17//! invalidation on theme change ([`HighlightCache::set_theme`]). Untokenized
18//! lines return `None` and render in the default style — never an error or a
19//! stall. Each drive tokenizes at most [`HIGHLIGHT_MAX_LINES_PER_CALL`] lines,
20//! so no single call can stall a frame however far a cascade wants to run.
21//!
22//! Retention is **virtualized** so RAM does not grow with the idle sweep:
23//! spans + per-line states live only in a window around the viewport
24//! ([`HighlightCache::set_window`]); everywhere else, sparse checkpoints
25//! ([`HIGHLIGHT_CHECKPOINT_STRIDE`]) keep every row re-derivable. A fully swept
26//! document holds `O(window + lines/stride)`, not `O(lines)` — see
27//! [`HighlightCache`] for the mechanics.
28
29use core::ops::Range;
30use std::io::Cursor;
31use std::sync::Arc;
32
33use crate::buffer::Snapshot;
34use crate::sum_tree::{Dimension, Item, Summary, SumTree};
35
36use syntect::highlighting::{
37 FontStyle, HighlightState, Highlighter as SyntectHighlighter, RangedHighlightIterator, Style,
38 Theme, ThemeSet,
39};
40use syntect::parsing::{ParseState, ScopeStack, SyntaxDefinition, SyntaxReference, SyntaxSet, SyntaxSetBuilder};
41
42/// A GUI-free color; scrive-iced maps it to `iced::Color` at render time.
43#[derive(Copy, Clone, PartialEq, Eq, Debug)]
44pub struct Rgba {
45 /// Red.
46 pub r: u8,
47 /// Green.
48 pub g: u8,
49 /// Blue.
50 pub b: u8,
51 /// Alpha.
52 pub a: u8,
53}
54
55/// Resolved inline style for one run — the theme is consulted at tokenize time
56/// (syntect's highlight iterator already yields resolved styles), so nothing
57/// downstream re-touches syntect.
58#[derive(Copy, Clone, PartialEq, Eq, Debug)]
59pub struct SpanStyle {
60 /// Foreground color.
61 pub fg: Rgba,
62 /// Bold.
63 pub bold: bool,
64 /// Italic.
65 pub italic: bool,
66}
67
68/// One styled run within a single buffer line. `range` is **bytes within the
69/// line** (not document offsets), so it survives line-local repair and drops
70/// straight onto a display chunk; byte→cell conversion happens only at render.
71#[derive(Clone, PartialEq, Eq, Debug)]
72pub struct HighlightSpan {
73 /// Byte range within the line.
74 pub range: Range<u32>,
75 /// The run's resolved style.
76 pub style: SpanStyle,
77}
78
79/// Failed to parse an injected `.sublime-syntax` grammar.
80#[derive(Debug, thiserror::Error)]
81#[error("invalid .sublime-syntax grammar: {0}")]
82pub struct SyntaxError(String);
83
84/// Failed to parse a `.tmTheme`.
85#[derive(Debug, thiserror::Error)]
86#[error("invalid .tmTheme: {0}")]
87pub struct ThemeError(String);
88
89/// A parsed grammar. The app supplies a validated `.sublime-syntax` definition;
90/// scrive-core ships no grammar of its own and stays language-agnostic.
91pub struct SyntaxDef {
92 set: SyntaxSet,
93 name: String,
94}
95
96impl SyntaxDef {
97 /// Parse an injected `.sublime-syntax` grammar (LF-only lines, no trailing
98 /// newline in the regexes).
99 pub fn from_sublime_syntax(s: &str) -> Result<Self, SyntaxError> {
100 let syntax =
101 SyntaxDefinition::load_from_str(s, false, None).map_err(|e| SyntaxError(e.to_string()))?;
102 let name = syntax.name.clone();
103 let mut builder = SyntaxSetBuilder::new();
104 builder.add(syntax);
105 Ok(Self { set: builder.build(), name })
106 }
107
108 /// The syntax reference to parse with — the one injected grammar.
109 fn reference(&self) -> &SyntaxReference {
110 self.set
111 .find_syntax_by_name(&self.name)
112 .or_else(|| self.set.syntaxes().first())
113 .expect("the injected grammar is in its own set")
114 }
115}
116
117/// Convert a syntect `(Style, byte range)` to a public [`HighlightSpan`] — the
118/// one place a resolved style crosses out of syntect.
119fn span_from(style: Style, range: Range<usize>) -> HighlightSpan {
120 HighlightSpan {
121 range: range.start as u32..range.end as u32,
122 style: SpanStyle {
123 fg: Rgba {
124 r: style.foreground.r,
125 g: style.foreground.g,
126 b: style.foreground.b,
127 a: style.foreground.a,
128 },
129 bold: style.font_style.contains(FontStyle::BOLD),
130 italic: style.font_style.contains(FontStyle::ITALIC),
131 },
132 }
133}
134
135/// A parsed highlight theme — opaque over syntect's `Theme`.
136pub struct TokenTheme(Theme);
137
138impl TokenTheme {
139 /// Parse a `.tmTheme` (plist).
140 pub fn from_tm_theme(s: &str) -> Result<Self, ThemeError> {
141 ThemeSet::load_from_reader(&mut Cursor::new(s))
142 .map(TokenTheme)
143 .map_err(|e| ThemeError(e.to_string()))
144 }
145}
146
147/// A whole-document highlighter over a grammar + theme. Owns both; syntect stays
148/// private behind it.
149pub struct Highlighter {
150 syntax: SyntaxDef,
151 theme: TokenTheme,
152}
153
154impl Highlighter {
155 /// A highlighter over an injected grammar and theme.
156 #[must_use]
157 pub fn new(syntax: SyntaxDef, theme: TokenTheme) -> Self {
158 Self { syntax, theme }
159 }
160
161 /// Tokenize `text` (LF-only) into per-line spans — one `Vec` per line
162 /// (including the trailing empty final line), each span a byte range within
163 /// its line. State is carried line to line, so multi-line constructs resolve.
164 #[must_use]
165 pub fn highlight(&self, text: &str) -> Vec<Vec<HighlightSpan>> {
166 let syntect = SyntectHighlighter::new(&self.theme.0);
167 let mut state = LineState {
168 parse: ParseState::new(self.syntax.reference()),
169 highlight: HighlightState::new(&syntect, ScopeStack::new()),
170 };
171 text.split('\n')
172 .map(|line| {
173 let (spans, next) = tokenize_line(&syntect, &self.syntax.set, &state, line);
174 state = next;
175 spans
176 })
177 .collect()
178 }
179}
180
181/// The per-line carry state — syntect's `(ParseState, HighlightState)`. Its `==`
182/// is the convergence probe: equal end states mean identical tokens downstream
183/// (both halves are `Clone + PartialEq`). Private: no syntect type crosses the
184/// module boundary.
185#[derive(Clone, PartialEq)]
186struct LineState {
187 parse: ParseState,
188 highlight: HighlightState,
189}
190
191/// Tokenize one line from `start`, returning its spans and its **end** state
192/// (the start state for the next line). A parse error falls back to no spans,
193/// carrying the start state forward unchanged so the tokenize loop cannot spin.
194fn tokenize_line(
195 syntect: &SyntectHighlighter<'_>,
196 set: &SyntaxSet,
197 start: &LineState,
198 line: &str,
199) -> (Vec<HighlightSpan>, LineState) {
200 let mut end = start.clone();
201 let ops = end.parse.parse_line(line, set).unwrap_or_default();
202 let spans = RangedHighlightIterator::new(&mut end.highlight, &ops, line, syntect)
203 .map(|(style, _text, range)| span_from(style, range))
204 .collect();
205 (spans, end)
206}
207
208/// Per-call tokenize budget, expressed as an op count — deterministic and
209/// testable, unlike wall-clock. At ~2–20 µs of syntect per line this is
210/// roughly 0.5–5 ms per call, so one drive can never stall a frame however
211/// far a state-changing cascade wants to run; the idle sweep resumes where
212/// the budget stopped.
213pub const HIGHLIGHT_MAX_LINES_PER_CALL: u32 = 256;
214
215/// Sparse-checkpoint stride, in rows: outside the retention window the cache
216/// keeps one end state per this many tokenized rows (plus each budget-stop
217/// resume point), so any row's start state is re-derivable by tokenizing at
218/// most a stride forward from the checkpoint above it. Memory per fully-swept
219/// document: `lines / stride` states instead of `lines`.
220pub const HIGHLIGHT_CHECKPOINT_STRIDE: u32 = 256;
221
222/// Retention slack, in rows, kept on EACH side of the window handed to
223/// [`HighlightCache::set_window`] — scrolling within the slack costs nothing;
224/// beyond it, evicted rows refill from the nearest checkpoint (budgeted).
225pub const HIGHLIGHT_WINDOW_SLACK: u32 = 512;
226
227/// Hard cap on the retention window's total length, in rows. A collapsed
228/// mega-fold makes the reported visible range span the fold's hidden interior;
229/// capping the window keeps retention bounded there instead of re-growing to
230/// O(document). Rows past the cap render in the fallback style until scrolled
231/// to (which re-aims the window at them); a fold hiding fewer than ~3k rows
232/// still fits entirely.
233pub const HIGHLIGHT_MAX_WINDOW_ROWS: u32 = 4096;
234
235/// The retention/paint window for a viewport: `viewport` padded by
236/// [`HIGHLIGHT_WINDOW_SLACK`] on each side, its length capped at
237/// [`HIGHLIGHT_MAX_WINDOW_ROWS`], and clamped to `[0, n_lines]`. The **one**
238/// owner of this formula: [`HighlightCache::set_window`] (what the cache
239/// *retains*) and any parallel-highlight worker pool (what it speculatively
240/// *paints* and *tokenizes*) must aim at the SAME rows, so both call this
241/// instead of re-deriving it — an integrator running the core's speculative
242/// tokenizer on worker threads cannot drift from the core's retention rule. The
243/// cap is load-bearing: a collapsed mega-fold makes the reported visible range
244/// span the fold's hidden interior, and without it retention (or a pool's
245/// synchronous speculate) would re-grow to O(document).
246#[must_use]
247pub fn padded_highlight_window(viewport: Range<u32>, n_lines: u32) -> Range<u32> {
248 let start = viewport.start.saturating_sub(HIGHLIGHT_WINDOW_SLACK);
249 let end = viewport
250 .end
251 .saturating_add(HIGHLIGHT_WINDOW_SLACK)
252 .min(start.saturating_add(HIGHLIGHT_MAX_WINDOW_ROWS))
253 .min(n_lines)
254 .max(start);
255 start..end
256}
257
258/// Disjoint, ascending dirty-line ranges: the set of lines whose highlight
259/// state has not yet converged. Storing runs (not individual rows) keeps a
260/// commit O(edit + #runs) even when a large file carries a long dirty tail.
261/// All operations are front-biased: `tokenize_until` always consumes the first
262/// dirty row, and edits splice near the front of whatever tail remains, so the
263/// run list stays single-digit in practice (a canary pins that).
264#[derive(Clone, Debug, Default, PartialEq, Eq)]
265struct DirtyRanges(Vec<Range<u32>>);
266
267impl DirtyRanges {
268 /// Every row of an `n`-line document dirty (load / theme change) — ONE
269 /// run covering the document (a `Vec` holding a single `Range` element,
270 /// which is exactly the point of the range-run representation).
271 fn all(n: u32) -> Self {
272 Self((n > 0).then_some(0..n).into_iter().collect())
273 }
274
275 /// The first dirty row, if any — the tokenize frontier.
276 fn first(&self) -> Option<u32> {
277 self.0.first().map(|r| r.start)
278 }
279
280 /// Remove `row`, which MUST be the current first dirty row (the only
281 /// consumption order `tokenize_until` uses). O(1) amortized.
282 fn remove_first(&mut self, row: u32) {
283 debug_assert_eq!(self.first(), Some(row), "consumption is front-only");
284 let r = &mut self.0[0];
285 r.start += 1;
286 if r.start >= r.end {
287 self.0.remove(0);
288 }
289 }
290
291 /// Mark one row dirty (the cascade step) — merging into a neighbouring
292 /// run so the list stays disjoint and non-adjacent. O(log + shift), and
293 /// the cascade inserts at/near the front in practice.
294 fn insert(&mut self, row: u32) {
295 let i = self.0.partition_point(|r| r.end < row);
296 if i < self.0.len() {
297 let r = &mut self.0[i];
298 if r.start <= row && row < r.end {
299 return; // already dirty
300 }
301 if r.end == row {
302 r.end += 1; // extend this run right…
303 if i + 1 < self.0.len() && self.0[i].end == self.0[i + 1].start {
304 let next_end = self.0[i + 1].end;
305 self.0[i].end = next_end; // …merging into the next run
306 self.0.remove(i + 1);
307 }
308 return;
309 }
310 if r.start == row + 1 {
311 r.start = row; // extend this run left
312 return;
313 }
314 }
315 self.0.insert(i, row..row + 1);
316 }
317
318 /// The commit splice: `spans` is the per-edit list of pre-edit line
319 /// spans `(pre_start, old_lines, new_lines)`, ascending and disjoint. Shifts
320 /// existing runs through the combined splices and marks ONLY each edit's own
321 /// post-edit line span dirty — so a scattered multi-caret transaction marks
322 /// O(carets) lines, not the whole first-to-last covering range. One pass and
323 /// one sort of the O(runs + edits) boundary set. A single covering span
324 /// reproduces the classic clip-shift-mark commit splice exactly.
325 fn apply_splices(&mut self, spans: &[(u32, u32, u32)]) {
326 if spans.is_empty() {
327 return;
328 }
329 // `pref[i]` = accumulated line delta of `spans[..i]`. `shift_at(pre)` is
330 // the post-edit displacement of a pre-edit row *not inside* any edit
331 // (rows inside an edit are covered by that edit's new span below), which
332 // is the delta of all edits entirely above it.
333 let mut pref: Vec<i64> = Vec::with_capacity(spans.len() + 1);
334 pref.push(0);
335 for &(_, o, n) in spans {
336 pref.push(pref[pref.len() - 1] + i64::from(n) - i64::from(o));
337 }
338 let shift_at = |pre: u32| -> i64 {
339 let i = spans.partition_point(|s| s.0 + s.1 <= pre);
340 pref[i]
341 };
342 let old_runs = std::mem::take(&mut self.0);
343 let mut out: Vec<Range<u32>> = Vec::with_capacity(old_runs.len() + spans.len());
344 // Existing runs, shifted. `[a + shift(a), b + shift(b))` is a superset
345 // of the shifted gap rows and any spanned edit region (shift is
346 // non-decreasing), which is safe: over-marking dirty only costs re-work,
347 // under-marking would leave a stale color.
348 for r in &old_runs {
349 let a = (i64::from(r.start) + shift_at(r.start)) as u32;
350 let b = (i64::from(r.end) + shift_at(r.end)) as u32;
351 if a < b {
352 out.push(a..b);
353 }
354 }
355 // Each edit's own post-edit new span.
356 for (j, &(ps, _o, n)) in spans.iter().enumerate() {
357 if n > 0 {
358 let post_start = (i64::from(ps) + pref[j]) as u32;
359 out.push(post_start..post_start + n);
360 }
361 }
362 out.sort_unstable_by_key(|r| r.start);
363 // Coalesce touching/overlapping runs back into the ascending-disjoint
364 // invariant `splice`/`clear_range` maintain.
365 let mut merged: Vec<Range<u32>> = Vec::with_capacity(out.len());
366 for r in out {
367 match merged.last_mut() {
368 Some(last) if last.end >= r.start => last.end = last.end.max(r.end),
369 _ => merged.push(r),
370 }
371 }
372 self.0 = merged;
373 }
374
375 /// Total dirty rows (sum of run lengths) — the invalidation size a commit
376 /// scheduled, which the sweep must eventually walk.
377 #[cfg(test)]
378 fn total_rows(&self) -> u32 {
379 self.0.iter().map(|r| r.end - r.start).sum()
380 }
381
382 /// Whether `row` is dirty. Binary search over the ascending disjoint runs.
383 fn contains(&self, row: u32) -> bool {
384 let i = self.0.partition_point(|r| r.end <= row);
385 i < self.0.len() && self.0[i].start <= row
386 }
387
388 /// Clear rows `[range)` from the dirty set — the verified-absorb path: those
389 /// rows' states are proven, so they leave the frontier without being walked.
390 /// Clips each run around the range; the output stays ascending, disjoint,
391 /// and non-adjacent (inputs are, and clipping only shrinks), so front-biased
392 /// consumption is preserved.
393 fn clear_range(&mut self, range: Range<u32>) {
394 if range.start >= range.end {
395 return;
396 }
397 let old = std::mem::take(&mut self.0);
398 let mut out: Vec<Range<u32>> = Vec::with_capacity(old.len() + 1);
399 for r in old {
400 let lo = r.start..r.end.min(range.start); // part below the cleared span
401 let hi = r.start.max(range.end)..r.end; // part at/above it
402 if lo.start < lo.end {
403 out.push(lo);
404 }
405 if hi.start < hi.end {
406 out.push(hi);
407 }
408 }
409 self.0 = out;
410 }
411
412 /// Number of disjoint runs — the canary probe.
413 #[cfg(test)]
414 fn runs(&self) -> usize {
415 self.0.len()
416 }
417}
418
419/// The document-owned **incremental, virtualized** highlight cache.
420///
421/// The *correctness* model is end-state convergence over `DirtyRanges`:
422/// a line is re-tokenized only while its computed end state differs from the
423/// stored one, so an edit repaints just the lines whose state actually
424/// changed. It is driven lazily and budgeted by
425/// [`HighlightCache::tokenize_until`]. **Retention** is what keeps memory
426/// bounded — two facts make it work:
427///
428/// - **Spans are draw output** — nothing but visible rows reads them, so they
429/// are retained only inside a *dense window* (the viewport ±
430/// [`HIGHLIGHT_WINDOW_SLACK`], set via [`HighlightCache::set_window`]),
431/// together with per-line end states there (so a keystroke at the caret
432/// still converges per-line).
433/// - **States exist to be resumed from** — and any row's state is
434/// re-derivable by tokenizing forward, so outside the window only sparse
435/// *checkpoints* survive: one per [`HIGHLIGHT_CHECKPOINT_STRIDE`] tokenized
436/// rows plus each budget-stop resume point. A cold jump refills the window
437/// from the nearest checkpoint (≤ a stride of warm-up, budgeted; the rows
438/// render in the fallback style until filled).
439///
440/// Convergence verification is per-line inside the window and
441/// checkpoint-grain outside (an out-of-window cascade may run up to one
442/// stride past where a fully-dense cache would have stopped — bounded, and
443/// such edits are rare: you edit what you see). Memory for a fully swept
444/// document: `O(window + lines / stride)` instead of `O(lines)`.
445pub struct HighlightCache {
446 /// `Arc`-shared so [`HighlightCache::engine`] hands an off-thread worker
447 /// the SAME immutable grammar/theme without a deep `SyntaxSet` clone.
448 syntax: Arc<SyntaxDef>,
449 theme: Arc<TokenTheme>,
450 /// Everything the walks mutate — split from the grammar/theme so the
451 /// borrow of `theme` inside a live `SyntectHighlighter` stays disjoint
452 /// from the retention mutations.
453 ret: Retention,
454}
455
456/// The virtualized retention state (see [`HighlightCache`]).
457struct Retention {
458 /// Buffer line count (the commit path keeps it in step).
459 n_lines: u32,
460 /// Dirty-line runs — the correctness frontier: lines not yet converged.
461 invalid: DirtyRanges,
462 /// The last window AIM (the rows handed to `set_window`, pre-padding) —
463 /// preserved across grammar swaps so `set_syntax` keeps retention aimed at
464 /// the viewport instead of silently resetting to the document top.
465 aim: Range<u32>,
466 /// The dense retention window: rows `[win.start, win.end)`.
467 win: Range<u32>,
468 /// Spans for window rows (`None` ⇒ fallback / not yet filled).
469 win_spans: Vec<Option<Vec<HighlightSpan>>>,
470 /// End states for window rows — the per-line convergence probes.
471 win_states: Vec<Option<Box<LineState>>>,
472 /// Sparse end-state checkpoints, ascending by row — a delta-gap
473 /// [`SumTree`]: reads are an O(log) seek and the per-keystroke
474 /// [`Checkpoints::shift`] mover rewrites one seam gap rather than the whole
475 /// list. Holds stride rows plus budget-stop resume points; splices shift
476 /// them like every other line-indexed structure.
477 checkpoints: Checkpoints,
478}
479
480// Manual (syntect's `Theme`/state aren't all `Debug`, and they'd be noise
481// anyway); lets `Document` keep its `#[derive(Debug)]`.
482impl core::fmt::Debug for HighlightCache {
483 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
484 f.debug_struct("HighlightCache")
485 .field("lines", &self.ret.n_lines)
486 .field("window", &self.ret.win)
487 .field("checkpoints", &self.ret.checkpoints.len())
488 .field("dirty", &self.ret.invalid)
489 .finish_non_exhaustive()
490 }
491}
492
493// ======================================================================
494// Sparse end-state checkpoints — a delta-gap `SumTree`.
495//
496// One of the delta-gap `SumTree` structures (shared with decorations, folds,
497// and brackets): every read is an O(log) seek, the per-keystroke mover
498// (`shift`) shifts a suffix by rewriting ONE seam gap, and only a scattered
499// MULTI-edit commit falls back to the correctness-first O(#checkpoints) walk.
500// `row_gap` is this checkpoint's row minus the previous one's (a prefix sum
501// over `RowDim` recovers the absolute row); `state` is the end state after
502// that row — opaque and heavy (a syntect `LineState`), the copy-on-write cost
503// the `benches/perf.rs` checkpoint case pins.
504// ======================================================================
505
506/// One sparse checkpoint leaf. `row_gap` = this checkpoint's row minus the
507/// previous checkpoint's row (delta-gap, so a suffix shift rewrites just one
508/// gap); `state` = the end state after that row.
509struct CkptItem {
510 row_gap: u32,
511 state: Box<LineState>,
512}
513
514impl Clone for CkptItem {
515 fn clone(&self) -> Self {
516 Self { row_gap: self.row_gap, state: self.state.clone() }
517 }
518}
519
520// `LineState` is deliberately not `Debug` (syntect's parse/highlight state is
521// noise) — the same reason `HighlightCache`'s `Debug` is manual. `Item` demands
522// `Debug`, so show the shape without the state.
523impl core::fmt::Debug for CkptItem {
524 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
525 f.debug_struct("CkptItem").field("row_gap", &self.row_gap).finish_non_exhaustive()
526 }
527}
528
529/// Subtree aggregate: `rows` = Σ row_gaps (the [`RowDim`] absolute-row extent),
530/// `count` = item count (the [`CkptIx`] index extent). Both additive monoids, so
531/// a suffix shift is invariant under a uniform row displacement.
532#[derive(Clone, Copy, Debug, Default)]
533struct CkptSummary {
534 rows: u32,
535 count: u32,
536}
537
538impl Summary for CkptSummary {
539 fn add_summary(&mut self, o: &Self) {
540 self.rows += o.rows;
541 self.count += o.count;
542 }
543}
544
545impl Item for CkptItem {
546 type Summary = CkptSummary;
547 fn summary(&self) -> CkptSummary {
548 CkptSummary { rows: self.row_gap, count: 1 }
549 }
550}
551
552/// Dimension: absolute row — accumulates each item's `row_gap` (a prefix sum),
553/// so an item's `RowDim`-end is its absolute checkpoint row.
554#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
555struct RowDim(u32);
556impl Dimension<CkptSummary> for RowDim {
557 fn add_summary(&mut self, s: &CkptSummary) {
558 self.0 += s.rows;
559 }
560}
561
562/// Dimension: item index — accumulates `count`. Writes split/replace by index
563/// (a delta-gap item is located at its row, not a byte offset), mirroring the
564/// decoration and bracket trees.
565#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
566struct CkptIx(u32);
567impl Dimension<CkptSummary> for CkptIx {
568 fn add_summary(&mut self, s: &CkptSummary) {
569 self.0 += s.count;
570 }
571}
572
573/// Re-anchor `suffix`'s first item onto a rebuilt head: its absolute row becomes
574/// `old_row + delta` and its predecessor is now at `prev_row`, so its single
575/// `row_gap` is rewritten while every later gap stays relative and untouched — a
576/// whole-suffix shift in O(log). `k_hi` is that item's index in `orig` (read
577/// only for its old absolute row). A port of the decoration tree's `reanchor`
578/// (oracle-proven there); the heavy `state` is cloned exactly once.
579fn reanchor(
580 suffix: &SumTree<CkptItem>,
581 k_hi: u32,
582 orig: &SumTree<CkptItem>,
583 delta: i64,
584 prev_row: u32,
585) -> SumTree<CkptItem> {
586 if suffix.is_empty() {
587 return suffix.clone();
588 }
589 let (item, _c, RowDim(r)) = orig
590 .seek::<CkptIx, RowDim>(&CkptIx(k_hi))
591 .expect("suffix non-empty ⇒ a k_hi-th item exists");
592 let first_old_row = r + item.row_gap;
593 let new_gap = (i64::from(first_old_row) + delta - i64::from(prev_row)) as u32;
594 let fixed = CkptItem { row_gap: new_gap, state: item.state.clone() };
595 suffix.replace(CkptIx(0)..CkptIx(1), std::iter::once(fixed))
596}
597
598/// Sparse end-state checkpoints as a delta-gap [`SumTree`]. See the module
599/// section header above for the structure and its cost model.
600struct Checkpoints {
601 tree: SumTree<CkptItem>,
602}
603
604impl Checkpoints {
605 fn new() -> Self {
606 Self { tree: SumTree::new() }
607 }
608
609 /// Number of checkpoints — an O(1) root-summary read.
610 fn len(&self) -> usize {
611 self.tree.summary().count as usize
612 }
613
614 #[cfg(test)]
615 fn is_empty(&self) -> bool {
616 self.tree.is_empty()
617 }
618
619 fn clear(&mut self) {
620 self.tree = SumTree::new();
621 }
622
623 /// The state stored EXACTLY at `row`, or `None`. O(log). The `stored_state_after`
624 /// checkpoint probe.
625 fn state_at(&self, row: u32) -> Option<&LineState> {
626 let n_le = self.tree.summary_before(&RowDim(row.saturating_add(1))).count; // abs_row <= row
627 let n_lt = self.tree.summary_before(&RowDim(row)).count; // abs_row < row
628 if n_le == n_lt {
629 return None; // nothing sits at exactly `row`
630 }
631 let (item, _ix, RowDim(before)) = self.tree.seek::<CkptIx, RowDim>(&CkptIx(n_lt))?;
632 debug_assert_eq!(before + item.row_gap, row, "state_at must land exactly on `row`");
633 Some(&*item.state)
634 }
635
636 /// The nearest checkpoint at or before `row`: `(abs_row, &state)`, or `None`.
637 /// O(log). The warm-up floor.
638 fn floor(&self, row: u32) -> Option<(u32, &LineState)> {
639 let n_le = self.tree.summary_before(&RowDim(row.saturating_add(1))).count; // abs_row <= row
640 let idx = n_le.checked_sub(1)?; // none at or before `row`
641 let (item, _ix, RowDim(before)) = self.tree.seek::<CkptIx, RowDim>(&CkptIx(idx))?;
642 Some((before + item.row_gap, &*item.state))
643 }
644
645 /// Replace the state of the checkpoint at index `i`, keeping its `row_gap`
646 /// (its position). O(log).
647 fn replace_state_at_index(&mut self, i: u32, state: LineState) {
648 let gap = {
649 let (item, _ix, _rd) =
650 self.tree.seek::<CkptIx, RowDim>(&CkptIx(i)).expect("index in range");
651 item.row_gap
652 };
653 self.tree = self
654 .tree
655 .replace(CkptIx(i)..CkptIx(i + 1), std::iter::once(CkptItem { row_gap: gap, state: Box::new(state) }));
656 }
657
658 /// Insert a new checkpoint at absolute `row` (which must NOT already exist)
659 /// at index `i`: mint its delta-gap onto its predecessor and re-relativize the
660 /// successor's now-orphaned gap at one seam. Insertion moves no rows, so the
661 /// suffix re-anchors with `delta = 0`. O(log).
662 fn insert_at(&mut self, i: u32, row: u32, state: LineState) {
663 let (left, right) = self.tree.split_at(&CkptIx(i));
664 let pred = left.extent::<RowDim>().0; // abs_row of item i-1 (0 if none)
665 let mid = SumTree::from_items(std::iter::once(CkptItem {
666 row_gap: row - pred,
667 state: Box::new(state),
668 }));
669 // The successor keeps its absolute row; only its predecessor changed to
670 // `row`, so it re-anchors with delta 0.
671 let right_fixed = reanchor(&right, i, &self.tree, 0, row);
672 self.tree = left.append(&mid).append(&right_fixed);
673 }
674
675 /// Insert a checkpoint at `row`, or refresh the state if one already sits
676 /// there. O(log).
677 fn upsert(&mut self, row: u32, state: LineState) {
678 let n_le = self.tree.summary_before(&RowDim(row.saturating_add(1))).count; // abs_row <= row
679 let n_lt = self.tree.summary_before(&RowDim(row)).count; // abs_row < row
680 if n_le > n_lt {
681 self.replace_state_at_index(n_lt, state); // exact match at index n_lt
682 } else {
683 self.insert_at(n_lt, row, state);
684 }
685 }
686
687 /// Refresh the state at `row` ONLY if a checkpoint already sits there (a
688 /// drifted off-grid pin a fresh tokenize just re-derived); a no-op otherwise.
689 /// O(log).
690 fn refresh_if_present(&mut self, row: u32, state: LineState) {
691 let n_le = self.tree.summary_before(&RowDim(row.saturating_add(1))).count;
692 let n_lt = self.tree.summary_before(&RowDim(row)).count;
693 if n_le > n_lt {
694 self.replace_state_at_index(n_lt, state);
695 }
696 }
697
698 /// Insert/refresh a stride-aligned checkpoint at `row`, then drop the
699 /// off-grid pins within one stride behind it (drifted pins and old resume
700 /// points, redundant once this one lands). THE one owner of the
701 /// sparse-checkpoint rule. O(log + pruned).
702 fn set_stride(&mut self, row: u32, state: LineState) {
703 self.upsert(row, state);
704 self.prune_behind(row);
705 }
706
707 /// Drop the off-grid checkpoints strictly between `row - stride` and `row`
708 /// (exclusive of both an exact-`floor` pin and `row` itself) — redundant once
709 /// a stride checkpoint lands at `row`. `row`'s checkpoint keeps its absolute
710 /// position; only its `row_gap` re-relativizes onto the surviving predecessor.
711 /// O(log + pruned).
712 fn prune_behind(&mut self, row: u32) {
713 let floor = row.saturating_sub(HIGHLIGHT_CHECKPOINT_STRIDE);
714 let k_lo = self.tree.summary_before(&RowDim(floor.saturating_add(1))).count; // abs_row <= floor
715 let k_hi = self.tree.summary_before(&RowDim(row)).count; // abs_row < row
716 if k_hi <= k_lo {
717 return; // nothing off-grid behind `row`
718 }
719 let (left, rest) = self.tree.split_at(&CkptIx(k_lo));
720 let (_dropped, right) = rest.split_at(&CkptIx(k_hi - k_lo));
721 // `right` starts with `row`'s checkpoint (abs_row == row); it stays put
722 // (delta 0), re-gapped onto the last surviving item at/below `floor`.
723 let right_fixed = reanchor(&right, k_hi, &self.tree, 0, left.extent::<RowDim>().0);
724 self.tree = left.append(&right_fixed);
725 }
726
727 /// Shift checkpoints through a commit's per-edit line splices `(pre_start,
728 /// old_lines, new_lines)` (ascending, disjoint) — the per-keystroke mover,
729 /// run on EVERY commit and every undo step (`on_commit_patch`). A single
730 /// edit takes the O(edit + log) windowed path; a scattered multi-edit
731 /// commit falls back to the correctness-first O(#checkpoints) walk.
732 fn shift(&mut self, spans: &[(u32, u32, u32)]) {
733 if spans.is_empty() {
734 return;
735 }
736 if spans.len() == 1 {
737 self.shift_single(spans[0]);
738 } else {
739 self.shift_walk(spans);
740 }
741 }
742
743 /// The single-edit fast path: drop the (usually 0) checkpoints inside the
744 /// replaced rows `[s, s+o)` and shift the suffix `[s+o, ∞)` by the line delta
745 /// at ONE seam. O(edit + log). Charges only the checkpoints actually TOUCHED
746 /// (dropped band + the one re-anchor seam), so a keystroke far from any
747 /// checkpoint is flat in the checkpoint count.
748 fn shift_single(&mut self, (s, o, n): (u32, u32, u32)) {
749 let d = i64::from(n) - i64::from(o);
750 let k_lo = self.tree.summary_before(&RowDim(s)).count; // abs_row < s
751 let k_hi = self.tree.summary_before(&RowDim(s + o)).count; // abs_row < s+o
752 let dropped = k_hi - k_lo;
753 crate::perf::charge(u64::from(dropped) + 1);
754 if dropped == 0 && d == 0 {
755 return; // nothing dropped, nothing shifts — tree untouched
756 }
757 let (left, rest) = self.tree.split_at(&CkptIx(k_lo));
758 let (_dropped, suffix) = rest.split_at(&CkptIx(k_hi - k_lo));
759 let pred = left.extent::<RowDim>().0;
760 let suffix_fixed = reanchor(&suffix, k_hi, &self.tree, d, pred);
761 self.tree = left.append(&suffix_fixed);
762 }
763
764 /// The multi-edit fallback: decode to absolute `(row, state)`, run the
765 /// merge (drop checkpoints inside any edit, shift the rest by the cumulative
766 /// delta of all edits entirely above them), rebuild. O(#checkpoints +
767 /// edits). Charges per checkpoint walked — the cost the perf gate meters, so
768 /// the single-edit seam path stays the norm.
769 fn shift_walk(&mut self, spans: &[(u32, u32, u32)]) {
770 let old = self.tree.items();
771 let mut out: Vec<CkptItem> = Vec::with_capacity(old.len());
772 let mut si = 0usize;
773 let mut acc = 0i64;
774 let mut abs = 0u32; // running absolute row of the current checkpoint
775 let mut prev_out = 0u32; // last emitted absolute row (delta-gap re-encode)
776 for item in old {
777 crate::perf::charge(1); // walks every checkpoint — the O(#checkpoints) cost
778 abs += item.row_gap;
779 while si < spans.len() && spans[si].0 + spans[si].1 <= abs {
780 acc += i64::from(spans[si].2) - i64::from(spans[si].1);
781 si += 1;
782 }
783 if si < spans.len() && abs >= spans[si].0 {
784 continue; // inside a replaced span → the stored state is stale
785 }
786 let new_abs = (i64::from(abs) + acc) as u32;
787 out.push(CkptItem { row_gap: new_abs - prev_out, state: item.state });
788 prev_out = new_abs;
789 }
790 self.tree = SumTree::from_items(out);
791 }
792
793 /// Build a checkpoint tree from ascending absolute `rows` sharing one `state`
794 /// — the perf-cell / oracle seed (no real sweep required).
795 #[cfg(test)]
796 fn from_rows(rows: &[u32], state: &LineState) -> Self {
797 let mut prev = 0u32;
798 let items: Vec<CkptItem> = rows
799 .iter()
800 .map(|&r| {
801 let gap = r - prev;
802 prev = r;
803 CkptItem { row_gap: gap, state: Box::new(state.clone()) }
804 })
805 .collect();
806 Self { tree: SumTree::from_items(items) }
807 }
808
809 /// The absolute checkpoint rows, ascending — the oracle handle.
810 #[cfg(test)]
811 fn rows(&self) -> Vec<u32> {
812 let mut abs = 0u32;
813 self.tree
814 .items()
815 .iter()
816 .map(|it| {
817 abs += it.row_gap;
818 abs
819 })
820 .collect()
821 }
822}
823
824/// How [`Retention::start_state_for`] answers: the start state is stored, is
825/// the fresh document-top state, or must be warmed up by tokenizing forward
826/// from the nearest earlier stored state (`WarmupFresh` ⇒ from row 0).
827enum StartState {
828 Ready(LineState),
829 Fresh,
830 WarmupStored(u32, LineState),
831 WarmupFresh,
832}
833
834impl HighlightCache {
835 /// A cache for an `n_lines` document over an injected grammar + theme:
836 /// every line invalid, nothing retained. The retention window defaults to
837 /// the document top (2 × [`HIGHLIGHT_WINDOW_SLACK`] rows) — right for a
838 /// freshly opened view; the app re-aims it per viewport report via
839 /// [`HighlightCache::set_window`].
840 #[must_use]
841 pub fn new(syntax: SyntaxDef, theme: TokenTheme, n_lines: u32) -> Self {
842 let win = 0..(2 * HIGHLIGHT_WINDOW_SLACK).min(n_lines);
843 let len = win.len();
844 Self {
845 syntax: Arc::new(syntax),
846 theme: Arc::new(theme),
847 ret: Retention {
848 n_lines,
849 invalid: DirtyRanges::all(n_lines),
850 aim: win.clone(),
851 win,
852 win_spans: vec![None; len],
853 win_states: vec![None; len],
854 checkpoints: Checkpoints::new(),
855 },
856 }
857 }
858
859 /// The first dirty row, if any — `None` means every line's state has
860 /// converged (spans may still be evicted outside the window; see
861 /// [`HighlightCache::pending`], which the sweep polls instead).
862 #[must_use]
863 pub fn first_dirty(&self) -> Option<u32> {
864 self.ret.invalid.first()
865 }
866
867 /// The next row a budgeted [`HighlightCache::tokenize_until`] call would
868 /// work on: the first dirty row, or — once states have converged — the
869 /// first window row whose spans await a refill (a window move over
870 /// already-swept rows). `None` = nothing to do (the idle-zero-work probe;
871 /// the app's sweep keys its subscription off this).
872 #[must_use]
873 pub fn pending(&self) -> Option<u32> {
874 let dirt = self.ret.invalid.first();
875 let gap = self.ret.first_window_gap(dirt.unwrap_or(u32::MAX));
876 match (dirt, gap) {
877 (Some(d), Some(g)) => Some(d.min(g)),
878 (d, g) => d.or(g),
879 }
880 }
881
882 /// The cached spans for a line, or `None` if the row is outside the
883 /// retention window or not yet tokenized/refilled (the caller renders
884 /// that line in the default style — never an error, never a stall).
885 #[must_use]
886 pub fn line_spans(&self, row: u32) -> Option<&[HighlightSpan]> {
887 self.ret.win_spans[self.ret.win_idx(row)?].as_deref()
888 }
889
890 /// How many lines the cache is sized for — must track the buffer's line
891 /// count (the commit path keeps them equal).
892 #[must_use]
893 pub fn line_count(&self) -> u32 {
894 self.ret.n_lines
895 }
896
897 /// The last rows handed to [`HighlightCache::set_window`] (pre-padding) —
898 /// `Document::set_syntax` re-aims a replacement cache with this so a
899 /// grammar swap keeps retention at the viewport instead of silently
900 /// resetting to the document top.
901 #[must_use]
902 pub fn window_aim(&self) -> Range<u32> {
903 self.ret.aim.clone()
904 }
905
906 /// Aim the retention window at `rows` (the viewport, in buffer rows) —
907 /// the cache retains `rows` ± [`HIGHLIGHT_WINDOW_SLACK`] and evicts
908 /// outside. Repositioning is O(window); rows entering the window refill
909 /// on the next [`HighlightCache::tokenize_until`] (see
910 /// [`HighlightCache::pending`]).
911 pub fn set_window(&mut self, rows: Range<u32>) {
912 let r = &mut self.ret;
913 r.aim = rows.clone();
914 // The padded, length-capped window through the ONE owner
915 // (`padded_highlight_window`), shared with any parallel-highlight pool so
916 // the rows the cache retains and the rows a pool paints cannot drift.
917 let win = padded_highlight_window(rows, r.n_lines);
918 let (start, end) = (win.start, win.end);
919 if (start..end) == r.win {
920 return;
921 }
922 let len = (end - start) as usize;
923 let mut spans = vec![None; len];
924 let mut states = vec![None; len];
925 for row in start.max(r.win.start)..end.min(r.win.end) {
926 let old_i = (row - r.win.start) as usize;
927 let new_i = (row - start) as usize;
928 spans[new_i] = r.win_spans[old_i].take();
929 states[new_i] = r.win_states[old_i].take();
930 }
931 r.win = start..end;
932 r.win_spans = spans;
933 r.win_states = states;
934 }
935
936 /// The commit hook: buffer lines `[start, start+old)` became `new` lines.
937 /// Splices the window (keeping the **old end state of the last edited
938 /// line** as the convergence probe in the new last slot), drops replaced
939 /// checkpoints and shifts the rest, and marks `[start, start+new)` dirty.
940 /// O(edit + window + #checkpoints); the cascade waits for `tokenize_until`.
941 pub fn on_commit(&mut self, start: u32, old: u32, new: u32) {
942 // A single edit as one line splice. Multi-edit commits use the per-edit
943 // spans directly (see `on_commit_patch`).
944 self.on_commit_patch(&[(start, old, new)]);
945 }
946
947 /// The commit hook, given the **per-edit** pre-edit line spans `(pre_start,
948 /// old_lines, new_lines)` (ascending, disjoint). Checkpoints, the dense
949 /// window, and the dirty runs all ride the per-edit spans — NOT the whole
950 /// transaction's first-to-last covering range. A scattered multi-caret
951 /// transaction has a document-scale covering range but O(carets) tiny spans,
952 /// so this keeps the commit (and the sweep it schedules) O(carets + window +
953 /// checkpoints), and — critically — leaves the retention window aimed at the
954 /// viewport: only the actually-edited in-window rows are invalidated, so the
955 /// visible rows recolor on the next tokenize instead of going stale until a
956 /// scroll re-aims the window.
957 pub fn on_commit_patch(&mut self, spans: &[(u32, u32, u32)]) {
958 if spans.is_empty() {
959 return;
960 }
961 let r = &mut self.ret;
962 let delta: i64 = spans.iter().map(|&(_, o, n)| i64::from(n) - i64::from(o)).sum();
963 // Checkpoints: drop only those inside an actual edit and shift the rest
964 // by the per-edit deltas — scattered carets must not wipe every
965 // checkpoint between the first and last (a cold-jump refill needs them).
966 r.checkpoints.shift(spans);
967 // Window: shift through the per-edit spans, staying aimed at the viewport.
968 r.shift_window(spans);
969 // Dirty runs: mark ONLY each edit's own post-edit line span.
970 r.invalid.apply_splices(spans);
971 r.n_lines = (r.n_lines as i64 + delta) as u32;
972 }
973
974 /// Tokenize toward `target` (inclusive), lazily and budgeted, and return
975 /// **how many lines were tokenized** (warm-up included) — the op count
976 /// the perf canaries assert on. Two phases share the `max_lines` budget:
977 ///
978 /// 1. **The dirty walk** — dense convergence semantics: each first-dirty
979 /// line tokenizes from its start state (stored, or warmed up from the
980 /// nearest checkpoint); if its new end state differs from the stored
981 /// one the cascade continues, otherwise it stops (convergence).
982 /// Where no state is stored to compare against (outside the window,
983 /// between checkpoints) the cascade keeps going until one is — at
984 /// most a stride more than a fully-dense cache would have done.
985 /// 2. **The window refill** — rows inside the retention window whose
986 /// spans were evicted (a window move over already-swept text) are
987 /// re-derived from the nearest checkpoint. Only rows below the dirty
988 /// frontier are fillable (states past dirt are unverified).
989 ///
990 /// `line(i)` hands back line i's text — an ACCESSOR, never an all-lines
991 /// Vec, so this never materializes the whole document. A budget stop
992 /// records a resume checkpoint so the next call continues without re-warming.
993 pub fn tokenize_until<S: AsRef<str>>(
994 &mut self,
995 target: u32,
996 max_lines: u32,
997 mut line: impl FnMut(u32) -> S,
998 ) -> u32 {
999 let syntect = SyntectHighlighter::new(&self.theme.0);
1000 let syntax = &*self.syntax;
1001 let fresh = || LineState {
1002 parse: ParseState::new(syntax.reference()),
1003 highlight: HighlightState::new(&syntect, ScopeStack::new()),
1004 };
1005 let ret = &mut self.ret;
1006 let mut work = 0u32;
1007
1008 // Phase 1 — the dirty walk.
1009 'walk: while work < max_lines {
1010 let Some(first) = ret.invalid.first() else { break };
1011 if first > target {
1012 break;
1013 }
1014 let started = match ret.start_state_for(first) {
1015 StartState::Ready(s) => Some(s),
1016 StartState::Fresh => Some(fresh()),
1017 StartState::WarmupStored(r, s) => ret
1018 .warm_up(Some(r), first, s, &mut work, max_lines, &syntect, &syntax.set, &mut line),
1019 StartState::WarmupFresh => ret
1020 .warm_up(None, first, fresh(), &mut work, max_lines, &syntect, &syntax.set, &mut line),
1021 };
1022 let Some(mut state) = started else { break 'walk }; // budget died mid-warm-up
1023 let mut row = first;
1024 loop {
1025 if work >= max_lines {
1026 ret.keep_resume_checkpoint(row.checked_sub(1), &state);
1027 break 'walk;
1028 }
1029 let (spans, end) = tokenize_line(&syntect, &syntax.set, &state, line(row).as_ref());
1030 // Probe BEFORE retain overwrites; nothing stored ⇒ unverifiable
1031 // ⇒ keep cascading.
1032 let converged = ret.stored_state_after(row).is_some_and(|old| *old == end);
1033 ret.retain(row, spans, &end);
1034 ret.invalid.remove_first(row);
1035 work += 1;
1036 state = end;
1037 if converged || row + 1 >= ret.n_lines {
1038 break;
1039 }
1040 ret.invalid.insert(row + 1);
1041 if row + 1 > target {
1042 break; // beyond the caller's viewport — the sweep's job
1043 }
1044 row += 1;
1045 }
1046 }
1047
1048 // Phase 2 — the window refill.
1049 'fill: while work < max_lines {
1050 let dirt = ret.invalid.first().unwrap_or(u32::MAX);
1051 let Some(gap) = ret.first_window_gap(dirt) else { break };
1052 let started = match ret.start_state_for(gap) {
1053 StartState::Ready(s) => Some(s),
1054 StartState::Fresh => Some(fresh()),
1055 StartState::WarmupStored(r, s) => ret
1056 .warm_up(Some(r), gap, s, &mut work, max_lines, &syntect, &syntax.set, &mut line),
1057 StartState::WarmupFresh => ret
1058 .warm_up(None, gap, fresh(), &mut work, max_lines, &syntect, &syntax.set, &mut line),
1059 };
1060 let Some(mut state) = started else { break 'fill };
1061 let end_row = ret.win.end.min(ret.n_lines).min(dirt);
1062 let mut row = gap;
1063 while row < end_row {
1064 if work >= max_lines {
1065 ret.keep_resume_checkpoint(row.checked_sub(1), &state);
1066 break 'fill;
1067 }
1068 if ret.win_spans[(row - ret.win.start) as usize].is_some() {
1069 if let Some(s) = ret.stored_state_after(row) {
1070 state = s.clone(); // hop an already-filled row
1071 row += 1;
1072 continue;
1073 }
1074 }
1075 let (spans, end) = tokenize_line(&syntect, &syntax.set, &state, line(row).as_ref());
1076 ret.retain(row, spans, &end);
1077 work += 1;
1078 state = end;
1079 row += 1;
1080 }
1081 }
1082 work
1083 }
1084
1085 /// Swap the theme and invalidate the whole cache: a theme change is a full
1086 /// invalidation because syntect resolves colors at tokenize time, so every
1087 /// line's styles change and must be re-tokenized. States and checkpoints
1088 /// are dropped and every line marked dirty; the **old window spans are kept
1089 /// as a stale fallback** so the view keeps showing the outgoing theme's
1090 /// colors (not a flash to default) until the frontier repaints them on the
1091 /// next `tokenize_until`.
1092 pub fn set_theme(&mut self, theme: TokenTheme) {
1093 self.theme = Arc::new(theme);
1094 for s in &mut self.ret.win_states {
1095 *s = None;
1096 }
1097 self.ret.checkpoints.clear();
1098 self.ret.invalid = DirtyRanges::all(self.ret.n_lines);
1099 }
1100
1101 /// A cheap-to-clone, `Send + Sync` handle to this cache's grammar + theme,
1102 /// for off-thread bulk tokenization (the parallel/speculative sweep).
1103 /// Shares the `Arc`-held grammar/theme — no `SyntaxSet` deep clone.
1104 #[must_use]
1105 pub fn engine(&self) -> HighlightEngine {
1106 HighlightEngine { syntax: self.syntax.clone(), theme: self.theme.clone() }
1107 }
1108
1109 /// Ingest a segment tokenized off-thread (parallel/speculative sweep). The
1110 /// caller ([`crate::Document::absorb_highlight`]) guarantees the segment
1111 /// was computed at the current revision, so `rows`/checkpoints align with
1112 /// this cache.
1113 ///
1114 /// - `verified` — the coordinator chained this segment from row 0, so its
1115 /// start state (and thus its whole result) is TRUE. Merge its stride
1116 /// checkpoints (through the one [`Checkpoints::set_stride`]
1117 /// owner, so the set matches a sync walk), write its window spans+states
1118 /// into the current retention window, and **clear `rows` from the dirty
1119 /// set** — this replaces the foreground dirty walk for that span.
1120 /// - `!verified` — viewport speculation from a GUESSED fresh start. Write
1121 /// window spans+states ONLY into window rows that are still dirty (never
1122 /// clobber verified data), and **never plant a checkpoint**: an
1123 /// unverified state outside the window would poison a later warm-up.
1124 /// Dirt is untouched, so the frontier re-verifies
1125 /// these rows per-line and converges in O(1) if the guess was right; a
1126 /// dirty row's state is only ever read as a convergence probe, never as
1127 /// a warm-up source (that reads `first_dirty − 1`, which is clean).
1128 pub(crate) fn absorb(&mut self, seg: SegmentTokens, verified: bool) {
1129 let r = &mut self.ret;
1130 let SegmentTokens { rows, checkpoints, win, win_spans, win_states, .. } = seg;
1131 if verified {
1132 for (row, state) in checkpoints {
1133 r.checkpoints.set_stride(row, *state);
1134 }
1135 // Evict window rows this segment COVERS but did not SUPPLY spans
1136 // for: they are about to be marked clean, so any speculative span
1137 // there would survive as trusted-but-unverified (poison). `None`
1138 // renders as fallback and is refilled from the (correct)
1139 // checkpoints by the sync window sweep. Supplied rows are written
1140 // below, so they show correct colors with no flash.
1141 let cover = rows.start.max(r.win.start)..rows.end.min(r.win.end);
1142 for row in cover {
1143 if !win.contains(&row) {
1144 let i = (row - r.win.start) as usize;
1145 r.win_spans[i] = None;
1146 r.win_states[i] = None;
1147 }
1148 }
1149 for ((row, spans), state) in win.zip(win_spans).zip(win_states) {
1150 if let Some(i) = r.win_idx(row) {
1151 r.win_spans[i] = Some(spans);
1152 r.win_states[i] = Some(Box::new(state));
1153 }
1154 }
1155 r.invalid.clear_range(rows);
1156 } else {
1157 for ((row, spans), state) in win.zip(win_spans).zip(win_states) {
1158 // Only still-dirty window rows: never clobber verified spans,
1159 // and a dirty row's state is a probe, not a warm-up source.
1160 if r.invalid.contains(row) {
1161 if let Some(i) = r.win_idx(row) {
1162 r.win_spans[i] = Some(spans);
1163 r.win_states[i] = Some(Box::new(state));
1164 }
1165 }
1166 }
1167 }
1168 }
1169
1170 // Test-only retention probes — the memory canaries count entries, the
1171 // deterministic op-count analog of an RSS measurement.
1172 #[cfg(test)]
1173 fn retained_span_rows(&self) -> usize {
1174 self.ret.win_spans.iter().filter(|s| s.is_some()).count()
1175 }
1176 #[cfg(test)]
1177 fn window_len(&self) -> usize {
1178 debug_assert_eq!(self.ret.win_spans.len(), self.ret.win_states.len());
1179 self.ret.win_spans.len()
1180 }
1181 #[cfg(test)]
1182 fn dirty_row_count(&self) -> u32 {
1183 self.ret.invalid.total_rows()
1184 }
1185 #[cfg(test)]
1186 fn retained_state_rows(&self) -> usize {
1187 self.ret.win_states.iter().filter(|s| s.is_some()).count() + self.ret.checkpoints.len()
1188 }
1189}
1190
1191// ======================================================================
1192// Off-thread bulk tokenization (parallel + speculative sweep).
1193//
1194// The retention cache above serves the UI thread synchronously. On a
1195// multi-million-line document the top-down state chain would make a
1196// jump-to-bottom render fall back to plain text for ~16-20s of single-core
1197// work. Instead the app tokenizes SEGMENTS off-thread over an O(1) `Snapshot`
1198// clone, from GUESSED fresh states, and STITCHES boundaries with the same
1199// convergence rule the cache uses per line: segment i's true end state equals
1200// segment i+1's guessed (fresh) start iff everything downstream is provably
1201// identical. A viewport segment is shown immediately (speculative); the full
1202// document is verified left-to-right and absorbed. All threading is the APP's;
1203// this layer is pure and sync, syntect stays sealed.
1204// ======================================================================
1205
1206/// A cheap-to-clone, `Send + Sync` handle to a grammar + theme for off-thread
1207/// bulk tokenization. Nothing syntect crosses its surface; [`SegmentBoundary`]
1208/// and [`SegmentTokens`] are opaque. Obtain via [`HighlightCache::engine`] (to
1209/// share the cache's exact grammar/theme) or [`HighlightEngine::new`], and move
1210/// clones to worker threads.
1211#[derive(Clone)]
1212pub struct HighlightEngine {
1213 syntax: Arc<SyntaxDef>,
1214 theme: Arc<TokenTheme>,
1215}
1216
1217impl HighlightEngine {
1218 /// An engine over an injected grammar + theme (mirrors [`Highlighter::new`]).
1219 #[must_use]
1220 pub fn new(syntax: SyntaxDef, theme: TokenTheme) -> Self {
1221 Self { syntax: Arc::new(syntax), theme: Arc::new(theme) }
1222 }
1223
1224 /// The document-top fresh start state, as a boundary. The coordinator
1225 /// compares a segment's guessed `Fresh` start against a prior segment's
1226 /// true end with this: equal => the guess was right, the segment verifies
1227 /// as-is; unequal => re-run from the true end.
1228 #[must_use]
1229 pub fn fresh_boundary(&self) -> SegmentBoundary {
1230 SegmentBoundary(Box::new(self.fresh_state()))
1231 }
1232
1233 fn fresh_state(&self) -> LineState {
1234 let syntect = SyntectHighlighter::new(&self.theme.0);
1235 LineState {
1236 parse: ParseState::new(self.syntax.reference()),
1237 highlight: HighlightState::new(&syntect, ScopeStack::new()),
1238 }
1239 }
1240}
1241
1242/// The per-line carry state at a segment edge - opaque, comparable, sendable.
1243/// Two edges compare equal iff everything downstream is provably identical
1244/// (the convergence theorem: same state + same text => same tokens).
1245#[derive(Clone, PartialEq)]
1246pub struct SegmentBoundary(Box<LineState>);
1247
1248/// Where a segment's tokenization begins.
1249pub enum SegmentStart {
1250 /// From the document-top fresh state - a speculative guess, or genuinely
1251 /// row 0.
1252 Fresh,
1253 /// From a known boundary (a prior segment's verified end state).
1254 After(SegmentBoundary),
1255}
1256
1257/// One segment tokenized off-thread - opaque; ingested by
1258/// [`crate::Document::absorb_highlight`]. Carries stride checkpoints, the end
1259/// boundary, and (for a requested sub-range) dense window spans+states.
1260pub struct SegmentTokens {
1261 rows: Range<u32>,
1262 started_fresh: bool,
1263 /// Stride-aligned end-state checkpoints within `rows` (rows where
1264 /// `(row + 1) % HIGHLIGHT_CHECKPOINT_STRIDE == 0`) - the SAME rows the
1265 /// sync walk would pick, so [`HighlightCache::absorb`] merges them cleanly.
1266 checkpoints: Vec<(u32, Box<LineState>)>,
1267 /// End state after `rows.end - 1` (equals the start state if `rows` empty).
1268 end: SegmentBoundary,
1269 /// The captured window sub-range (subset of `rows`) and its dense data.
1270 win: Range<u32>,
1271 win_spans: Vec<Vec<HighlightSpan>>,
1272 win_states: Vec<LineState>,
1273 /// How many rows were actually tokenized (a converging re-run splices the
1274 /// rest from the old segment): `rows.len()` on a full run, less on an
1275 /// early-stop repair. The op-count the repair-bound canary asserts on, and
1276 /// a progress signal for the app.
1277 tokenized: u32,
1278}
1279
1280impl SegmentTokens {
1281 /// The rows this segment covers.
1282 #[must_use]
1283 pub fn rows(&self) -> Range<u32> {
1284 self.rows.clone()
1285 }
1286
1287 /// How many rows this segment actually tokenized (`< rows().len()` when a
1288 /// converging re-run spliced a proven-identical tail from the old segment).
1289 #[must_use]
1290 pub fn tokenized_rows(&self) -> u32 {
1291 self.tokenized
1292 }
1293
1294 /// The end-of-segment boundary (the true state after `rows.end - 1` once
1295 /// this segment is verified) - what the coordinator chains the next
1296 /// segment's verification against.
1297 #[must_use]
1298 pub fn end_boundary(&self) -> &SegmentBoundary {
1299 &self.end
1300 }
1301
1302 /// Whether this segment started from the fresh (guessed) state.
1303 #[must_use]
1304 pub fn started_fresh(&self) -> bool {
1305 self.started_fresh
1306 }
1307
1308 fn checkpoint_at(&self, row: u32) -> Option<&LineState> {
1309 self.checkpoints.binary_search_by_key(&row, |(r, _)| *r).ok().map(|i| &*self.checkpoints[i].1)
1310 }
1311}
1312
1313/// Tokenize `rows` of `snapshot` off-thread - pure, sync, unbudgeted (the app
1314/// runs it on a worker over an O(1) [`Snapshot`] clone). Records stride
1315/// checkpoints and, for `spans_for` intersected with `rows`, dense
1316/// spans+states.
1317///
1318/// `converge_against`: when RE-running a segment from a corrected start (a
1319/// speculative guess proved wrong), pass the old result. At each stride
1320/// checkpoint the new end state is compared with the old's; on the first match
1321/// the tail is spliced verbatim from the old (provably identical downstream) -
1322/// so repair costs O(distance to the construct's close), not O(segment). Pass
1323/// it on a re-run whose `rows` match the old segment's; **the window is forced
1324/// to the old segment's** (the passed `spans_for` is ignored when
1325/// `converge_against` is `Some`), so the caller need not track which window
1326/// the old segment was tokenized with — the splice stays internally consistent
1327/// even if the viewport moved since.
1328#[must_use]
1329pub fn tokenize_segment(
1330 engine: &HighlightEngine,
1331 snapshot: &Snapshot,
1332 rows: Range<u32>,
1333 start: SegmentStart,
1334 spans_for: Option<Range<u32>>,
1335 converge_against: Option<&SegmentTokens>,
1336) -> SegmentTokens {
1337 let syntect = SyntectHighlighter::new(&engine.theme.0);
1338 let set = &engine.syntax.set;
1339 let started_fresh = matches!(start, SegmentStart::Fresh);
1340 let mut state = match start {
1341 SegmentStart::Fresh => engine.fresh_state(),
1342 SegmentStart::After(b) => *b.0,
1343 };
1344
1345 let n = snapshot.line_count();
1346 let lo = rows.start.min(n);
1347 let hi = rows.end.min(n);
1348 // A converging re-run splices the old segment's window tail, so its window
1349 // MUST equal the old segment's — enforce that here regardless of the
1350 // caller's `spans_for`. The coordinator can dispatch a re-run with a moved
1351 // viewport's `spans_for` while `converge_against` still carries the original
1352 // window; honoring the moved one would splice mismatched rows. When
1353 // re-running, the window IS the old segment's window.
1354 let win = match converge_against {
1355 Some(old) => old.win.clone(),
1356 None => spans_for
1357 .map(|w| w.start.max(lo)..w.end.min(hi))
1358 .filter(|w| w.start < w.end)
1359 .unwrap_or(lo..lo),
1360 };
1361
1362 let mut checkpoints: Vec<(u32, Box<LineState>)> = Vec::new();
1363 let mut win_spans: Vec<Vec<HighlightSpan>> = Vec::with_capacity(win.len());
1364 let mut win_states: Vec<LineState> = Vec::with_capacity(win.len());
1365
1366 let mut row = lo;
1367 while row < hi {
1368 let line = snapshot.line(row);
1369 let (spans, end) = tokenize_line(&syntect, set, &state, line.as_ref());
1370 if win.contains(&row) {
1371 win_spans.push(spans);
1372 win_states.push(end.clone());
1373 }
1374 state = end;
1375 if (row + 1).is_multiple_of(HIGHLIGHT_CHECKPOINT_STRIDE) {
1376 checkpoints.push((row, Box::new(state.clone())));
1377 // Early-stop against the old segment on a corrected re-run: the
1378 // new state after `row` matching the old's checkpoint there means
1379 // every later row is provably identical - splice the old tail.
1380 if let Some(old) = converge_against {
1381 if old.checkpoint_at(row).is_some_and(|s| *s == state) {
1382 debug_assert_eq!(win, old.win, "the window was forced to old.win above");
1383 for (r, s) in &old.checkpoints {
1384 if *r > row {
1385 checkpoints.push((*r, s.clone()));
1386 }
1387 }
1388 for (k, wr) in old.win.clone().enumerate() {
1389 if wr > row {
1390 win_spans.push(old.win_spans[k].clone());
1391 win_states.push(old.win_states[k].clone());
1392 }
1393 }
1394 return SegmentTokens {
1395 rows,
1396 started_fresh,
1397 checkpoints,
1398 end: old.end.clone(),
1399 win,
1400 win_spans,
1401 win_states,
1402 tokenized: row - lo + 1, // rows lo..=row were run
1403 };
1404 }
1405 }
1406 }
1407 row += 1;
1408 }
1409
1410 SegmentTokens {
1411 rows,
1412 started_fresh,
1413 checkpoints,
1414 end: SegmentBoundary(Box::new(state)),
1415 win,
1416 win_spans,
1417 win_states,
1418 tokenized: hi - lo,
1419 }
1420}
1421
1422impl Retention {
1423 /// The window slot for `row`, if it is inside the retention window (and
1424 /// the document).
1425 fn win_idx(&self, row: u32) -> Option<usize> {
1426 (self.win.contains(&row) && row < self.n_lines).then(|| (row - self.win.start) as usize)
1427 }
1428
1429 /// Move the dense retention window through the per-edit line splices,
1430 /// keeping it aimed at the same buffer rows: a row above an edit shifts by
1431 /// that edit's line delta, a row inside an edit loses its now-stale span but
1432 /// keeps a convergence probe (the edit's old end state) so a state-neutral
1433 /// keystroke still converges in O(1), and rows below are untouched. Because
1434 /// it rides the per-edit spans (not one covering range), a SCATTERED
1435 /// multi-caret edit whose first-to-last span engulfs the window neither
1436 /// drains nor repositions it — the visible rows stay in the window and
1437 /// recolor on the next tokenize, rather than going stale until a scroll
1438 /// fires `set_window`. Bounded to [`HIGHLIGHT_MAX_WINDOW_ROWS`];
1439 /// O(window + edits).
1440 fn shift_window(&mut self, spans: &[(u32, u32, u32)]) {
1441 if spans.is_empty() {
1442 return;
1443 }
1444 let b = HIGHLIGHT_MAX_WINDOW_ROWS as usize;
1445 let mut pref: Vec<i64> = Vec::with_capacity(spans.len() + 1);
1446 pref.push(0);
1447 for &(_, o, n) in spans {
1448 pref.push(pref[pref.len() - 1] + i64::from(n) - i64::from(o));
1449 }
1450 // Displacement of a pre-edit row NOT inside any edit = delta of edits
1451 // entirely above it.
1452 let shift_at = |pre: u32| -> i64 { pref[spans.partition_point(|s| s.0 + s.1 <= pre)] };
1453 let edited = |pre: u32| -> bool {
1454 let i = spans.partition_point(|s| s.0 + s.1 <= pre);
1455 i < spans.len() && pre >= spans[i].0
1456 };
1457 // Convergence probes: each edit's OLD end state (of its last old row),
1458 // read BEFORE the window moves, keyed by the post row it lands on.
1459 let mut probes: Vec<(u32, Box<LineState>)> = Vec::new();
1460 for (j, &(ps, o, n)) in spans.iter().enumerate() {
1461 if n == 0 {
1462 continue;
1463 }
1464 if let Some(st) = self.stored_state_after(ps + o - 1) {
1465 let post = (i64::from(ps) + pref[j]) as u32 + n - 1;
1466 probes.push((post, Box::new(st.clone())));
1467 }
1468 }
1469 let old_start = self.win.start;
1470 let new_start = (i64::from(old_start) + shift_at(old_start)) as u32;
1471 let old_spans = std::mem::take(&mut self.win_spans);
1472 let old_states = std::mem::take(&mut self.win_states);
1473 // Survivors: window rows not inside an edit, at their post positions —
1474 // `(post row, spans, end state)`.
1475 type Survivor = (u32, Option<Vec<HighlightSpan>>, Option<Box<LineState>>);
1476 let mut max_post = new_start;
1477 let mut survivors: Vec<Survivor> = Vec::with_capacity(old_spans.len());
1478 for (idx, (sp, st)) in old_spans.into_iter().zip(old_states).enumerate() {
1479 let pre = old_start + idx as u32;
1480 if edited(pre) {
1481 continue; // edited row → span stale (a probe below drives convergence)
1482 }
1483 let post = (i64::from(pre) + shift_at(pre)) as u32;
1484 max_post = max_post.max(post);
1485 survivors.push((post, sp, st));
1486 }
1487 for (r, _) in &probes {
1488 max_post = max_post.max(*r);
1489 }
1490 let len = ((max_post + 1).saturating_sub(new_start) as usize).min(b);
1491 let mut spans_v: Vec<Option<Vec<HighlightSpan>>> = vec![None; len];
1492 let mut states_v: Vec<Option<Box<LineState>>> = vec![None; len];
1493 for (post, sp, st) in survivors {
1494 if let Some(i) = post.checked_sub(new_start).map(|d| d as usize) {
1495 if i < len {
1496 spans_v[i] = sp;
1497 states_v[i] = st;
1498 }
1499 }
1500 }
1501 for (post, probe) in probes {
1502 if let Some(i) = post.checked_sub(new_start).map(|d| d as usize) {
1503 if i < len && states_v[i].is_none() {
1504 states_v[i] = Some(probe);
1505 }
1506 }
1507 }
1508 self.win = new_start..new_start + len as u32;
1509 self.win_spans = spans_v;
1510 self.win_states = states_v;
1511 }
1512
1513 /// The stored end state after `row`: its window slot, else an exact
1514 /// checkpoint. Trustworthy only when `first_dirty()` is above `row` — the
1515 /// invariant that the first dirty line implies every line above it is
1516 /// valid.
1517 fn stored_state_after(&self, row: u32) -> Option<&LineState> {
1518 if let Some(i) = self.win_idx(row) {
1519 if let Some(s) = &self.win_states[i] {
1520 return Some(s);
1521 }
1522 }
1523 self.checkpoints.state_at(row)
1524 }
1525
1526 /// The nearest stored state at or before `row` (window slot or
1527 /// checkpoint), for warm-up.
1528 fn nearest_state_at_or_before(&self, row: u32) -> Option<(u32, &LineState)> {
1529 let cp = self.checkpoints.floor(row);
1530 // A window state can only beat the checkpoint in (cp_row, row] — scan
1531 // down just that far.
1532 let mut ws = None;
1533 if !self.win.is_empty() && self.win.start <= row {
1534 let hi = row.min(self.win.end - 1);
1535 let lo = cp.map_or(self.win.start, |(r, _)| (r + 1).max(self.win.start));
1536 let mut r = hi;
1537 while r >= lo {
1538 if let Some(s) = &self.win_states[(r - self.win.start) as usize] {
1539 ws = Some((r, &**s));
1540 break;
1541 }
1542 if r == lo {
1543 break;
1544 }
1545 r -= 1;
1546 }
1547 }
1548 match (cp, ws) {
1549 (Some(c), Some(w)) => Some(if w.0 >= c.0 { w } else { c }),
1550 (c, w) => w.or(c),
1551 }
1552 }
1553
1554 /// The start state for tokenizing `row`.
1555 fn start_state_for(&self, row: u32) -> StartState {
1556 if row == 0 {
1557 return StartState::Fresh;
1558 }
1559 if let Some(s) = self.stored_state_after(row - 1) {
1560 return StartState::Ready(s.clone());
1561 }
1562 match self.nearest_state_at_or_before(row - 1) {
1563 Some((r, s)) => StartState::WarmupStored(r, s.clone()),
1564 None => StartState::WarmupFresh,
1565 }
1566 }
1567
1568 /// Tokenize the clean rows between `from` (the row whose end state
1569 /// `state` is; `None` ⇒ fresh before row 0) and `to` (exclusive) to
1570 /// re-derive `to`'s start state, retaining as it goes. `None` if the
1571 /// budget dies first (a resume checkpoint is recorded).
1572 #[allow(clippy::too_many_arguments)]
1573 fn warm_up<S: AsRef<str>>(
1574 &mut self,
1575 from: Option<u32>,
1576 to: u32,
1577 mut state: LineState,
1578 work: &mut u32,
1579 max_lines: u32,
1580 syntect: &SyntectHighlighter<'_>,
1581 set: &SyntaxSet,
1582 line: &mut impl FnMut(u32) -> S,
1583 ) -> Option<LineState> {
1584 let mut r = from;
1585 loop {
1586 let next = r.map_or(0, |r| r + 1);
1587 if next >= to {
1588 return Some(state);
1589 }
1590 if *work >= max_lines {
1591 if let Some(r) = r {
1592 self.keep_resume_checkpoint(Some(r), &state);
1593 }
1594 return None;
1595 }
1596 let (spans, end) = tokenize_line(syntect, set, &state, line(next).as_ref());
1597 self.retain(next, spans, &end);
1598 *work += 1;
1599 r = Some(next);
1600 state = end;
1601 }
1602 }
1603
1604 /// Retain one tokenized row: spans + state in the window, a checkpoint at
1605 /// stride rows — and, crucially, a freshly tokenized row REFRESHES any
1606 /// checkpoint already sitting at it. Checkpoints drift off the stride grid
1607 /// (edits shift them; budget stops pin arbitrary rows), and a stale one
1608 /// left below the frontier would poison later warm-ups into permanently
1609 /// wrong colors. Stride inserts also prune the now-redundant off-grid
1610 /// checkpoints in the stride behind them, so drift and old resume pins
1611 /// cannot accumulate.
1612 fn retain(&mut self, row: u32, spans: Vec<HighlightSpan>, end: &LineState) {
1613 if let Some(i) = self.win_idx(row) {
1614 self.win_spans[i] = Some(spans);
1615 self.win_states[i] = Some(Box::new(end.clone()));
1616 }
1617 if (row + 1).is_multiple_of(HIGHLIGHT_CHECKPOINT_STRIDE) {
1618 self.checkpoints.set_stride(row, end.clone());
1619 } else {
1620 // A drifted (off-grid) checkpoint sitting on this freshly tokenized
1621 // row is refreshed so a stale state can't poison a later warm-up; a
1622 // no-op when none is there. Do not prune (it is not stride-aligned).
1623 self.checkpoints.refresh_if_present(row, end.clone());
1624 }
1625 }
1626
1627 /// A budget stop about to abandon the state after `row`: pin it as a
1628 /// checkpoint if nothing stores it, so the next call resumes without
1629 /// re-warming.
1630 fn keep_resume_checkpoint(&mut self, row: Option<u32>, state: &LineState) {
1631 let Some(row) = row else { return }; // before row 0 — fresh is free
1632 if self.stored_state_after(row).is_none() {
1633 self.checkpoints.upsert(row, state.clone());
1634 }
1635 }
1636
1637 /// The first window row below `before` whose spans await a (re)fill.
1638 fn first_window_gap(&self, before: u32) -> Option<u32> {
1639 let end = self.win.end.min(self.n_lines).min(before);
1640 (self.win.start..end).find(|&r| self.win_spans[(r - self.win.start) as usize].is_none())
1641 }
1642}
1643
1644#[cfg(test)]
1645mod tests {
1646 use super::*;
1647
1648 // A one-rule grammar: the word `kw` is a keyword.
1649 const GRAMMAR: &str = "%YAML 1.2\n\
1650 ---\n\
1651 name: Test\n\
1652 scope: source.test\n\
1653 contexts:\n\
1654 \x20 main:\n\
1655 \x20 - match: '\\bkw\\b'\n\
1656 \x20 scope: keyword.control.test\n";
1657
1658 // A minimal theme: default white, keyword red.
1659 const THEME: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
1660<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1661<plist version="1.0"><dict>
1662<key>name</key><string>Test</string>
1663<key>settings</key><array>
1664<dict><key>settings</key><dict><key>background</key><string>#000000</string><key>foreground</key><string>#ffffff</string></dict></dict>
1665<dict><key>scope</key><string>keyword</string><key>settings</key><dict><key>foreground</key><string>#ff0000</string></dict></dict>
1666</array></dict></plist>"#;
1667
1668 fn highlighter() -> Highlighter {
1669 Highlighter::new(
1670 SyntaxDef::from_sublime_syntax(GRAMMAR).expect("grammar parses"),
1671 TokenTheme::from_tm_theme(THEME).expect("theme parses"),
1672 )
1673 }
1674
1675 #[test]
1676 fn keyword_gets_the_keyword_color() {
1677 let lines = highlighter().highlight("kw x");
1678 assert_eq!(lines.len(), 1);
1679 // The `kw` at bytes 0..2 is red; something else is white.
1680 let red = Rgba { r: 0xff, g: 0, b: 0, a: 0xff };
1681 let white = Rgba { r: 0xff, g: 0xff, b: 0xff, a: 0xff };
1682 let kw = lines[0].iter().find(|s| s.range == (0..2)).expect("a span at 0..2");
1683 assert_eq!(kw.style.fg, red);
1684 assert!(lines[0].iter().any(|s| s.style.fg == white), "non-keyword text is default white");
1685 }
1686
1687 #[test]
1688 fn per_line_spans_and_a_trailing_empty_line() {
1689 let lines = highlighter().highlight("kw\nx\n");
1690 assert_eq!(lines.len(), 3); // "kw", "x", "" (trailing)
1691 assert!(lines[0].iter().any(|s| s.range == (0..2))); // "kw" on line 0
1692 assert!(lines[2].is_empty()); // the empty final line has no spans
1693 }
1694
1695 fn cache(n: u32) -> HighlightCache {
1696 HighlightCache::new(
1697 SyntaxDef::from_sublime_syntax(GRAMMAR).unwrap(),
1698 TokenTheme::from_tm_theme(THEME).unwrap(),
1699 n,
1700 )
1701 }
1702
1703 fn lines(text: &str) -> Vec<&str> {
1704 text.split('\n').collect()
1705 }
1706
1707 /// After tokenizing, every cached line must equal a fresh whole-document
1708 /// highlight of the same text — the convergence invariant, whatever the
1709 /// edit path.
1710 fn assert_matches_full(cache: &HighlightCache, text: &str) {
1711 let full = highlighter().highlight(text);
1712 for (row, expected) in full.iter().enumerate() {
1713 assert_eq!(cache.line_spans(row as u32), Some(expected.as_slice()), "line {row}");
1714 }
1715 }
1716
1717 #[test]
1718 fn fresh_cache_matches_whole_document_highlight() {
1719 let text = "kw a\nb kw\nc";
1720 let ls = lines(text);
1721 let mut c = cache(ls.len() as u32);
1722 c.tokenize_until(ls.len() as u32, u32::MAX, |r| ls[r as usize]);
1723 assert_matches_full(&c, text);
1724 }
1725
1726 #[test]
1727 fn in_place_edit_reconverges() {
1728 let before = "kw a\nb b\nkw c";
1729 let ls0 = lines(before);
1730 let mut c = cache(ls0.len() as u32);
1731 c.tokenize_until(ls0.len() as u32, u32::MAX, |r| ls0[r as usize]);
1732 // Edit line 1 in place ("b b" -> "b kw"); old == new == 1 line.
1733 let after = "kw a\nb kw\nkw c";
1734 let ls1 = lines(after);
1735 c.on_commit(1, 1, 1);
1736 c.tokenize_until(ls1.len() as u32, u32::MAX, |r| ls1[r as usize]);
1737 assert_matches_full(&c, after);
1738 }
1739
1740 #[test]
1741 fn insert_line_splices_and_reconverges() {
1742 let before = "kw\nx";
1743 let ls0 = lines(before);
1744 let mut c = cache(ls0.len() as u32);
1745 c.tokenize_until(ls0.len() as u32, u32::MAX, |r| ls0[r as usize]);
1746 // Insert a line: line 0 ("kw") becomes two lines ("kw", "y").
1747 let after = "kw\ny\nx";
1748 let ls1 = lines(after);
1749 c.on_commit(0, 1, 2);
1750 c.tokenize_until(ls1.len() as u32, u32::MAX, |r| ls1[r as usize]);
1751 assert_matches_full(&c, after);
1752 }
1753
1754 #[test]
1755 fn theme_swap_invalidates_all_keeps_stale_colors_then_recolors() {
1756 // The same minimal theme, but the keyword is blue instead of red.
1757 const THEME_BLUE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
1758<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1759<plist version="1.0"><dict>
1760<key>name</key><string>Blue</string>
1761<key>settings</key><array>
1762<dict><key>settings</key><dict><key>background</key><string>#000000</string><key>foreground</key><string>#ffffff</string></dict></dict>
1763<dict><key>scope</key><string>keyword</string><key>settings</key><dict><key>foreground</key><string>#0000ff</string></dict></dict>
1764</array></dict></plist>"#;
1765 let red = Rgba { r: 0xff, g: 0, b: 0, a: 0xff };
1766 let blue = Rgba { r: 0, g: 0, b: 0xff, a: 0xff };
1767 let has = |c: &HighlightCache, row: u32, col: Rgba| {
1768 c.line_spans(row).unwrap().iter().any(|s| s.style.fg == col)
1769 };
1770 // Two keyword lines, both tokenized (red).
1771 let ls = lines("kw a\nb kw");
1772 let mut c = cache(ls.len() as u32);
1773 c.tokenize_until(ls.len() as u32, u32::MAX, |r| ls[r as usize]);
1774 assert!(has(&c, 0, red) && has(&c, 1, red));
1775 // Swap the theme: every line goes dirty, but the OLD (red) spans stay as
1776 // a stale fallback — no flash to default before the repaint.
1777 c.set_theme(TokenTheme::from_tm_theme(THEME_BLUE).unwrap());
1778 assert!(has(&c, 0, red) && has(&c, 1, red), "stale colors until repainted");
1779 // Repaint the whole cache: the keyword is now blue on every line, no red left.
1780 c.tokenize_until(ls.len() as u32, u32::MAX, |r| ls[r as usize]);
1781 assert!(has(&c, 0, blue) && has(&c, 1, blue), "both lines recolored");
1782 assert!(!has(&c, 0, red) && !has(&c, 1, red), "no stale red survives repaint");
1783 }
1784
1785 #[test]
1786 fn convergence_stops_early_below_an_edit() {
1787 // A grammar with no multi-line state ⇒ every line's end state is equal,
1788 // so an in-place edit converges at the edited line and never re-tokenizes
1789 // the (unchanged) line below it. We prove that by leaving the line below
1790 // deliberately WRONG in `lines` — if it were re-tokenized it would change.
1791 let ls0 = lines("kw\nkw\nkw");
1792 let mut c = cache(3);
1793 c.tokenize_until(3, u32::MAX, |r| ls0[r as usize]);
1794 c.on_commit(0, 1, 1); // edit line 0 only
1795 // Pass a lines slice whose line 2 is a sentinel that must NOT be touched.
1796 let probe_lines = ["kw", "kw", "SHOULD-NOT-RETOKENIZE"];
1797 c.tokenize_until(3, u32::MAX, |r| probe_lines[r as usize]);
1798 // Line 2 kept its original "kw" spans (convergence stopped at line 0).
1799 assert_eq!(c.line_spans(2), Some(highlighter().highlight("kw").pop().unwrap().as_slice()));
1800 }
1801
1802 #[test]
1803 fn perf_canary_state_neutral_keystroke_is_o1_regardless_of_size() {
1804 // Op-count canary (not timing): the test grammar has no multi-line
1805 // state, so editing any line converges at that line — a keystroke
1806 // re-tokenizes ~1 line no matter how big the document is. The "no
1807 // O(file) stacking" guarantee: the number is independent of the
1808 // 2000-line size.
1809 let big = vec!["kw x"; 2000].join("\n");
1810 let ls = lines(&big);
1811 let mut c = cache(ls.len() as u32);
1812 c.tokenize_until(ls.len() as u32, u32::MAX, |r| ls[r as usize]); // full initial pass
1813 c.on_commit(1000, 1, 1); // a keystroke deep in the document
1814 let n = c.tokenize_until(ls.len() as u32, u32::MAX, |r| ls[r as usize]);
1815 assert!(n <= 2, "state-neutral keystroke re-tokenized {n} lines in a 2000-line doc (expected O(1))");
1816 }
1817
1818 #[test]
1819 fn perf_canary_target_bounds_work_to_the_viewport() {
1820 // Perf canary 1 (the viewport-targeting tooth): tokenizing to a viewport
1821 // target does ≤ viewport work, never O(file) — even with every line dirty
1822 // (a fresh cache, or a top-of-file state cascade). 2000 lines dirty,
1823 // target row 50 → exactly 51 lines tokenized, nothing below.
1824 let big = vec!["kw x"; 2000].join("\n");
1825 let ls = lines(&big);
1826 let mut c = cache(ls.len() as u32); // all 2000 lines dirty
1827 let n = c.tokenize_until(50, u32::MAX, |r| ls[r as usize]);
1828 assert_eq!(n, 51, "target must bound tokenization to the viewport (rows 0..=50)");
1829 assert!(c.line_spans(50).is_some(), "the viewport is tokenized");
1830 assert!(c.line_spans(51).is_none(), "nothing past the viewport target is tokenized");
1831 }
1832
1833 #[test]
1834 fn perf_canary_budget_caps_a_call_and_resumes() {
1835 // The per-call budget: one call does at most `max_lines` of work, the
1836 // frontier records where it stopped, and the next call resumes there —
1837 // total work conserved.
1838 let big = vec!["kw x"; 300].join("\n");
1839 let ls = lines(&big);
1840 let mut c = cache(ls.len() as u32);
1841 let n = c.tokenize_until(u32::MAX, 100, |r| ls[r as usize]);
1842 assert_eq!(n, 100, "the budget caps the call");
1843 assert_eq!(c.first_dirty(), Some(100), "the frontier records the resume point");
1844 let n = c.tokenize_until(u32::MAX, u32::MAX, |r| ls[r as usize]);
1845 assert_eq!(n, 200, "the next call finishes the remainder");
1846 assert_eq!(c.first_dirty(), None, "converged");
1847 }
1848
1849 #[test]
1850 fn perf_canary_deep_dirty_tail_commit_is_cheap() {
1851 // A commit must be O(edit + #runs), independent of how long a dirty
1852 // tail the document carries — storing runs (not individual rows) is
1853 // what makes that hold. Generous wall-clock bound (the one in-test
1854 // timing allowed): 1000 commits against a 1M-line dirty tail must be
1855 // near-instant; a per-commit rebuild of the whole dirty set would take
1856 // tens of seconds.
1857 let mut c = cache(1_000_000); // every line dirty, one run
1858 let t0 = std::time::Instant::now();
1859 for _ in 0..1_000 {
1860 c.on_commit(500_000, 1, 1);
1861 }
1862 assert!(
1863 t0.elapsed() < std::time::Duration::from_secs(1),
1864 "1000 deep-dirty-tail commits took {:?} — the dirty set is being rebuilt per commit",
1865 t0.elapsed()
1866 );
1867 assert!(c.ret.invalid.runs() <= 3, "the run list stays compact: {}", c.ret.invalid.runs());
1868 }
1869
1870 #[test]
1871 fn dirty_runs_stay_small_under_a_typing_run() {
1872 let mut c = cache(2_000);
1873 // Converge everything first.
1874 let big = vec!["kw x"; 2000].join("\n");
1875 let ls = lines(&big);
1876 c.tokenize_until(u32::MAX, u32::MAX, |r| ls[r as usize]);
1877 // A typing run at one spot, plus a couple of scattered edits.
1878 for _ in 0..50 {
1879 c.on_commit(700, 1, 1);
1880 }
1881 c.on_commit(100, 1, 2);
1882 c.on_commit(1500, 2, 1);
1883 assert!(c.ret.invalid.runs() <= 4, "scattered typing produced {} runs", c.ret.invalid.runs());
1884 }
1885
1886 // ------------------------------------------------------------------
1887 // Highlight virtualization: a STATEFUL grammar — an unterminated `"` flips
1888 // every following line into the `string` context — so checkpoints,
1889 // warm-ups, and cross-stride convergence are exercised for real (the `kw`
1890 // grammar above is stateless: every end state is equal, which can never
1891 // distinguish a right checkpoint from a wrong one).
1892 // ------------------------------------------------------------------
1893 const GRAMMAR_STR: &str = "%YAML 1.2\n\
1894 ---\n\
1895 name: TestStr\n\
1896 scope: source.tstr\n\
1897 contexts:\n\
1898 \x20 main:\n\
1899 \x20 - match: '\"'\n\
1900 \x20 scope: punctuation.definition.string.begin\n\
1901 \x20 push: string\n\
1902 \x20 - match: '\\bkw\\b'\n\
1903 \x20 scope: keyword.control.test\n\
1904 \x20 string:\n\
1905 \x20 - match: '\"'\n\
1906 \x20 scope: punctuation.definition.string.end\n\
1907 \x20 pop: true\n\
1908 \x20 - match: '.'\n\
1909 \x20 scope: string.quoted.test\n";
1910
1911 // A grammar with an ASYMMETRIC block comment (`/* … */`): unlike the
1912 // symmetric `"` toggle, a wrong guess about being inside a comment
1913 // SELF-HEALS at the next `*/` (which returns to ground unconditionally),
1914 // so a mis-guessed segment re-CONVERGES — the case the stitch's early-stop
1915 // splice exists for. (`/*` only opens from `main`; `*/` only closes from
1916 // `comment`; neither nests.)
1917 const GRAMMAR_CMT: &str = "%YAML 1.2\n\
1918 ---\n\
1919 name: TestCmt\n\
1920 scope: source.tcmt\n\
1921 contexts:\n\
1922 \x20 main:\n\
1923 \x20 - match: '/\\*'\n\
1924 \x20 scope: punctuation.definition.comment.begin\n\
1925 \x20 push: comment\n\
1926 \x20 - match: '\\bkw\\b'\n\
1927 \x20 scope: keyword.control.test\n\
1928 \x20 comment:\n\
1929 \x20 - match: '\\*/'\n\
1930 \x20 scope: punctuation.definition.comment.end\n\
1931 \x20 pop: true\n\
1932 \x20 - match: '.'\n\
1933 \x20 scope: comment.block.test\n";
1934
1935 fn engine_cmt() -> HighlightEngine {
1936 HighlightEngine::new(
1937 SyntaxDef::from_sublime_syntax(GRAMMAR_CMT).unwrap(),
1938 TokenTheme::from_tm_theme(THEME).unwrap(),
1939 )
1940 }
1941
1942 fn cache_cmt(n: u32) -> HighlightCache {
1943 HighlightCache::new(
1944 SyntaxDef::from_sublime_syntax(GRAMMAR_CMT).unwrap(),
1945 TokenTheme::from_tm_theme(THEME).unwrap(),
1946 n,
1947 )
1948 }
1949
1950 fn highlighter_cmt() -> Highlighter {
1951 Highlighter::new(
1952 SyntaxDef::from_sublime_syntax(GRAMMAR_CMT).unwrap(),
1953 TokenTheme::from_tm_theme(THEME).unwrap(),
1954 )
1955 }
1956
1957 fn cache_str(n: u32) -> HighlightCache {
1958 HighlightCache::new(
1959 SyntaxDef::from_sublime_syntax(GRAMMAR_STR).unwrap(),
1960 TokenTheme::from_tm_theme(THEME).unwrap(),
1961 n,
1962 )
1963 }
1964
1965 fn highlighter_str() -> Highlighter {
1966 Highlighter::new(
1967 SyntaxDef::from_sublime_syntax(GRAMMAR_STR).unwrap(),
1968 TokenTheme::from_tm_theme(THEME).unwrap(),
1969 )
1970 }
1971
1972 /// Drive to full convergence (dirty walk + window refill), budgeted like
1973 /// the real sweep.
1974 fn drive_all(c: &mut HighlightCache, lines: &[String]) -> u32 {
1975 let mut work = 0;
1976 while c.pending().is_some() {
1977 work += c
1978 .tokenize_until(u32::MAX, HIGHLIGHT_MAX_LINES_PER_CALL, |r| lines[r as usize].as_str());
1979 }
1980 work
1981 }
1982
1983 /// The virtualization guarantee: sweeping a 10k-line document with the
1984 /// window aimed at the top retains a bounded number of rows, not the whole
1985 /// document — spans and states outside the window are evicted, so RAM does
1986 /// not grow with the sweep.
1987 #[test]
1988 fn virtualized_sweep_retains_bounded_rows() {
1989 let lines: Vec<String> = (0..10_000).map(|i| format!("kw line {i}")).collect();
1990 let mut c = cache(10_000);
1991 c.set_window(0..64);
1992 drive_all(&mut c, &lines);
1993 let window_bound = (64 + 2 * HIGHLIGHT_WINDOW_SLACK) as usize;
1994 assert!(
1995 c.retained_span_rows() <= window_bound,
1996 "span retention must be windowed: {} rows kept (bound {window_bound})",
1997 c.retained_span_rows()
1998 );
1999 let state_bound = window_bound + 3 * (10_000 / HIGHLIGHT_CHECKPOINT_STRIDE as usize);
2000 assert!(
2001 c.retained_state_rows() <= state_bound,
2002 "state retention must be window + sparse checkpoints: {} kept (bound {state_bound})",
2003 c.retained_state_rows()
2004 );
2005 // Retention is bounded but the window is still CORRECT…
2006 let full = highlighter().highlight(&lines.join("\n"));
2007 assert_eq!(c.line_spans(10), Some(full[10].as_slice()));
2008 // …and out-of-window rows honestly report nothing (fallback render).
2009 assert_eq!(c.line_spans(5_000), None);
2010 }
2011
2012 /// A window move over already-swept rows refills from the nearest
2013 /// checkpoint: correct spans (stateful grammar — a wrong warm-up state
2014 /// would color the string region wrong), bounded work.
2015 #[test]
2016 fn window_move_refills_from_checkpoints() {
2017 let mut lines: Vec<String> = (0..3_000).map(|i| format!("x {i} kw")).collect();
2018 lines[1_000] = "\"open".into(); // unterminated: rows 1001.. are in-string
2019 lines[2_000] = "close\"".into(); // …until the string pops here
2020 let full = highlighter_str().highlight(&lines.join("\n"));
2021 let mut c = cache_str(3_000);
2022 c.set_window(0..40);
2023 drive_all(&mut c, &lines);
2024 assert!(c.line_spans(1_600).is_none(), "deep rows evicted after the sweep");
2025
2026 // Cold jump into the middle of the multi-line string.
2027 c.set_window(1_500..1_540);
2028 let fill_work = drive_all(&mut c, &lines);
2029 for r in 1_500..1_540 {
2030 assert_eq!(c.line_spans(r), Some(full[r as usize].as_slice()), "row {r}");
2031 }
2032 assert_eq!(c.line_spans(10), None, "the old window was evicted");
2033 // ≤ one stride of warm-up + the retained window (+ a little dust).
2034 let bound = HIGHLIGHT_CHECKPOINT_STRIDE + 40 + 2 * HIGHLIGHT_WINDOW_SLACK + 8;
2035 assert!(fill_work <= bound, "cold-jump refill did {fill_work} lines (bound {bound})");
2036 }
2037
2038 /// A state-neutral edit OUTSIDE the window re-converges at checkpoint
2039 /// grain: bounded by strides (+ warm-up), never by the document — and
2040 /// the far-away window's rows stay correct.
2041 #[test]
2042 fn out_of_window_edit_reconverges_within_a_stride() {
2043 let mut lines: Vec<String> = (0..2_000).map(|i| format!("kw {i}")).collect();
2044 let mut c = cache_str(2_000);
2045 c.set_window(1_500..1_540);
2046 drive_all(&mut c, &lines);
2047 lines[100] = "kw edited".into(); // state-neutral, far above the window
2048 c.on_commit(100, 1, 1);
2049 let work = drive_all(&mut c, &lines);
2050 let bound = 2 * HIGHLIGHT_CHECKPOINT_STRIDE + 2;
2051 assert!(work <= bound, "out-of-window keystroke re-tokenized {work} lines (bound {bound})");
2052 let full = highlighter_str().highlight(&lines.join("\n"));
2053 assert_eq!(c.line_spans(1_520), Some(full[1_520].as_slice()));
2054 }
2055
2056 /// An in-window state-neutral keystroke stays O(1) under the STATEFUL
2057 /// grammar too — per-line convergence probes live in the window.
2058 #[test]
2059 fn in_window_keystroke_stays_o1_with_stateful_grammar() {
2060 let mut lines: Vec<String> = (0..2_000).map(|i| format!("kw {i}")).collect();
2061 lines[300] = "\"open".into();
2062 lines[600] = "shut\"".into();
2063 let mut c = cache_str(2_000);
2064 c.set_window(900..940);
2065 drive_all(&mut c, &lines);
2066 lines[920] = "kw retyped".into();
2067 c.on_commit(920, 1, 1);
2068 let work = drive_all(&mut c, &lines);
2069 assert!(work <= 2, "in-window keystroke re-tokenized {work} lines (expected O(1))");
2070 }
2071
2072 /// The randomized battering ram: seeded edits, window moves, and
2073 /// budget-starved drives in any order — after convergence, every retained
2074 /// row equals the whole-document oracle, wherever the window lands.
2075 #[test]
2076 fn randomized_edits_and_window_moves_match_the_oracle() {
2077 let mut lines: Vec<String> = (0..1_200)
2078 .map(|i| match i % 97 {
2079 13 => "\"".to_string(),
2080 51 => "shut\" kw".to_string(),
2081 _ => format!("kw word {i}"),
2082 })
2083 .collect();
2084 let mut c = cache_str(1_200);
2085 let mut state = 0xDEADBEEF_u64;
2086 let mut rand = move |bound: usize| {
2087 state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
2088 ((state >> 33) as usize) % bound.max(1)
2089 };
2090 for _step in 0..120 {
2091 match rand(3) {
2092 0 => {
2093 let s = rand(lines.len());
2094 let old = (1 + rand(3)).min(lines.len() - s);
2095 let n = 1 + rand(3);
2096 let repl: Vec<String> = (0..n)
2097 .map(|k| match rand(5) {
2098 0 => "\"".to_string(),
2099 1 => "q\" kw".to_string(),
2100 _ => format!("e{k} kw"),
2101 })
2102 .collect();
2103 lines.splice(s..s + old, repl);
2104 c.on_commit(s as u32, old as u32, n as u32);
2105 }
2106 1 => {
2107 let s = rand(lines.len());
2108 c.set_window(s as u32..(s + 40).min(lines.len()) as u32);
2109 }
2110 _ => {
2111 let target = rand(lines.len()) as u32;
2112 let budget = 32 + rand(200) as u32;
2113 c.tokenize_until(target, budget, |r| lines[r as usize].as_str());
2114 }
2115 }
2116 assert_eq!(c.line_count() as usize, lines.len(), "line count in step");
2117 }
2118 // Converge, then check every retained row — and re-aim the window at
2119 // several places so refill paths get checked against the oracle too.
2120 let full = highlighter_str().highlight(&lines.join("\n"));
2121 drive_all(&mut c, &lines);
2122 let mut checked = 0usize;
2123 for r in 0..lines.len() as u32 {
2124 if let Some(spans) = c.line_spans(r) {
2125 assert_eq!(spans, full[r as usize].as_slice(), "row {r}");
2126 checked += 1;
2127 }
2128 }
2129 assert!(checked >= 40, "the window retained rows to check ({checked})");
2130 for &probe in &[0usize, 400, 800, lines.len().saturating_sub(45)] {
2131 c.set_window(probe as u32..(probe + 40).min(lines.len()) as u32);
2132 drive_all(&mut c, &lines);
2133 for (r, expected) in
2134 full.iter().enumerate().take((probe + 40).min(lines.len())).skip(probe)
2135 {
2136 assert_eq!(c.line_spans(r as u32), Some(expected.as_slice()), "probe {probe} row {r}");
2137 }
2138 }
2139 }
2140
2141 #[test]
2142 fn scattered_multi_caret_marks_only_edited_lines_dirty() {
2143 // A scattered multi-caret keystroke folds into ONE covering line range
2144 // (first caret .. last caret). The dirty set must hold only the EDITED
2145 // lines (per-edit spans), not the whole covering span — else the sweep
2146 // re-tokenizes O(document) between the carets.
2147 let mut lines: Vec<String> = (0..2_000).map(|i| format!("kw {i}")).collect();
2148 let mut c = cache_str(2_000);
2149 c.set_window(0..40);
2150 drive_all(&mut c, &lines);
2151 assert_eq!(c.dirty_row_count(), 0, "swept clean");
2152 // Two carets, rows 10 and 1_500, one char each (state-neutral, 1→1).
2153 lines[10] = "kw a".into();
2154 lines[1_500] = "kw b".into();
2155 c.on_commit_patch(&[(10, 1, 1), (1_500, 1, 1)]);
2156 assert_eq!(
2157 c.dirty_row_count(),
2158 2,
2159 "a scattered 2-caret edit must mark 2 lines dirty, not the ~1491-row covering span"
2160 );
2161 // …and the highlight is still correct after the sweep.
2162 drive_all(&mut c, &lines);
2163 let full = highlighter_str().highlight(&lines.join("\n"));
2164 for r in 0..40 {
2165 assert_eq!(c.line_spans(r), Some(full[r as usize].as_slice()), "row {r}");
2166 }
2167 }
2168
2169 #[test]
2170 fn scattered_edit_keeps_the_window_aimed_at_the_viewport() {
2171 // Select-all-occurrences of a word + delete is a scattered multi-edit
2172 // whose covering range engulfs the viewport window. The window must
2173 // stay aimed at the viewport (only the edited rows invalidated), so the
2174 // VISIBLE rows recolor on the next tokenize — not only after a scroll
2175 // fires set_window and re-aims it. A splice that rode the covering range
2176 // would drain or reposition the window, dropping the visible rows to
2177 // fallback until a scrollwheel tick.
2178 let mut lines: Vec<String> = (0..2_000).map(|i| format!("kw word {i}")).collect();
2179 let mut c = cache_str(2_000);
2180 c.set_window(940..980);
2181 drive_all(&mut c, &lines);
2182 assert!(c.line_spans(960).is_some(), "the viewport window is filled");
2183 // Delete a word at scattered occurrences: rows 10, 960 (in view), 1_500,
2184 // one transaction, all state-neutral (1→1). Covering range 10..1_500
2185 // engulfs the window at 940..980.
2186 for r in [10usize, 960, 1_500] {
2187 lines[r] = "kw w".into();
2188 }
2189 c.on_commit_patch(&[(10, 1, 1), (960, 1, 1), (1_500, 1, 1)]);
2190 // Tokenize WITHOUT re-aiming the window (no set_window / no scroll).
2191 drive_all(&mut c, &lines);
2192 let full = highlighter_str().highlight(&lines.join("\n"));
2193 for r in 940..980u32 {
2194 assert_eq!(
2195 c.line_spans(r),
2196 Some(full[r as usize].as_slice()),
2197 "viewport row {r} did not recolor without a scroll to re-aim the window",
2198 );
2199 }
2200 }
2201
2202 /// The multi-edit analogue of the randomized oracle: each step commits a
2203 /// TRANSACTION of several disjoint edits at once via `on_commit_patch` (the
2204 /// scattered multi-caret path), and after convergence every retained row
2205 /// must still equal the whole-document oracle. Guards the per-edit
2206 /// checkpoint/dirty merge walks against any shift or coalescing bug.
2207 #[test]
2208 fn randomized_multi_edit_commits_match_the_oracle() {
2209 let mut lines: Vec<String> = (0..1_200)
2210 .map(|i| match i % 97 {
2211 13 => "\"".to_string(),
2212 51 => "shut\" kw".to_string(),
2213 _ => format!("kw word {i}"),
2214 })
2215 .collect();
2216 let mut c = cache_str(1_200);
2217 let mut state = 0x1234_5678_9ABC_DEF0_u64;
2218 let mut rand = move |bound: usize| {
2219 state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
2220 ((state >> 33) as usize) % bound.max(1)
2221 };
2222 for _step in 0..80 {
2223 if rand(3) == 2 {
2224 // Sometimes just tokenize a budget, leaving dirt around.
2225 let target = rand(lines.len()) as u32;
2226 c.tokenize_until(target, 32 + rand(200) as u32, |r| lines[r as usize].as_str());
2227 assert_eq!(c.line_count() as usize, lines.len());
2228 continue;
2229 }
2230 // Build up to 4 disjoint edits, ascending with a gap between each.
2231 let k = 1 + rand(4);
2232 let mut sites: Vec<(usize, usize, Vec<String>)> = Vec::new();
2233 let mut cursor = rand(20);
2234 for _ in 0..k {
2235 let s = cursor + rand(30);
2236 if s >= lines.len() {
2237 break;
2238 }
2239 let old = (1 + rand(3)).min(lines.len() - s);
2240 let n = 1 + rand(3);
2241 let repl: Vec<String> = (0..n)
2242 .map(|j| match rand(5) {
2243 0 => "\"".to_string(),
2244 1 => "q\" kw".to_string(),
2245 _ => format!("m{j} kw"),
2246 })
2247 .collect();
2248 sites.push((s, old, repl));
2249 cursor = s + old + 1; // a gap ⇒ the next start is strictly after
2250 }
2251 if sites.is_empty() {
2252 continue;
2253 }
2254 // Per-edit spans (pre-edit, disjoint) + the covering splice.
2255 let spans: Vec<(u32, u32, u32)> =
2256 sites.iter().map(|(s, o, r)| (*s as u32, *o as u32, r.len() as u32)).collect();
2257 c.on_commit_patch(&spans);
2258 // Apply to `lines` back-to-front so earlier indices stay valid.
2259 for (s, o, repl) in sites.iter().rev() {
2260 lines.splice(*s..*s + *o, repl.clone());
2261 }
2262 assert_eq!(c.line_count() as usize, lines.len(), "line count after multi-edit");
2263 }
2264 let full = highlighter_str().highlight(&lines.join("\n"));
2265 drive_all(&mut c, &lines);
2266 let mut checked = 0usize;
2267 for r in 0..lines.len() as u32 {
2268 if let Some(spans) = c.line_spans(r) {
2269 assert_eq!(spans, full[r as usize].as_slice(), "row {r}");
2270 checked += 1;
2271 }
2272 }
2273 assert!(checked >= 40, "retained rows to check ({checked})");
2274 for &probe in &[0usize, 300, 700, lines.len().saturating_sub(45)] {
2275 c.set_window(probe as u32..(probe + 40).min(lines.len()) as u32);
2276 drive_all(&mut c, &lines);
2277 for (r, expected) in
2278 full.iter().enumerate().take((probe + 40).min(lines.len())).skip(probe)
2279 {
2280 assert_eq!(c.line_spans(r as u32), Some(expected.as_slice()), "probe {probe} row {r}");
2281 }
2282 }
2283 }
2284
2285 /// Checkpoints drift off the stride grid (any line-delta edit shifts them),
2286 /// and a state-changing cascade crossing an off-stride checkpoint must
2287 /// REFRESH it — a stale one left below the frontier poisons a later
2288 /// cold-jump warm-up into permanently wrong highlighting. Fails against a
2289 /// `retain` that only writes stride-aligned checkpoints.
2290 #[test]
2291 fn shifted_checkpoints_are_refreshed_by_a_crossing_cascade() {
2292 let mut lines: Vec<String> = (0..2_000).map(|i| format!("kw {i}")).collect();
2293 let mut c = cache_str(2_000);
2294 c.set_window(0..40);
2295 drive_all(&mut c, &lines);
2296 // A single Enter above everything: every checkpoint shifts off-stride.
2297 lines.insert(10, "kw split".to_string());
2298 c.on_commit(10, 1, 2);
2299 drive_all(&mut c, &lines);
2300 // A state-changing edit near the top: the cascade repaints the tail,
2301 // crossing every shifted checkpoint.
2302 lines[12] = "\"open".to_string(); // unterminated → everything below is in-string
2303 c.on_commit(12, 1, 1);
2304 drive_all(&mut c, &lines);
2305 // Cold-jump far below: the refill warms up from whatever checkpoint
2306 // is nearest — which must hold the POST-cascade (in-string) state.
2307 c.set_window(1_285..1_325);
2308 drive_all(&mut c, &lines);
2309 let full = highlighter_str().highlight(&lines.join("\n"));
2310 for r in 1_285..1_325u32 {
2311 assert_eq!(c.line_spans(r), Some(full[r as usize].as_slice()), "row {r}");
2312 }
2313 }
2314
2315 /// Off-stride and orphaned resume checkpoints must be pruned as fresh
2316 /// stride checkpoints land, or they accumulate without bound across
2317 /// edit/cascade cycles — slowly re-growing the RAM virtualization keeps
2318 /// bounded.
2319 #[test]
2320 fn checkpoints_do_not_accumulate_across_edit_cycles() {
2321 let mut lines: Vec<String> = (0..4_000).map(|i| format!("kw {i}")).collect();
2322 let mut c = cache_str(4_000);
2323 c.set_window(0..40);
2324 drive_all(&mut c, &lines);
2325 for cycle in 0..16 {
2326 // Shift the grid…
2327 lines.insert(5, format!("kw cycle {cycle}"));
2328 c.on_commit(5, 1, 2);
2329 // …and flip the whole tail's state, twice (open then close).
2330 lines[7] = "\"open".to_string();
2331 c.on_commit(7, 1, 1);
2332 drive_all(&mut c, &lines);
2333 lines[7] = "kw closed".to_string();
2334 c.on_commit(7, 1, 1);
2335 drive_all(&mut c, &lines);
2336 }
2337 let bound = 2 * (c.line_count() / HIGHLIGHT_CHECKPOINT_STRIDE) as usize + 8;
2338 assert!(
2339 c.ret.checkpoints.len() <= bound,
2340 "checkpoints accumulate: {} after 16 cycles (bound {bound})",
2341 c.ret.checkpoints.len()
2342 );
2343 }
2344
2345 // ── The checkpoint delta-gap `SumTree` ──────────────────────────────────
2346
2347 /// A fresh (document-top) `LineState` for seeding checkpoint trees without a
2348 /// real sweep — the `main`-context start state.
2349 fn fresh_state() -> LineState {
2350 engine_str().fresh_state()
2351 }
2352
2353 /// A distinct `LineState`: the parser left INSIDE a string context (after an
2354 /// unclosed `"`), so it compares unequal to [`fresh_state`] — lets the oracle
2355 /// pin the `(row, state)` sequence, not just the rows.
2356 fn str_state() -> LineState {
2357 let eng = engine_str();
2358 let syntect = SyntectHighlighter::new(&eng.theme.0);
2359 let (_spans, end) = tokenize_line(&syntect, &eng.syntax.set, &eng.fresh_state(), "\"");
2360 end
2361 }
2362
2363 /// A straightforward flat-Vec checkpoint shift — the ORACLE reference the
2364 /// delta-gap tree's `shift` must match, edit for edit. Drift in a
2365 /// checkpoint row poisons later warm-ups (a wrong start state → permanently
2366 /// wrong colors), so this equivalence is load-bearing.
2367 fn vec_shift_reference(cps: &mut Vec<(u32, Box<LineState>)>, spans: &[(u32, u32, u32)]) {
2368 if spans.is_empty() {
2369 return;
2370 }
2371 let old = std::mem::take(cps);
2372 let mut si = 0;
2373 let mut acc = 0i64;
2374 for (row, state) in old {
2375 while si < spans.len() && spans[si].0 + spans[si].1 <= row {
2376 acc += i64::from(spans[si].2) - i64::from(spans[si].1);
2377 si += 1;
2378 }
2379 if si < spans.len() && row >= spans[si].0 {
2380 continue; // inside a replaced span → stale
2381 }
2382 cps.push(((row as i64 + acc) as u32, state));
2383 }
2384 }
2385
2386 /// ORACLE: the checkpoint tree's `shift` equals the flat-Vec reference under
2387 /// random single- AND multi-edit patches, after EVERY commit. Single-edit
2388 /// patches drive `shift_single` (the windowed seam surgery — the real risk);
2389 /// multi-edit patches drive `shift_walk`. States alternate between two
2390 /// distinct values so a mismapped payload (not just a wrong row) is caught.
2391 #[test]
2392 fn checkpoint_tree_equals_the_vec_reference_under_random_edits() {
2393 let (st_a, st_b) = (fresh_state(), str_state());
2394 assert!(st_a != st_b, "seed states must differ for the state check to bite");
2395 let mut rng = 0x1234_5678u32;
2396 let mut next = || {
2397 rng ^= rng << 13;
2398 rng ^= rng >> 17;
2399 rng ^= rng << 5;
2400 rng
2401 };
2402 for trial in 0..200 {
2403 // A fresh random ascending, distinct checkpoint set per trial.
2404 let mut n_lines = 400u32 + next() % 4_000;
2405 let k = 4 + next() % 60;
2406 let mut rows: Vec<u32> = Vec::new();
2407 let mut r = next() % 40;
2408 for _ in 0..k {
2409 if r >= n_lines {
2410 break;
2411 }
2412 rows.push(r);
2413 r += 1 + next() % 90;
2414 }
2415 let seed = |i: usize| if i.is_multiple_of(2) { st_a.clone() } else { st_b.clone() };
2416 let init: Vec<(u32, Box<LineState>)> =
2417 rows.iter().enumerate().map(|(i, &r)| (r, Box::new(seed(i)))).collect();
2418 let mut tree = {
2419 let items = init.iter().scan(0u32, |prev, (r, s)| {
2420 let gap = r - *prev;
2421 *prev = *r;
2422 Some(CkptItem { row_gap: gap, state: s.clone() })
2423 });
2424 Checkpoints { tree: SumTree::from_items(items) }
2425 };
2426 let mut reference = init;
2427
2428 for step in 0..12 {
2429 // A valid ascending, disjoint patch of 1..=3 edits (mixes the paths).
2430 let n_edits = 1 + (next() % 3) as usize;
2431 let mut spans: Vec<(u32, u32, u32)> = Vec::new();
2432 let mut cursor = 0u32;
2433 for _ in 0..n_edits {
2434 if cursor >= n_lines {
2435 break;
2436 }
2437 let span = (n_lines - cursor).clamp(1, 120);
2438 let s = cursor + next() % span;
2439 if s >= n_lines {
2440 break;
2441 }
2442 let o = (next() % 5).min(n_lines - s);
2443 let nw = next() % 5;
2444 spans.push((s, o, nw));
2445 cursor = s + o + 1; // keep replaced spans disjoint + ascending
2446 }
2447 if spans.is_empty() {
2448 continue;
2449 }
2450 let delta: i64 = spans.iter().map(|&(_, o, n)| i64::from(n) - i64::from(o)).sum();
2451
2452 tree.shift(&spans);
2453 vec_shift_reference(&mut reference, &spans);
2454 n_lines = (n_lines as i64 + delta) as u32;
2455
2456 let got = tree.rows();
2457 let want: Vec<u32> = reference.iter().map(|(r, _)| *r).collect();
2458 assert_eq!(got, want, "trial {trial} step {step}: rows diverge for {spans:?}");
2459 let states_ok = tree
2460 .tree
2461 .items()
2462 .iter()
2463 .zip(&reference)
2464 .all(|(it, (_, s))| *it.state == **s);
2465 assert!(states_ok, "trial {trial} step {step}: a checkpoint state was mismapped");
2466 }
2467 }
2468 }
2469
2470 /// PERF CELL: one 1-line keystroke's checkpoint shift is independent of the
2471 /// checkpoint count. A flat-Vec walk would charge K (take + rebuild every
2472 /// commit and undo step); the windowed seam touches O(1). FLIP-VERIFY:
2473 /// force `shift` down the `shift_walk` fallback (change its dispatch to
2474 /// `if false`) and this trips — the meter reads ≈K → 2K instead of a flat 1.
2475 #[test]
2476 fn checkpoint_shift_is_checkpoint_count_independent() {
2477 let st = fresh_state();
2478 let meter = |k: u32| -> u64 {
2479 // K stride checkpoints; the edit (row 5, 1→1) sits far below the first
2480 // (row 255), so the windowed shift drops nothing and shifts nothing.
2481 let rows: Vec<u32> =
2482 (0..k).map(|i| (i + 1) * HIGHLIGHT_CHECKPOINT_STRIDE - 1).collect();
2483 let mut cps = Checkpoints::from_rows(&rows, &st);
2484 crate::perf::reset();
2485 cps.shift(&[(5, 1, 1)]);
2486 crate::perf::meter()
2487 };
2488 let (small, big) = (meter(1000), meter(2000));
2489 eprintln!("[perf_gate] checkpoint shift charge {small:>7} -> {big:>7}");
2490 assert!(
2491 big <= small + small / 4 + 256,
2492 "checkpoint shift charged {small} -> {big}: it walked every checkpoint, not the seam",
2493 );
2494 }
2495
2496 /// A collapsed mega-fold makes the widget report a visible range spanning
2497 /// the fold's hidden interior — the retention window must CAP its length,
2498 /// or a fold re-grows retention to O(document).
2499 #[test]
2500 fn window_length_is_capped() {
2501 let lines: Vec<String> = (0..200_000).map(|i| format!("kw {i}")).collect();
2502 let mut c = cache(200_000);
2503 c.set_window(0..150_000); // a viewport straddling a huge collapsed fold
2504 drive_all(&mut c, &lines);
2505 assert!(
2506 c.retained_span_rows() <= HIGHLIGHT_MAX_WINDOW_ROWS as usize,
2507 "the window must be capped: {} rows retained",
2508 c.retained_span_rows()
2509 );
2510 }
2511
2512 #[test]
2513 fn in_window_commit_with_a_document_scale_span_stays_bounded() {
2514 // A scattered multi-caret transaction folds into ONE covering line span
2515 // (first caret .. last caret), so on_commit's in-window branch sees a
2516 // document-scale `new` even though the window is small. The dense splice
2517 // must stay bounded — splicing `vec![None; new]` would regrow `win` to
2518 // O(new), an O(document) per-keystroke allocation.
2519 let mut c = cache(200_000);
2520 c.set_window(100..140); // small window near the top; the first caret is in it
2521 assert!(c.window_len() <= HIGHLIGHT_MAX_WINDOW_ROWS as usize);
2522 // The covering splice of an edit spanning rows 100..150_000 (top+bottom
2523 // carets, one char each → +150k covering lines, say).
2524 c.on_commit(100, 1, 150_000);
2525 assert!(
2526 c.window_len() <= HIGHLIGHT_MAX_WINDOW_ROWS as usize,
2527 "in-window covering splice regrew the window to {} rows",
2528 c.window_len()
2529 );
2530 // The dirty set and line count still track the (over-wide) splice, so
2531 // the covered rows are refilled correctly on the sweep.
2532 assert_eq!(c.line_count(), 200_000 + 150_000 - 1);
2533 }
2534
2535 // ------------------------------------------------------------------
2536 // Off-thread bulk tokenization: parallel sweep + viewport speculation.
2537 // All under the STATEFUL GRAMMAR_STR so a wrong guess (a cut inside a
2538 // multi-line string) produces genuinely different colors that the stitch
2539 // must repair - the whole point.
2540 // ------------------------------------------------------------------
2541
2542 fn engine_str() -> HighlightEngine {
2543 HighlightEngine::new(
2544 SyntaxDef::from_sublime_syntax(GRAMMAR_STR).unwrap(),
2545 TokenTheme::from_tm_theme(THEME).unwrap(),
2546 )
2547 }
2548
2549 fn snap(lines: &[String]) -> Snapshot {
2550 crate::buffer::Buffer::new(&lines.join("\n")).unwrap().snapshot()
2551 }
2552
2553 fn drive_all_snap(c: &mut HighlightCache, s: &Snapshot) {
2554 while c.pending().is_some() {
2555 c.tokenize_until(u32::MAX, HIGHLIGHT_MAX_LINES_PER_CALL, |r| s.line(r));
2556 }
2557 }
2558
2559 /// Simulate the app's coordinator: tokenize every segment Fresh (as the
2560 /// worker pool would, order-independently), then stitch left to right -
2561 /// re-running (with the early-stop `converge_against`) any segment whose
2562 /// Fresh guess mismatched the true prior end. Returns the verified chain
2563 /// and how many segments needed a re-run.
2564 fn parallel_verify(
2565 engine: &HighlightEngine,
2566 snapshot: &Snapshot,
2567 cuts: &[u32],
2568 spans_for: Option<Range<u32>>,
2569 ) -> (Vec<SegmentTokens>, usize) {
2570 let n = snapshot.line_count();
2571 let mut bounds = vec![0u32];
2572 bounds.extend(cuts.iter().copied().filter(|&c| c > 0 && c < n));
2573 bounds.sort_unstable();
2574 bounds.dedup();
2575 bounds.push(n);
2576 // Pass 1 - all Fresh (the parallel workers; order irrelevant).
2577 let segs: Vec<SegmentTokens> = bounds
2578 .windows(2)
2579 .map(|w| {
2580 tokenize_segment(engine, snapshot, w[0]..w[1], SegmentStart::Fresh, spans_for.clone(), None)
2581 })
2582 .collect();
2583 // Pass 2 - sequential stitch.
2584 let fresh = engine.fresh_boundary();
2585 let mut verified: Vec<SegmentTokens> = Vec::with_capacity(segs.len());
2586 let mut prev_end: Option<SegmentBoundary> = None;
2587 let mut reruns = 0;
2588 for (idx, seg) in segs.into_iter().enumerate() {
2589 let corrected = if idx == 0 {
2590 seg // Fresh at row 0 is the true start
2591 } else {
2592 let true_start = prev_end.clone().unwrap();
2593 if true_start == fresh {
2594 seg // the Fresh guess happened to be right
2595 } else {
2596 reruns += 1;
2597 let rows = seg.rows();
2598 tokenize_segment(
2599 engine,
2600 snapshot,
2601 rows,
2602 SegmentStart::After(true_start),
2603 spans_for.clone(),
2604 Some(&seg),
2605 )
2606 }
2607 };
2608 prev_end = Some(corrected.end_boundary().clone());
2609 verified.push(corrected);
2610 }
2611 (verified, reruns)
2612 }
2613
2614 /// THE correctness spine (property test): under random stateful documents
2615 /// and random segment cuts - including cuts deliberately inside multi-line
2616 /// strings - the parallel-verified + absorbed cache must equal a
2617 /// whole-document `Highlighter::highlight` on every row, and its checkpoint
2618 /// rows must match a sync-swept cache. Fails against any stitch that does
2619 /// not repair a wrong-guessed boundary.
2620 #[test]
2621 fn parallel_stitch_equals_the_whole_document_oracle() {
2622 let engine = engine_str();
2623 let mut state = 0x1234_5678_9ABC_DEF0_u64;
2624 let mut rng = move |bound: usize| {
2625 state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
2626 ((state >> 33) as usize) % bound.max(1)
2627 };
2628 for round in 0..24 {
2629 let n = 200 + rng(1_200);
2630 let lines: Vec<String> = (0..n)
2631 .map(|i| match rng(11) {
2632 0 => "\"".to_string(), // open a multi-line string
2633 1 => "text\" kw".to_string(), // close it, then a keyword
2634 2 => "\" and \" kw".to_string(),
2635 _ => format!("kw word {i}"),
2636 })
2637 .collect();
2638 let s = snap(&lines);
2639 let ln = s.line_count();
2640 // 1-6 random cuts, some likely inside string regions.
2641 let cuts: Vec<u32> = (0..1 + rng(6)).map(|_| rng(ln as usize) as u32).collect();
2642 let win_start = rng(ln as usize) as u32;
2643 let win = win_start..(win_start + 40).min(ln);
2644
2645 let (verified, _reruns) = parallel_verify(&engine, &s, &cuts, None);
2646 let mut c = cache_str(ln);
2647 c.set_window(win.clone());
2648 for seg in verified {
2649 c.absorb(seg, true);
2650 }
2651 assert_eq!(c.first_dirty(), None, "round {round}: verified sweep must clear all dirt");
2652 // Spans-less segments left window gaps - the sync refill (from the
2653 // absorbed checkpoints) fills them; then every window row is exact.
2654 drive_all_snap(&mut c, &s);
2655 let full = highlighter_str().highlight(&lines.join("\n"));
2656 for r in win.clone() {
2657 assert_eq!(c.line_spans(r), Some(full[r as usize].as_slice()), "round {round} row {r}");
2658 }
2659 // Checkpoint ROWS must match a purely sync sweep (the absorb path
2660 // may not fabricate off-grid checkpoints the sync path lacks).
2661 let mut sync = cache_str(ln);
2662 sync.set_window(win.clone());
2663 drive_all(&mut sync, &lines);
2664 let got: Vec<u32> = c.ret.checkpoints.rows();
2665 let want: Vec<u32> = sync.ret.checkpoints.rows();
2666 assert_eq!(got, want, "round {round}: checkpoint rows diverge from a sync sweep");
2667 }
2668 }
2669
2670 /// A wrong-guessed boundary (a cut deep inside a block comment) repairs in
2671 /// O(distance to the comment's close), NOT O(segment): the re-run's
2672 /// early-stop splices the proven-identical tail, tokenizing only up to one
2673 /// stride past the closing line. (Uses the self-healing GRAMMAR_CMT — a
2674 /// symmetric-toggle string never re-converges, so it can't exercise the
2675 /// splice; that is the degenerate case tested separately.)
2676 #[test]
2677 fn wrong_guess_repair_stops_within_a_stride() {
2678 let engine = engine_cmt();
2679 // A comment opens at row 100 and closes at row 300; the rest is plain.
2680 // A cut at 1500 is far PAST the comment, so both the fresh guess and
2681 // the truth are in the ground state there — to force a genuine
2682 // mis-guess, cut at 200 (inside the comment).
2683 let mut lines: Vec<String> = (0..8_000).map(|i| format!("kw word {i}")).collect();
2684 lines[100] = "/*".to_string();
2685 lines[300] = "*/ kw".to_string();
2686 let s = snap(&lines);
2687 // The tail segment 200..8000, guessed Fresh (ground) — wrong, row 200
2688 // is inside the comment.
2689 let seg_rows = 200u32..8_000;
2690 let guessed = tokenize_segment(&engine, &s, seg_rows.clone(), SegmentStart::Fresh, None, None);
2691 let truth_prefix = tokenize_segment(&engine, &s, 0..200, SegmentStart::Fresh, None, None);
2692 let true_start = SegmentStart::After(truth_prefix.end_boundary().clone());
2693 let repaired = tokenize_segment(&engine, &s, seg_rows, true_start, None, Some(&guessed));
2694 // Converged: the tail was spliced, so far fewer than 7800 rows ran.
2695 // The comment closes at 300; convergence is detected at the first
2696 // stride checkpoint past it, so tokenized ≤ (300 − 200) + one stride.
2697 let bound = (300 - 200) + HIGHLIGHT_CHECKPOINT_STRIDE;
2698 assert!(
2699 repaired.tokenized_rows() <= bound,
2700 "repair tokenized {} rows (bound {bound}) — early-stop did not fire",
2701 repaired.tokenized_rows()
2702 );
2703 assert!(repaired.tokenized_rows() < 7_800, "a full re-run would tokenize the whole segment");
2704 // …and it is byte-identical to a full correct re-run.
2705 let full_rerun =
2706 tokenize_segment(&engine, &s, 200..8_000, SegmentStart::After(truth_prefix.end_boundary().clone()), None, None);
2707 assert!(repaired.end_boundary() == full_rerun.end_boundary(), "spliced tail must be correct");
2708 // End-to-end oracle around the close, where the guess was wrong.
2709 let (verified, reruns) = parallel_verify(&engine, &s, &[200], None);
2710 assert_eq!(reruns, 1, "the in-comment cut must trigger exactly one re-run");
2711 let mut c = cache_cmt(8_000);
2712 c.set_window(190..600);
2713 for v in verified {
2714 c.absorb(v, true);
2715 }
2716 drive_all_snap(&mut c, &s);
2717 let full = highlighter_cmt().highlight(&lines.join("\n"));
2718 for r in 190..600u32 {
2719 assert_eq!(c.line_spans(r), Some(full[r as usize].as_slice()), "row {r}");
2720 }
2721 }
2722
2723 /// A converging re-run whose `spans_for` DIFFERS from the old segment's
2724 /// window (the coordinator dispatched the re-run after the viewport moved)
2725 /// must stay internally consistent — the window is forced to the old
2726 /// segment's, so the spliced tail lines up. Were the moved `spans_for`
2727 /// honored instead, the splice would write misaligned spans as clean
2728 /// (viewport poison) or trip the window-equality assertion.
2729 #[test]
2730 fn converging_rerun_ignores_a_moved_spans_for() {
2731 let engine = engine_cmt();
2732 let mut lines: Vec<String> = (0..4_000).map(|i| format!("kw word {i}")).collect();
2733 lines[100] = "/*".to_string();
2734 lines[300] = "*/ kw".to_string();
2735 let s = snap(&lines);
2736 let seg_rows = 200u32..2_000;
2737 let old_window = Some(210u32..250); // the window when the guess was made
2738 let guessed =
2739 tokenize_segment(&engine, &s, seg_rows.clone(), SegmentStart::Fresh, old_window.clone(), None);
2740 let prefix = tokenize_segment(&engine, &s, 0..200, SegmentStart::Fresh, None, None);
2741 let after = || SegmentStart::After(prefix.end_boundary().clone());
2742 // Re-run with a MOVED window (1500..1540 — the viewport scrolled).
2743 let moved = tokenize_segment(&engine, &s, seg_rows.clone(), after(), Some(1_500..1_540), Some(&guessed));
2744 // …and a control re-run whose spans_for already matches the old window.
2745 let matched = tokenize_segment(&engine, &s, seg_rows, after(), old_window, Some(&guessed));
2746 // The moved re-run behaves as if it reused the old window: same tail,
2747 // same window range, same end — no panic, no misalignment.
2748 assert!(moved.end_boundary() == matched.end_boundary(), "same converged end");
2749 assert_eq!(moved.rows, matched.rows);
2750 assert_eq!(moved.win, matched.win, "window forced to the old segment's");
2751 assert_eq!(moved.win_spans.len(), moved.win.len(), "spans line up with the window");
2752 assert_eq!(moved.win_states.len(), moved.win.len());
2753 // And absorbing it colours the old window's rows correctly.
2754 let full = highlighter_cmt().highlight(&lines.join("\n"));
2755 let mut c = cache_cmt(4_000);
2756 c.set_window(210..250);
2757 c.absorb(prefix, true);
2758 c.absorb(moved, true);
2759 drive_all_snap(&mut c, &s);
2760 for r in 210..250u32 {
2761 assert_eq!(c.line_spans(r), Some(full[r as usize].as_slice()), "row {r}");
2762 }
2763 }
2764
2765 /// Degenerate honesty: a document that is one never-closed multi-line
2766 /// string makes every boundary mismatch (full sequential re-run) — the
2767 /// result is still byte-identical to the oracle.
2768 #[test]
2769 fn one_giant_string_falls_back_to_sequential_but_stays_correct() {
2770 let engine = engine_str();
2771 let mut lines: Vec<String> = (0..2_000).map(|i| format!("body {i}")).collect();
2772 lines[0] = "\"".to_string(); // opened, never closed
2773 let s = snap(&lines);
2774 let (verified, reruns) = parallel_verify(&engine, &s, &[400, 800, 1_200, 1_600], None);
2775 assert_eq!(reruns, 4, "every mid-string boundary mismatches");
2776 let mut c = cache_str(2_000);
2777 c.set_window(900..980);
2778 for v in verified {
2779 c.absorb(v, true);
2780 }
2781 drive_all_snap(&mut c, &s);
2782 let full = highlighter_str().highlight(&lines.join("\n"));
2783 for r in 900..980u32 {
2784 assert_eq!(c.line_spans(r), Some(full[r as usize].as_slice()), "row {r}");
2785 }
2786 }
2787
2788 /// Verified absorb clears the segment's rows from the frontier and the
2789 /// spans it carries (spans_for) render immediately - no sync walk needed.
2790 #[test]
2791 fn verified_absorb_clears_dirt_and_shows_spans() {
2792 let engine = engine_str();
2793 let lines: Vec<String> = (0..2_000).map(|i| format!("kw word {i}")).collect();
2794 let s = snap(&lines);
2795 let win = 900u32..940;
2796 // One verified segment covering the viewport, carrying its spans.
2797 let seg = tokenize_segment(&engine, &s, 0..2_000, SegmentStart::Fresh, Some(win.clone()), None);
2798 let mut c = cache_str(2_000);
2799 c.set_window(win.clone());
2800 c.absorb(seg, true);
2801 assert_eq!(c.first_dirty(), None, "verified absorb clears the whole frontier");
2802 // The requested spans render immediately — no sync walk. (The window
2803 // pads ±SLACK beyond `spans_for`, so pending() may still report a
2804 // slack gap; that is the sync refill's cheap job, tested elsewhere.)
2805 let full = highlighter_str().highlight(&lines.join("\n"));
2806 for r in win {
2807 assert_eq!(c.line_spans(r), Some(full[r as usize].as_slice()), "row {r}");
2808 }
2809 }
2810
2811 /// Speculative absorb (a GUESSED fresh start) shows spans in the window
2812 /// immediately, but plants NO checkpoints (unverified states would poison
2813 /// warm-ups) and leaves the rows dirty (the frontier re-verifies them).
2814 #[test]
2815 fn speculative_absorb_shows_spans_without_checkpoints_or_clearing_dirt() {
2816 let engine = engine_str();
2817 let lines: Vec<String> = (0..2_000).map(|i| format!("kw word {i}")).collect();
2818 let s = snap(&lines);
2819 let win = 900u32..940;
2820 // A viewport-first speculative segment: back off 128 rows, Fresh guess.
2821 let seg = tokenize_segment(&engine, &s, 772..940, SegmentStart::Fresh, Some(win.clone()), None);
2822 let mut c = cache_str(2_000);
2823 c.set_window(win.clone());
2824 c.absorb(seg, false);
2825 // Spans are visible now (the guess is right here - no multi-line state).
2826 let full = highlighter_str().highlight(&lines.join("\n"));
2827 for r in win.clone() {
2828 assert_eq!(c.line_spans(r), Some(full[r as usize].as_slice()), "speculative row {r}");
2829 }
2830 // But nothing is verified: the frontier is untouched and no checkpoint
2831 // was planted (retained_state_rows counts window states + checkpoints;
2832 // the window states are the harmless per-line probes).
2833 assert_eq!(c.first_dirty(), Some(0), "speculation must not clear dirt");
2834 assert!(c.ret.checkpoints.is_empty(), "speculation must not plant checkpoints");
2835 }
2836
2837 /// End to end: speculative colors first, then the frontier's dirty walk
2838 /// reaches the window and CONVERGES in O(1) against the correct
2839 /// speculative state (the guess was right) - the whole window consumed by
2840 /// tokenizing a single row.
2841 #[test]
2842 fn speculative_then_frontier_converges_o1_when_the_guess_was_right() {
2843 let engine = engine_str();
2844 let lines: Vec<String> = (0..2_000).map(|i| format!("kw word {i}")).collect();
2845 let s = snap(&lines);
2846 let win = 900u32..940;
2847 let seg = tokenize_segment(&engine, &s, 772..940, SegmentStart::Fresh, Some(win.clone()), None);
2848 let mut c = cache_str(2_000);
2849 c.set_window(win.clone());
2850 c.absorb(seg, false);
2851 // Now drive the frontier from row 0 (as the idle sweep would). When it
2852 // reaches the window, each row's freshly-tokenized state matches the
2853 // stored speculative state, so it converges immediately and the whole
2854 // window's spans are proven correct.
2855 drive_all_snap(&mut c, &s);
2856 assert_eq!(c.first_dirty(), None);
2857 let full = highlighter_str().highlight(&lines.join("\n"));
2858 for r in win {
2859 assert_eq!(c.line_spans(r), Some(full[r as usize].as_slice()), "row {r}");
2860 }
2861 }
2862
2863 /// The poison guard: a WRONG speculative span (guessed fresh inside a
2864 /// multi-line comment) must never survive as clean when a verified
2865 /// spans-less segment clears its dirt. Verified absorb evicts it; the sync
2866 /// refill (from the correct checkpoints) repaints it right.
2867 #[test]
2868 fn verified_absorb_evicts_wrong_speculative_spans_no_poison() {
2869 let engine = engine_cmt();
2870 let mut lines: Vec<String> = (0..4_000).map(|i| format!("kw word {i}")).collect();
2871 lines[100] = "/*".to_string(); // a comment that stays open…
2872 lines[3_000] = "*/ kw".to_string(); // …until here
2873 let s = snap(&lines);
2874 let win = 1_500u32..1_540; // deep inside the comment
2875 let full = highlighter_cmt().highlight(&lines.join("\n"));
2876 let mut c = cache_cmt(4_000);
2877 c.set_window(win.clone());
2878 // Speculate from a FRESH (ground) start — WRONG here, the truth is
2879 // in-comment.
2880 let spec = tokenize_segment(&engine, &s, 1_400..1_540, SegmentStart::Fresh, Some(win.clone()), None);
2881 c.absorb(spec, false);
2882 assert_ne!(
2883 c.line_spans(1_520),
2884 Some(full[1_520].as_slice()),
2885 "the speculative guess must actually be wrong here (else the test proves nothing)"
2886 );
2887 // The correct verified sweep (one segment, Fresh at row 0 = true).
2888 let (verified, _) = parallel_verify(&engine, &s, &[], None);
2889 for v in verified {
2890 c.absorb(v, true);
2891 }
2892 // The wrong speculative span is GONE — evicted, not left clean.
2893 assert_eq!(c.line_spans(1_520), None, "wrong speculative spans must be evicted, never trusted");
2894 // …and the sync refill repaints the window correctly.
2895 drive_all_snap(&mut c, &s);
2896 for r in win {
2897 assert_eq!(c.line_spans(r), Some(full[r as usize].as_slice()), "row {r} corrected");
2898 }
2899 }
2900
2901 #[test]
2902 fn dirty_ranges_shift_merge_unit() {
2903 let mut d = DirtyRanges::default();
2904 d.insert(5);
2905 d.insert(7);
2906 d.insert(6); // bridges 5..6 and 7..8 into one run
2907 assert_eq!(d.0, vec![5..8]);
2908 d.insert(4); // extends left
2909 assert_eq!(d.0, vec![4..8]);
2910 // Splice via a single covering span: rows [5,7) became 3 rows → tail
2911 // shifts by +1 (apply_splices reproduces the classic commit splice).
2912 d.apply_splices(&[(5, 2, 3)]);
2913 assert_eq!(d.0, vec![4..9], "clip + shift + new-block merge into one run");
2914 // A pure delete ahead of the run shifts it left.
2915 d.apply_splices(&[(0, 2, 0)]);
2916 assert_eq!(d.0, vec![2..7]);
2917 // Front consumption.
2918 assert_eq!(d.first(), Some(2));
2919 d.remove_first(2);
2920 assert_eq!(d.0, vec![3..7]);
2921 }
2922}