Skip to main content

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