Skip to main content

scrive_core/
decorations.rs

1//! Tracked-range decorations: the one store, one mover for diagnostics,
2//! snippet stops, auto-close regions, and find matches.
3//!
4//! Diagnostics, snippet placeholders, and find matches are ONE mechanism — a
5//! [`TrackedRange`] carrying a [`DecorationKind`] and a [`Stickiness`]. The
6//! store holds bare current-revision byte offsets and is moved *eagerly* by
7//! [`DecorationStore::apply_patch`] on every commit, so a decoration's offsets
8//! are always "now" — there is no cross-revision anchor wrapper to translate
9//! through, because the positions are re-based the instant the text changes.
10//! The store is a **delta-gap interval `SumTree`** (`DecoItem` = gap + len; the
11//! relative-coordinate `DecoSummary` carries a rebasing `max_end`): windowed
12//! queries are an O(log n) interval descent, and the eager mover shifts a whole
13//! suffix in O(edit-window + log n) by rewriting one seam gap — never the
14//! O(store) per-commit rescan a flat `Vec` would force at find scale (10k
15//! matches).
16//!
17//! Stickiness is a named pair of [`Bias`]es, one per boundary marker ("sticks
18//! to the previous char?"); the mover threads each endpoint through
19//! [`Patch::map_offset`] with that boundary's bias and never inverts — a range
20//! may collapse to empty, but its start never passes its end.
21//!
22//! One file, one owner: this module owns the store and its mover only. The
23//! squiggle *drawing* (`squiggle_vertices`, `SquiggleContext`,
24//! `default_squiggle_style`) is the iced layer's concern and lives in
25//! `scrive-iced/src/squiggle.rs` — nothing here touches pixels or colors. The
26//! store's methods live on a standalone [`DecorationStore`] so this module is
27//! self-contained; `Document` wires the store in and drives its mover.
28
29use std::ops::Range;
30use std::sync::Arc;
31
32use crate::buffer::Buffer;
33use crate::coords::Bias;
34use crate::patch::Patch;
35use crate::sum_tree::{Dimension, Item, Summary, SumTree};
36
37// Op-count canary: full-store `sort()`s on this thread. A document-scale test
38// asserts a plain keystroke with a large decoration set (find matches /
39// diagnostics) does NOT re-sort the whole store — the windowed mover keeps
40// order without sorting, and `add_sorted_batch` skips an empty batch. Debug/
41// test only.
42#[cfg(any(test, debug_assertions))]
43thread_local! {
44    pub(crate) static DECORATION_SORTS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
45}
46
47// Visit canary: how many decoration items the read/query paths touch on this
48// thread. Incremented once per item visited in `decorations_in` (pushed hits),
49// `decoration_range` (whole-store id scan), and `decoitems_to_ranges`/`to_vec`
50// (band/whole materialization). The visit-count tests read it to prove a read
51// stays sublinear even where the work-meter charges ~0 (a root-summary read like
52// `find_count`, or a bounded own-store scan). It is a separate mechanism from the
53// `crate::perf` work-meter, which deliberately skips the band materialization the
54// windowed mover rides; this canary counts it. Debug/test only.
55#[cfg(any(test, debug_assertions))]
56thread_local! {
57    pub(crate) static DECORATION_VISITS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
58}
59
60/// Bump the visit canary by one item (debug/test only; erased in release).
61#[inline]
62fn count_visit() {
63    #[cfg(any(test, debug_assertions))]
64    DECORATION_VISITS.with(|c| c.set(c.get() + 1));
65}
66
67/// Diagnostic severity. `Ord` is the render order: squiggles draw in ascending
68/// order so the most severe paints last and wins.
69#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
70pub enum Severity {
71    /// Faint advisory (unused import, style nudge). Lowest render priority.
72    Hint = 0,
73    /// Informational note.
74    Info = 1,
75    /// Something suspect but not fatal.
76    Warning = 2,
77    /// A hard error. Highest render priority.
78    Error = 3,
79}
80
81/// The four stickiness modes, stored as what they are: a named pair of
82/// [`Bias`]es, one per boundary marker.
83///
84/// The mode names describe how the range reacts to an insertion *at one of its
85/// edges*: `AlwaysGrows` swallows text inserted at either edge, `NeverGrows`
86/// rejects both, and the two `GrowsOnly*` modes swallow at exactly one edge.
87/// Typing strictly *inside* a range always grows it regardless of mode —
88/// stickiness only decides the edges.
89#[derive(Copy, Clone, PartialEq, Eq, Debug)]
90pub enum Stickiness {
91    /// Grows at both edges: `(Left, Right)`. Active snippet placeholder.
92    AlwaysGrows,
93    /// Grows at neither edge: `(Right, Left)`. Diagnostics, find matches,
94    /// inactive snippet stops.
95    NeverGrows,
96    /// Grows only at its start: `(Left, Left)`. Caret at end-of-line.
97    GrowsOnlyBefore,
98    /// Grows only at its end: `(Right, Right)`. A trailing caret.
99    GrowsOnlyAfter,
100}
101
102impl Stickiness {
103    /// The `(start_bias, end_bias)` pair — the entire semantic content of the
104    /// enum.
105    #[must_use]
106    pub fn biases(self) -> (Bias, Bias) {
107        match self {
108            Self::AlwaysGrows => (Bias::Left, Bias::Right),
109            Self::NeverGrows => (Bias::Right, Bias::Left),
110            Self::GrowsOnlyBefore => (Bias::Left, Bias::Left),
111            Self::GrowsOnlyAfter => (Bias::Right, Bias::Right),
112        }
113    }
114}
115
116/// What a tracked range *is*. The variant carries every distinction the render
117/// path, a diagnostics panel, and a future hover need directly — no separate
118/// owner-id or filter machinery alongside it.
119#[non_exhaustive]
120#[derive(Clone, Debug)]
121pub enum DecorationKind {
122    /// A compiler finding. `severity`, `message`, and `code` ride the store so
123    /// position and content stay reunited in one place; `code` is the
124    /// compiler's diagnostic code.
125    Diagnostic {
126        /// Render color / render-order key.
127        severity: Severity,
128        /// Human-readable message (shared, cheap to clone across frames).
129        message: Arc<str>,
130        /// The compiler's diagnostic code, if any (e.g. `E0425`).
131        code: Option<Arc<str>>,
132    },
133    /// A find/search hit. The find controller re-queries the whole match set on
134    /// every edit, so a collapsed hit is dropped rather than kept — its
135    /// empty-range policy is [`EmptyPolicy::Drop`].
136    FindMatch,
137    /// A snippet placeholder stop. Active-ness lives in the range's
138    /// [`Stickiness`], not here — the active stop is `AlwaysGrows`, the rest
139    /// `NeverGrows`.
140    SnippetStop {
141        /// Tab-order index of this stop within its session.
142        index: u8,
143    },
144    /// The provenance region of an auto-inserted closing pair.
145    AutoClosePair,
146}
147
148impl DecorationKind {
149    /// Post-commit policy for a range of this kind that has collapsed to empty:
150    /// [`FindMatch`](Self::FindMatch) is re-queried so it drops; everything else
151    /// keeps (the owner controls its lifetime).
152    #[must_use]
153    pub fn empty_policy(&self) -> EmptyPolicy {
154        match self {
155            Self::FindMatch => EmptyPolicy::Drop,
156            _ => EmptyPolicy::Keep,
157        }
158    }
159}
160
161/// What the mover does with a range that collapsed to empty on an edit.
162#[derive(Copy, Clone, PartialEq, Eq, Debug)]
163pub enum EmptyPolicy {
164    /// Keep the (now zero-width) range; its owner controls its lifetime and it
165    /// still renders (diagnostics at least one cell wide).
166    Keep,
167    /// Drop the range on collapse; a wholesale producer re-publishes it.
168    Drop,
169}
170
171/// A decoration's identity: monotonic, never reused within a store's lifetime.
172#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
173pub struct DecorationId(u64);
174
175/// One tracked range: a [`DecorationId`], a current-revision byte [`Range`], its
176/// [`DecorationKind`], and its [`Stickiness`].
177///
178/// Because the store is moved eagerly on every commit, `range` is always in
179/// "now" coordinates — no cross-revision `Anchor` wrapper is needed.
180#[derive(Clone, Debug)]
181pub struct TrackedRange {
182    /// Stable identity, minted at insertion.
183    pub id: DecorationId,
184    /// Current-revision byte offsets `[start, end)`.
185    pub range: Range<u32>,
186    /// What this decoration is (and its content, for diagnostics).
187    pub kind: DecorationKind,
188    /// How each endpoint reacts to an edit at its boundary.
189    pub stickiness: Stickiness,
190}
191
192/// One compiler finding as published by the app's debounced compile loop — the
193/// unified diagnostic type.
194///
195/// `span` is byte offsets into the revision-`N` snapshot the compile ran
196/// against; it is stamped with that revision at [`DecorationStore::set_diagnostics`]
197/// so a stale set is dropped rather than mis-placed. `#[non_exhaustive]` so
198/// future fields (related spans, fix-its) don't break construction sites.
199#[non_exhaustive]
200#[derive(Clone, Debug)]
201pub struct Diagnostic {
202    /// Byte span into the revision this diagnostic was computed against.
203    pub span: Range<u32>,
204    /// Severity — render color and render-order key.
205    pub severity: Severity,
206    /// Human-readable message.
207    pub message: String,
208    /// The compiler's diagnostic code, if any.
209    pub code: Option<String>,
210}
211
212impl Diagnostic {
213    /// A diagnostic with no code. Convenience for the common construction site.
214    #[must_use]
215    pub fn new(span: Range<u32>, severity: Severity, message: impl Into<String>) -> Self {
216        Self {
217            span,
218            severity,
219            message: message.into(),
220            code: None,
221        }
222    }
223}
224
225/// The result of publishing a diagnostic set.
226#[derive(Clone, PartialEq, Eq, Debug)]
227pub enum DiagnosticsOutcome {
228    /// The set was current: squiggle decorations were replaced wholesale.
229    Applied {
230        /// How many diagnostics were installed.
231        count: usize,
232    },
233    /// The set's revision was not current — the whole set was dropped (no
234    /// patch-forwarding of stale sets); the previous decorations keep riding
235    /// edits via stickiness and the app waits for its next debounce.
236    Stale {
237        /// The store's current revision at drop time.
238        current: u64,
239    },
240}
241
242/// Private delta-gap `SumTree` leaf. `gap` = this range's start minus the previous
243/// item's start; `len` = end − start. The absolute start is the prefix sum of gaps
244/// (the [`StartDim`] dimension), end = start + len. **Deltas, not absolute
245/// offsets** — this is what lets [`DecorationStore::apply_patch`] shift a whole
246/// suffix by adjusting ONE seam gap instead of rewriting every item
247/// (O(edit-window + log n) per commit, not O(n)).
248#[derive(Clone, Debug)]
249struct DecoItem {
250    gap: u32,
251    len: u32,
252    id: DecorationId,
253    kind: DecorationKind,
254    stickiness: Stickiness,
255}
256
257/// Interval-tree summary in RELATIVE coordinates. `span` = Σ gaps (the subtree's
258/// width); `count`; `max_end` = the greatest item end **relative to the subtree
259/// base**, composed by rebasing the right child by the left's `span`. Because the
260/// interior gaps and the relative `max_end` are invariant under a uniform shift,
261/// a suffix moves in O(1) at the seam — the whole point.
262#[derive(Clone, Copy, Debug, Default)]
263struct DecoSummary {
264    span: u32,
265    count: u32,
266    max_end: u32,
267    /// Number of [`DecorationKind::FindMatch`] items in the subtree — the additive
268    /// monoid behind the O(1) `find_count`, the O(log) `find_rank_before`/
269    /// `nth_find` order-statistic seek (via `FindCountDim`), and the find lane
270    /// of the scrollbar overview. Shift-invariant (independent of `span`), so the
271    /// suffix reanchor and the windowed-mover oracle are untouched.
272    find_count: u32,
273    /// The greatest Diagnostic severity in the subtree, encoded `severity + 1`
274    /// (`1..=4`, `0` = no diagnostic) so the empty monoid is a clean `0`. A max,
275    /// so it folds a whole subtree in O(1) for the diagnostic lane of the overview
276    /// reduce. Shift-invariant like `find_count`.
277    sev_max: u8,
278}
279
280impl Summary for DecoSummary {
281    fn add_summary(&mut self, o: &Self) {
282        // Rebase the right subtree's max end by this (left) subtree's span, then
283        // take the max — the relative-coordinate interval composition.
284        self.max_end = self.max_end.max(self.span + o.max_end);
285        self.span += o.span;
286        self.count += o.count;
287        self.find_count += o.find_count; // additive monoid
288        self.sev_max = self.sev_max.max(o.sev_max); // max monoid, position-free
289    }
290}
291
292impl Item for DecoItem {
293    type Summary = DecoSummary;
294    fn summary(&self) -> DecoSummary {
295        DecoSummary {
296            span: self.gap,
297            count: 1,
298            max_end: self.gap + self.len,
299            find_count: u32::from(matches!(self.kind, DecorationKind::FindMatch)),
300            sev_max: match self.kind {
301                DecorationKind::Diagnostic { severity, .. } => severity as u8 + 1, // 1..=4
302                _ => 0,
303            },
304        }
305    }
306}
307
308/// Dimension: absolute start position — accumulates each item's `gap` (a prefix
309/// sum), so seeking `StartDim(p)` lands at the first item whose start ≥ p.
310#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
311struct StartDim(u32);
312impl Dimension<DecoSummary> for StartDim {
313    fn add_summary(&mut self, s: &DecoSummary) {
314        self.0 += s.span;
315    }
316}
317
318/// Dimension: item index — accumulates `count`. Splits are by *index* (not byte),
319/// because a delta-gap item is located at its start and a byte split would keep an
320/// item the edit moves on the wrong side (mirrors the bracket tree).
321#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
322struct CountDim(u32);
323impl Dimension<DecoSummary> for CountDim {
324    fn add_summary(&mut self, s: &DecoSummary) {
325        self.0 += s.count;
326    }
327}
328
329/// Dimension: cumulative find-match count — accumulates `find_count`. Seeking
330/// `FindCountDim(r)` lands directly on the item carrying the r-th find increment
331/// (its cumulative find-count first exceeds `r`), which is therefore the r-th
332/// [`DecorationKind::FindMatch`] itself — the order-statistic behind `nth_find`.
333#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
334struct FindCountDim(u32);
335impl Dimension<DecoSummary> for FindCountDim {
336    fn add_summary(&mut self, s: &DecoSummary) {
337        self.0 += s.find_count;
338    }
339}
340
341/// Delta-gap encode a `(start, id)`-sorted absolute list, the first gap relative to
342/// `base` (0 for a whole-store rebuild; the split point's absolute offset when
343/// re-encoding a middle band that `append`s onto a shared prefix). The rebuild
344/// handle for the producer mutations and the windowed mover.
345fn ranges_to_decoitems(v: Vec<TrackedRange>, base: u32) -> impl Iterator<Item = DecoItem> {
346    let mut prev = base;
347    v.into_iter().map(move |r| {
348        debug_assert!(r.range.start >= prev, "ranges_to_decoitems needs ascending starts");
349        let gap = r.range.start - prev;
350        prev = r.range.start;
351        DecoItem {
352            gap,
353            len: r.range.end - r.range.start,
354            id: r.id,
355            kind: r.kind,
356            stickiness: r.stickiness,
357        }
358    })
359}
360
361/// A delta-gap subtree's items as absolute tracked ranges, accumulating from `base`
362/// (the absolute start where this — possibly split-off — subtree begins).
363fn decoitems_to_ranges(tree: &SumTree<DecoItem>, base: u32) -> Vec<TrackedRange> {
364    let mut out = Vec::with_capacity(tree.summary().count as usize);
365    let mut start = base;
366    for it in tree.items() {
367        count_visit(); // canary: band / whole-store materialization
368        start += it.gap;
369        out.push(TrackedRange {
370            id: it.id,
371            range: start..start + it.len,
372            kind: it.kind,
373            stickiness: it.stickiness,
374        });
375    }
376    out
377}
378
379/// Map every range's endpoints through `patch` with its stickiness bias, never
380/// inverting (a collapse pins both ends to the mapped end — [`Patch::map_range`]'s
381/// rule), and drop the ranges that collapsed to empty whose kind re-publishes
382/// ([`EmptyPolicy::Drop`], i.e. find matches). Shared by the naive and windowed
383/// movers so their per-range semantics are identical by construction.
384fn remap_ranges(patch: &Patch, v: &mut Vec<TrackedRange>) {
385    let mut queries: Vec<(u32, Bias)> = Vec::with_capacity(v.len() * 2);
386    for r in v.iter() {
387        let (bs, be) = r.stickiness.biases();
388        queries.push((r.range.start, bs));
389        queries.push((r.range.end, be));
390    }
391    let mut mapped: Vec<u32> = Vec::new();
392    patch.map_many(&queries, &mut mapped);
393    for (i, r) in v.iter_mut().enumerate() {
394        let (ms, me) = (mapped[2 * i], mapped[2 * i + 1]);
395        r.range = ms.min(me)..me;
396    }
397    v.retain(|r| {
398        let collapsed = r.range.start == r.range.end;
399        !(collapsed && matches!(r.kind.empty_policy(), EmptyPolicy::Drop))
400    });
401}
402
403/// Re-anchor a split-off `zone_c` (decorations entirely after the edit) onto a
404/// rebuilt head: its first item's start becomes `old_start + delta`, while every
405/// later gap and length stays relative and unchanged — so the whole suffix shifts
406/// by rewriting ONE gap, O(log n). `k_hi` is that first item's overall index;
407/// `orig` is the pre-split tree (read only for the item's old absolute start);
408/// `prev_start` is the last start placed before it. Mirrors the bracket tree's
409/// `reanchor_suffix`.
410fn reanchor(
411    zone_c: &SumTree<DecoItem>,
412    k_hi: u32,
413    orig: &SumTree<DecoItem>,
414    delta: i64,
415    prev_start: u32,
416) -> SumTree<DecoItem> {
417    if zone_c.is_empty() {
418        return zone_c.clone();
419    }
420    let (item, _c, StartDim(s)) = orig
421        .seek::<CountDim, StartDim>(&CountDim(k_hi))
422        .expect("zone_c non-empty ⇒ a k_hi-th item exists");
423    let first_old_start = s + item.gap;
424    let new_gap = (i64::from(first_old_start) + delta - i64::from(prev_start)) as u32;
425    let fixed = DecoItem { gap: new_gap, ..item.clone() };
426    zone_c.replace(CountDim(0)..CountDim(1), std::iter::once(fixed))
427}
428
429/// The tracked-range decoration store: the one store, one mover.
430///
431/// Producers (snippet session, auto-close, find, the compile loop) add/remove/
432/// replace; the commit path calls [`apply_patch`](Self::apply_patch) exactly once
433/// per transaction, *before* change events fire, so every consumer only ever sees
434/// post-edit positions.
435#[derive(Clone, Debug)]
436pub struct DecorationStore {
437    /// Delta-gap interval `SumTree`, items in `(start, id)` order. Each node's
438    /// relative [`DecoSummary`] carries a rebasing `max_end`, so `decorations_in`
439    /// is an O(log n) [`SumTree::filter_visit`] interval descent and `apply_patch`
440    /// shifts a suffix in O(log n) at one seam. Producer mutations (add / remove /
441    /// diagnostics) still reconstruct → rebuild O(n) — they are not the hot path;
442    /// the per-keystroke mover is.
443    tree: SumTree<DecoItem>,
444    next_id: u64,
445    /// The text a [`Document`](crate::Document) lent this store when it handed
446    /// it to a host ([`Document::decorations_mut`](crate::Document::decorations_mut)):
447    /// while present, every door that stores a caller's range clips it through
448    /// the one range rule ([`Buffer::clip_range`]), so a host range can never
449    /// sit mid-char or past the end. Never stale: the text changes only through
450    /// a commit, and every commit runs [`apply_patch`](Self::apply_patch) — which
451    /// drops it — before anything else touches the store; the next
452    /// `decorations_mut` lends the new text. An O(1) rope clone that shares the
453    /// buffer's structure. A standalone store has none and installs ranges
454    /// verbatim.
455    lent: Option<LentText>,
456}
457
458/// The document text a store clips host ranges against (the store's `lent`
459/// field). A newtype only so `Debug` names the revision instead of dumping the
460/// text.
461#[derive(Clone)]
462struct LentText(Buffer);
463
464impl core::fmt::Debug for LentText {
465    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
466        f.debug_tuple("LentText").field(&self.0.revision()).finish()
467    }
468}
469
470impl Default for DecorationStore {
471    fn default() -> Self {
472        Self::new()
473    }
474}
475
476impl DecorationStore {
477    /// An empty store.
478    #[must_use]
479    pub fn new() -> Self {
480        Self { tree: SumTree::new(), next_id: 1, lent: None }
481    }
482
483    /// Lend the store the document's current text, so host ranges stored until
484    /// the next commit clip against it — called as the store is handed out.
485    pub(crate) fn lend_text(&mut self, buffer: &Buffer) {
486        self.lent = Some(LentText(buffer.clone()));
487    }
488
489    /// `range` clipped to the lent text, or as given when none is lent.
490    fn clip(&self, range: Range<u32>) -> Range<u32> {
491        match &self.lent {
492            Some(LentText(buffer)) => buffer.clip_range(range),
493            None => range,
494        }
495    }
496
497    /// Number of tracked ranges.
498    #[must_use]
499    pub fn len(&self) -> usize {
500        self.tree.summary().count as usize
501    }
502
503    /// Whether the store holds no ranges.
504    #[must_use]
505    pub fn is_empty(&self) -> bool {
506        self.tree.is_empty()
507    }
508
509    /// Insert one tracked range and return its fresh id. Producers: the snippet
510    /// session (stops), auto-close, and find.
511    ///
512    /// The caller supplies current-revision offsets. Reached through
513    /// [`Document::decorations_mut`](crate::Document::decorations_mut), the range
514    /// is clipped to the document's text by the one range rule (past-end ends
515    /// clamp, a non-empty range snaps outward to whole chars, an empty one to
516    /// its char's start); a standalone store, knowing no text, installs it
517    /// verbatim.
518    pub fn add_decoration(
519        &mut self,
520        range: Range<u32>,
521        kind: DecorationKind,
522        stickiness: Stickiness,
523    ) -> DecorationId {
524        let range = self.clip(range);
525        let id = self.mint();
526        let mut v = self.to_vec();
527        v.push(TrackedRange { id, range, kind, stickiness });
528        self.set_sorted(v);
529        id
530    }
531
532    /// Owner-driven removal (session end, auto-close invalidation): returns the
533    /// range if `id` was present, else `None`.
534    pub fn take_decoration(&mut self, id: DecorationId) -> Option<TrackedRange> {
535        let mut v = self.to_vec();
536        let i = v.iter().position(|r| r.id == id)?;
537        let taken = v.remove(i);
538        self.rebuild(v); // removal keeps sort order — rebuild, no re-sort
539        Some(taken)
540    }
541
542    /// The decoration's current byte range after the mover has ridden it across
543    /// edits, or `None` if the id is gone. The snippet session and find read a
544    /// stop's or match's live position back through this.
545    #[must_use]
546    pub fn decoration_range(&self, id: DecorationId) -> Option<Range<u32>> {
547        // Delta-gap items store no absolute range; visit in order (accumulating the
548        // start) and read off the match. O(n) — a cold path (snippet tab / find
549        // navigation), never per-keystroke.
550        let mut found = None;
551        self.tree.filter_visit::<StartDim, _, _>(
552            &|_, _| true,
553            &mut |it: &DecoItem, before: &StartDim| {
554                // Unpruned whole-store scan: charge and count EVERY item so the
555                // complexity gate and visit canary see this O(M) read. This is a
556                // cold path (snippet tab / find navigation), never per-keystroke.
557                crate::perf::charge(1);
558                count_visit();
559                if it.id == id {
560                    let start = before.0 + it.gap;
561                    found = Some(start..start + it.len);
562                }
563            },
564        );
565        found
566    }
567
568    /// Swap a range's stickiness — the snippet active-stop swap: `AlwaysGrows`
569    /// onto the newly active stop, `NeverGrows` onto the leaving one. Returns
570    /// `false` if the id is gone.
571    pub fn set_decoration_stickiness(&mut self, id: DecorationId, s: Stickiness) -> bool {
572        let mut v = self.to_vec();
573        match v.iter_mut().find(|r| r.id == id) {
574            Some(r) => {
575                r.stickiness = s;
576                self.rebuild(v); // start order unchanged — rebuild, no re-sort
577                true
578            }
579            None => false,
580        }
581    }
582
583    /// All ranges intersecting `range`, in ascending `(start, id)` order.
584    /// Touching counts, so a zero-width range on the boundary still renders.
585    ///
586    /// An O(log n + hits) interval descent: a subtree is entered only when it can
587    /// hold a touching range — some start ≤ `range.end` and some end ≥
588    /// `range.start`, read from the relative `max_end` in its summary — then an
589    /// exact per-item touch filter keeps only the real hits. In-order, so the
590    /// yield is ascending `(start, id)`.
591    pub fn decorations_in(&self, range: Range<u32>) -> impl Iterator<Item = TrackedRange> {
592        let (qs, qe) = (range.start, range.end);
593        // Interval descent (O(log n + hits)): enter a subtree only if some start ≤ qe
594        // (`before ≤ qe`, since every start ≥ the base) AND some end ≥ qs (absolute
595        // max end = `before + max_end`), then the exact per-item touch filter.
596        // In-order, so ascending `(start, id)`. The range is *computed* from the
597        // delta-gaps (nothing absolute is stored to borrow), so the yield is owned.
598        let mut out: Vec<TrackedRange> = Vec::new();
599        self.tree.filter_visit::<StartDim, _, _>(
600            &|before: &StartDim, sum: &DecoSummary| {
601                before.0 <= qe && before.0 + sum.max_end >= qs
602            },
603            &mut |it: &DecoItem, before: &StartDim| {
604                let start = before.0 + it.gap;
605                let end = start + it.len;
606                if start <= qe && end >= qs {
607                    // Charge/count per HIT: a whole-store `decorations_in(0..MAX)`
608                    // charges O(M), while a windowed query charges only its
609                    // handful of hits.
610                    crate::perf::charge(1);
611                    count_visit();
612                    out.push(TrackedRange {
613                        id: it.id,
614                        range: start..end,
615                        kind: it.kind.clone(),
616                        stickiness: it.stickiness,
617                    });
618                }
619            },
620        );
621        out.into_iter()
622    }
623
624    /// How many [`DecorationKind::FindMatch`] ranges the store holds — an O(1)
625    /// read of the root summary, not a whole-store walk.
626    #[must_use]
627    pub fn find_count(&self) -> usize {
628        self.tree.summary().find_count as usize
629    }
630
631    /// How many find matches start strictly before byte offset `off` — the rank
632    /// (0-based document-order index) of the first find at or after `off`. O(log n)
633    /// prefix fold; find navigation uses it to number the current match.
634    #[must_use]
635    pub fn find_rank_before(&self, off: u32) -> usize {
636        self.tree.summary_before(&StartDim(off)).find_count as usize
637    }
638
639    /// The r-th find match in `(start, id)` order, or `None` when `r >=
640    /// find_count()`. O(log n): `FindCountDim` seek lands directly on the item
641    /// carrying the r-th find increment — since only find matches contribute to
642    /// that dimension, the landed item IS the r-th find (a `debug_assert` pins it).
643    #[must_use]
644    pub fn nth_find(&self, r: usize) -> Option<(DecorationId, Range<u32>)> {
645        if r >= self.find_count() {
646            return None;
647        }
648        let (it, _fc, StartDim(base)) =
649            self.tree.seek::<FindCountDim, StartDim>(&FindCountDim(r as u32))?;
650        debug_assert!(
651            matches!(it.kind, DecorationKind::FindMatch),
652            "FindCountDim seek must land on the r-th FindMatch (only finds carry the dimension)"
653        );
654        let start = base + it.gap;
655        Some((it.id, start..start + it.len))
656    }
657
658    /// Insert an ascending, disjoint `spans` batch confined to a small window into
659    /// its index band WITHOUT rematerializing the store — the windowed sibling of
660    /// [`add_sorted_batch`](Self::add_sorted_batch) for the per-keystroke find repair. O(band + batch +
661    /// log n), mirroring [`take_matching_in`](Self::take_matching_in): only the
662    /// existing items whose start falls in `[spans.first().start,
663    /// spans.last().start]` sit in the touched band; everything outside is SHARED
664    /// (`Arc` clone) and the suffix re-relativizes at one delta-gap seam.
665    ///
666    /// Ids are minted in span order and exceed every existing id, so a tie at a
667    /// shared start places the new item AFTER the existing one — byte-identical to
668    /// `set_sorted`. Insertion moves no bytes, so the suffix
669    /// `reanchor` runs with `delta = 0`. Spans clip as
670    /// [`add_decoration`](Self::add_decoration)'s do (the clip is monotone, so
671    /// the batch stays ascending).
672    pub fn splice_sorted_batch(
673        &mut self,
674        spans: &[Range<u32>],
675        kind: DecorationKind,
676        stickiness: Stickiness,
677    ) -> Vec<DecorationId> {
678        if spans.is_empty() {
679            return Vec::new();
680        }
681        let mut batch: Vec<TrackedRange> = Vec::with_capacity(spans.len());
682        let mut ids = Vec::with_capacity(spans.len());
683        for span in spans {
684            let id = self.mint();
685            ids.push(id);
686            let range = self.clip(span.clone());
687            batch.push(TrackedRange { id, range, kind: kind.clone(), stickiness });
688        }
689        // The touched band: existing items whose start ∈ [lo, hi] (every batch
690        // start lies here, so the batch merges entirely into it).
691        let lo = batch.first().expect("non-empty").range.start;
692        let hi = batch.last().expect("non-empty").range.start;
693        let k_lo = self.tree.summary_before(&StartDim(lo)).count; // start < lo
694        let k_hi = self.tree.summary_before(&StartDim(hi.saturating_add(1))).count; // start ≤ hi
695
696        let (left, rest) = self.tree.split_at(&CountDim(k_lo));
697        let (middle, zone_c) = rest.split_at(&CountDim(k_hi - k_lo));
698        let left_span = left.extent::<StartDim>().0; // absolute start where `middle` begins
699
700        // Merge the batch into the band by (start, id) — both ascending; new ids
701        // exceed all existing so ties order new-after-old, matching `set_sorted`.
702        let mut merged = decoitems_to_ranges(&middle, left_span);
703        merged.extend(batch);
704        merged.sort_by(|a, b| a.range.start.cmp(&b.range.start).then(a.id.cmp(&b.id)));
705        let middle_new = SumTree::from_items(ranges_to_decoitems(merged, left_span));
706
707        // Positions are unchanged (insertion moves no bytes), so delta = 0: zone_c's
708        // first gap just re-relativizes onto the new last band item.
709        let head = left.append(&middle_new);
710        let prev_start = head.extent::<StartDim>().0;
711        let zone_c_new = reanchor(&zone_c, k_hi, &self.tree, 0, prev_start);
712        self.tree = head.append(&zone_c_new);
713        ids
714    }
715
716    /// Per bucket `b` in `0..bounds.len()-1` (ascending offset bounds): the max
717    /// Diagnostic severity among the diagnostics *starting* in `[bounds[b],
718    /// bounds[b+1])` (encoded `severity + 1`, `1..=4`) and the start offset of the
719    /// FIRST severest one there — `(0, 0)` when the bucket holds no diagnostic.
720    /// The scrollbar-overview diagnostic lane: O(P + log M) to fold per-bucket max
721    /// severity, then O(log M) per non-empty bucket to fetch the winning offset,
722    /// so it never scans every diagnostic per frame.
723    pub fn diagnostic_overview(&self, bounds: &[u32], out: &mut Vec<(u8, u32)>) {
724        out.clear();
725        // Phase 1 — per-bucket max severity by summary fold (whole subtrees O(1)).
726        let start_bounds: Vec<StartDim> = bounds.iter().map(|&b| StartDim(b)).collect();
727        let sev = self.tree.bucketed_reduce(&start_bounds, 0u8, |acc: &mut u8, s: &DecoSummary| {
728            *acc = (*acc).max(s.sev_max);
729        });
730        // Phase 2 — the winner's offset (needed to reproduce the exact draw-y).
731        for (b, &max_sev) in sev.iter().enumerate() {
732            if max_sev == 0 {
733                out.push((0, 0));
734            } else {
735                let off = self
736                    .first_start_with_severity(bounds[b], bounds[b + 1], max_sev)
737                    .unwrap_or(bounds[b]);
738                out.push((max_sev, off));
739            }
740        }
741    }
742
743    /// Per bucket: the start offset of the first [`DecorationKind::FindMatch`]
744    /// starting in `[bounds[b], bounds[b+1])`, or `None`. The overview's find
745    /// lane: O(P·log M) via `find_rank_before`/`nth_find`, no whole-store walk.
746    pub fn find_overview(&self, bounds: &[u32], out: &mut Vec<Option<u32>>) {
747        out.clear();
748        for w in bounds.windows(2) {
749            let (lo, hi) = (w[0], w[1]);
750            let r = self.find_rank_before(lo); // first find with start ≥ lo
751            let first = self.nth_find(r).map(|(_, rng)| rng.start).filter(|&s| s < hi);
752            out.push(first);
753        }
754    }
755
756    /// The start offset of the first diagnostic in `[lo, hi)` whose encoded
757    /// severity equals `target` — the overview's phase-2 winner lookup. A pruned
758    /// [`SumTree::filter_visit`] descent (enter only subtrees whose start-range
759    /// overlaps `[lo, hi)` and whose `sev_max >= target`), so O(log M + few); this
760    /// is a per-frame read, so it charges neither the work-meter nor the canary.
761    fn first_start_with_severity(&self, lo: u32, hi: u32, target: u8) -> Option<u32> {
762        let mut found: Option<u32> = None;
763        self.tree.filter_visit::<StartDim, _, _>(
764            &|before: &StartDim, sum: &DecoSummary| {
765                before.0 < hi && before.0 + sum.span >= lo && sum.sev_max >= target
766            },
767            &mut |it: &DecoItem, before: &StartDim| {
768                if found.is_some() {
769                    return; // in-order visit: the first hit is THE first severest
770                }
771                let start = before.0 + it.gap;
772                if start >= lo && start < hi {
773                    let sev = match it.kind {
774                        DecorationKind::Diagnostic { severity, .. } => severity as u8 + 1,
775                        _ => 0,
776                    };
777                    if sev == target {
778                        found = Some(start);
779                    }
780                }
781            },
782        );
783        found
784    }
785
786    /// Batch-insert `spans` (ascending, one `kind`/`stickiness`) and return
787    /// the minted ids, in span order — ONE sort-and-rebuild for the whole batch
788    /// instead of one per span, so installing a 10k-match find set is a single
789    /// sort rather than one per match. The find re-scan installs its whole match
790    /// set through this. Spans clip as
791    /// [`add_decoration`](Self::add_decoration)'s do.
792    pub fn add_sorted_batch(
793        &mut self,
794        spans: impl IntoIterator<Item = Range<u32>>,
795        kind: DecorationKind,
796        stickiness: Stickiness,
797    ) -> Vec<DecorationId> {
798        let mut v = self.to_vec();
799        let mut ids = Vec::new();
800        for range in spans {
801            let range = self.clip(range);
802            let id = self.mint();
803            v.push(TrackedRange { id, range, kind: kind.clone(), stickiness });
804            ids.push(id);
805        }
806        if !ids.is_empty() {
807            self.set_sorted(v); // an empty batch (a find re-scan that found nothing) skips it
808        }
809        ids
810    }
811
812    /// Remove — and return, in store order — every range intersecting `window`
813    /// that `pred` accepts. Find's per-commit repair drives this with a small
814    /// window, so it is **windowed**: only the decorations that can touch `window`
815    /// sit in the index band `[k_lo, k_hi)` (starts in `[wl, qe]`, `wl` the leftmost
816    /// touching start), and everything outside is SHARED — a decoration with start
817    /// < wl has end < qs so it cannot touch, and start > qe cannot touch either.
818    /// Removal keeps positions, so the suffix re-relativizes at one seam
819    /// (`reanchor` with delta = 0). **O(band + log n)**, not O(store).
820    ///
821    /// A whole-store `window` (a find rescan / `clear_autoclose`) makes the band the
822    /// whole store — O(store) — but those callers are debounced or early-out, not
823    /// the per-keystroke path.
824    pub fn take_matching_in(
825        &mut self,
826        window: Range<u32>,
827        mut pred: impl FnMut(&TrackedRange) -> bool,
828    ) -> Vec<TrackedRange> {
829        let (qs, qe) = (window.start, window.end);
830        let Some(wl) = self.decorations_in(qs..qe).map(|r| r.range.start).min() else {
831            return Vec::new(); // nothing touches ⇒ nothing removed, tree untouched
832        };
833        let k_hi = self.tree.summary_before(&StartDim(qe.saturating_add(1))).count; // start ≤ qe
834        let k_lo = self.tree.summary_before(&StartDim(wl)).count; // start < wl
835
836        let (left, rest) = self.tree.split_at(&CountDim(k_lo));
837        let (middle, zone_c) = rest.split_at(&CountDim(k_hi - k_lo));
838        let left_span = left.extent::<StartDim>().0;
839
840        let mut removed = Vec::new();
841        let mut survivors = Vec::new();
842        for r in decoitems_to_ranges(&middle, left_span) {
843            let touches = r.range.start <= qe && r.range.end >= qs;
844            if touches && pred(&r) {
845                removed.push(r);
846            } else {
847                survivors.push(r); // ascending order preserved
848            }
849        }
850        if removed.is_empty() {
851            return removed; // matched nothing in the band ⇒ leave the tree as-is
852        }
853        // Positions are unchanged (removal only), so delta = 0: zone_c's first gap
854        // just re-relativizes onto the last surviving band item.
855        let middle_new = SumTree::from_items(ranges_to_decoitems(survivors, left_span));
856        let head = left.append(&middle_new);
857        let prev_start = head.extent::<StartDim>().0;
858        let zone_c_new = reanchor(&zone_c, k_hi, &self.tree, 0, prev_start);
859        self.tree = head.append(&zone_c_new);
860        removed
861    }
862
863    /// Every tracked range, in ascending `(start, id)` order — the render path's
864    /// full-store view before it intersects with the visible rows.
865    pub fn iter(&self) -> impl Iterator<Item = TrackedRange> {
866        self.to_vec().into_iter()
867    }
868
869    /// The eager mover: ride every tracked range across `patch`.
870    ///
871    /// Each endpoint threads through [`Patch::map_offset`] with its stickiness
872    /// bias (start with the start-bias, end with the end-bias); the range never
873    /// inverts (a collapse pins both to the mapped end, via
874    /// [`Patch::map_range`]). Ranges that collapsed to empty whose kind's
875    /// [`empty_policy`](DecorationKind::empty_policy) is [`EmptyPolicy::Drop`]
876    /// are removed, and the store is re-sorted (ties can only reorder at a
877    /// shared boundary).
878    ///
879    /// `Patch` already folds a multi-edit transaction into final post-edit
880    /// coordinates, so one `map_range` per range applies the whole transaction
881    /// at once — no back-to-front bookkeeping here (that lives in the patch).
882    pub fn apply_patch(&mut self, patch: &Patch) {
883        // The text moved on: a lent copy no longer indexes it (see `lent`).
884        self.lent = None;
885        if patch.is_empty() {
886            return;
887        }
888        // A single edit (the overwhelmingly common keystroke) takes the windowed
889        // structural mover: remap only the O(edit-window) decorations the edit can
890        // touch and shift the ENTIRE suffix in O(log) by adjusting one delta-gap
891        // seam — O(window + log n), not O(n). A multi-edit (multi-caret) patch
892        // falls back to the naive whole-store remap: rare with a large decoration
893        // set (you don't usually multi-cursor with 10k find matches live), and
894        // correctness-first. Both paths are pinned identical by the oracle test
895        // `windowed_apply_patch_equals_naive_under_random_single_edits`.
896        match patch.edits() {
897            [edit] => self.apply_single_edit(patch, edit),
898            _ => self.apply_patch_naive(patch),
899        }
900    }
901
902    /// The naive O(n) mover: materialize the whole store, remap every range, drop
903    /// collapsed find matches, re-encode. The reference the windowed path equals,
904    /// and the fallback for multi-edit patches.
905    fn apply_patch_naive(&mut self, patch: &Patch) {
906        let mut v = self.to_vec();
907        remap_ranges(patch, &mut v);
908        // `map_offset(_, Right)` is NOT monotonic (a start at a replacement's start
909        // maps past an interior one — see FoldSet), so starts CAN reorder. Re-sort
910        // only when they actually did (the common case rebuilds without the sort).
911        if v.windows(2).any(|w| (w[0].range.start, w[0].id) > (w[1].range.start, w[1].id)) {
912            self.set_sorted(v);
913        } else {
914            self.rebuild(v);
915        }
916    }
917
918    /// The windowed single-edit mover. Partition the store, by *index*, into three
919    /// bands relative to the edit `old = os..oe`:
920    ///
921    /// - **left** — every decoration whose start is `< wl`, where `wl` is the
922    ///   leftmost start of any decoration touching `[os, oe]`. Each has `end < os`
923    ///   (else it would touch, so its start ≥ wl), so both endpoints are fixed
924    ///   points of the edit's map — **shared verbatim**, no work.
925    /// - **middle** — starts in `[wl, oe]`: the touched decorations plus any
926    ///   straddler reaching in from the left, plus untouched decorations interleaved
927    ///   between them (their remap is a no-op). Remapped in absolute coords. `O(window)`.
928    /// - **zone_c** — starts `> oe`: entirely after the edit, so a uniform
929    ///   `+delta` shift. Delta-gap makes that **one seam-gap adjustment** — every
930    ///   interior gap and length is relative and unchanged. `O(log n)`.
931    ///
932    /// Only the middle is retained for the [`EmptyPolicy::Drop`] collapse rule, and
933    /// that is complete: a Drop-kind range collapses only when the edit deletes its
934    /// interior, which makes it *touch* the edit — so it is in the middle. The store
935    /// never holds a collapsed Drop-kind range (it is never added empty and is
936    /// dropped on the edit that collapses it), so left/zone_c need no retain pass.
937    fn apply_single_edit(&mut self, patch: &Patch, edit: &crate::patch::Edit) {
938        let (os, oe) = (edit.old.start, edit.old.end);
939        let delta = (i64::from(edit.new.end) - i64::from(edit.new.start))
940            - (i64::from(oe) - i64::from(os));
941
942        // Index band [k_lo, k_hi) = the middle. `wl` extends it left over straddlers.
943        let wl = self.decorations_in(os..oe).map(|r| r.range.start).min();
944        let k_hi = self.tree.summary_before(&StartDim(oe.saturating_add(1))).count; // start ≤ oe
945        let k_lo = match wl {
946            Some(wl) => self.tree.summary_before(&StartDim(wl)).count, // start < wl
947            None => k_hi, // nothing touches ⇒ empty middle (only a zone_c shift, if any)
948        };
949
950        let (left, rest) = self.tree.split_at(&CountDim(k_lo));
951        let (middle, zone_c) = rest.split_at(&CountDim(k_hi - k_lo));
952        let left_span = left.extent::<StartDim>().0; // absolute start where `middle` begins
953
954        // Remap the middle (absolute), drop collapsed find matches, re-sort — a
955        // single edit can reorder starts only inside its own span — delta-gap
956        // re-encode with the first gap relative to `left`.
957        let mut mv = decoitems_to_ranges(&middle, left_span);
958        remap_ranges(patch, &mut mv);
959        mv.sort_by(|a, b| a.range.start.cmp(&b.range.start).then(a.id.cmp(&b.id)));
960        let middle_new = SumTree::from_items(ranges_to_decoitems(mv, left_span));
961
962        // Reanchor zone_c onto the rebuilt head. `prev_start` is the last start
963        // before zone_c; zone_c's first item's start becomes `old_start + delta`.
964        let head = left.append(&middle_new);
965        let prev_start = head.extent::<StartDim>().0;
966        let zone_c_new = reanchor(&zone_c, k_hi, &self.tree, delta, prev_start);
967        self.tree = head.append(&zone_c_new);
968    }
969
970    /// Publish a diagnostic set against the revision it was computed on.
971    ///
972    /// 1. **Revision gate**: `revision != current_revision` → return
973    ///    [`DiagnosticsOutcome::Stale`] having touched nothing; the previous
974    ///    decorations keep riding edits via stickiness, so squiggles stay glued
975    ///    to their code until the next set arrives.
976    /// 2. **Wholesale replace**: remove every existing
977    ///    [`DecorationKind::Diagnostic`] range and install the new set with
978    ///    [`Stickiness::NeverGrows`]; severity, message, and code ride the kind
979    ///    variant. Non-diagnostic decorations (snippet stops, find matches,
980    ///    auto-close) are untouched.
981    ///
982    /// Spans clip as [`add_decoration`](Self::add_decoration)'s do: to the
983    /// document's text when reached through
984    /// [`Document::decorations_mut`](crate::Document::decorations_mut) or
985    /// [`Document::set_diagnostics`](crate::Document::set_diagnostics), verbatim
986    /// in a standalone store.
987    pub fn set_diagnostics(
988        &mut self,
989        revision: u64,
990        current_revision: u64,
991        diags: Vec<Diagnostic>,
992    ) -> DiagnosticsOutcome {
993        if revision != current_revision {
994            return DiagnosticsOutcome::Stale {
995                current: current_revision,
996            };
997        }
998        let mut v = self.to_vec();
999        v.retain(|r| !matches!(r.kind, DecorationKind::Diagnostic { .. }));
1000        let count = diags.len();
1001        for d in diags {
1002            let id = self.mint();
1003            v.push(TrackedRange {
1004                id,
1005                range: self.clip(d.span),
1006                kind: DecorationKind::Diagnostic {
1007                    severity: d.severity,
1008                    message: Arc::from(d.message),
1009                    code: d.code.map(Arc::from),
1010                },
1011                stickiness: Stickiness::NeverGrows,
1012            });
1013        }
1014        self.set_sorted(v);
1015        DiagnosticsOutcome::Applied { count }
1016    }
1017
1018    /// Mint the next id and bump the monotonic counter.
1019    fn mint(&mut self) -> DecorationId {
1020        let id = DecorationId(self.next_id);
1021        self.next_id += 1;
1022        id
1023    }
1024
1025    /// Materialize the ranges in store order (accumulating the delta-gaps into
1026    /// absolute offsets) — the reconstruct handle for the O(n) producer mutations
1027    /// that reuse the pre-tree Vec logic (add / diagnostics / the naive mover).
1028    fn to_vec(&self) -> Vec<TrackedRange> {
1029        decoitems_to_ranges(&self.tree, 0)
1030    }
1031
1032    /// Rebuild the tree from `v`, restoring the `(start, id)` order (ids are
1033    /// unique, so it is total). The sort also repairs the reorder an edit can cause
1034    /// at a shared boundary; use [`rebuild`](Self::rebuild) when order is already
1035    /// intact (a removal / a stickiness swap).
1036    fn set_sorted(&mut self, mut v: Vec<TrackedRange>) {
1037        #[cfg(any(test, debug_assertions))]
1038        DECORATION_SORTS.with(|c| c.set(c.get() + 1));
1039        crate::perf::charge(v.len() as u64); // complexity gate: whole-store sort
1040        v.sort_by(|a, b| a.range.start.cmp(&b.range.start).then(a.id.cmp(&b.id)));
1041        self.tree = SumTree::from_items(ranges_to_decoitems(v, 0));
1042    }
1043
1044    /// Rebuild the tree from an already-`(start, id)`-ordered `v` (no re-sort).
1045    fn rebuild(&mut self, v: Vec<TrackedRange>) {
1046        crate::perf::charge(v.len() as u64); // complexity gate: whole-store pass
1047        self.tree = SumTree::from_items(ranges_to_decoitems(v, 0));
1048    }
1049}
1050
1051#[cfg(test)]
1052mod tests {
1053    use super::*;
1054    use crate::coords::Bias::{Left, Right};
1055    use crate::patch::Edit;
1056
1057    fn store() -> DecorationStore {
1058        DecorationStore::new()
1059    }
1060
1061    #[test]
1062    fn windowed_query_equals_the_linear_oracle() {
1063        // A linear filter, kept verbatim as the oracle that the interval-descent
1064        // decorations_in must match.
1065        let linear = |s: &DecorationStore, qs: u32, qe: u32| -> Vec<u64> {
1066            s.iter()
1067                .filter(|r| r.range.start <= qe && r.range.end >= qs)
1068                .map(|r| r.id.0)
1069                .collect()
1070        };
1071        // Seeded pseudo-random ranges (incl. zero-width and long spanners).
1072        let mut rng = 0x9E3779B97F4A7C15u64;
1073        let mut next = move || {
1074            rng ^= rng << 13;
1075            rng ^= rng >> 7;
1076            rng ^= rng << 17;
1077            rng
1078        };
1079        let mut s = store();
1080        for _ in 0..300 {
1081            let a = (next() % 10_000) as u32;
1082            let len = (next() % 50) as u32 * if next() % 4 == 0 { 40 } else { 1 };
1083            s.add_decoration(a..a + len, DecorationKind::FindMatch, Stickiness::NeverGrows);
1084        }
1085        for _ in 0..200 {
1086            let qs = (next() % 11_000) as u32;
1087            let qe = qs + (next() % 400) as u32;
1088            let fast: Vec<u64> = s.decorations_in(qs..qe).map(|r| r.id.0).collect();
1089            assert_eq!(fast, linear(&s, qs, qe), "window {qs}..{qe} diverged from the oracle");
1090        }
1091        // After removals the bounds must stay exact (the prefix max tracks).
1092        for _ in 0..50 {
1093            let victim = DecorationId((next() % 300) + 1);
1094            let _ = s.take_decoration(victim);
1095        }
1096        for _ in 0..100 {
1097            let qs = (next() % 11_000) as u32;
1098            let qe = qs + (next() % 400) as u32;
1099            let fast: Vec<u64> = s.decorations_in(qs..qe).map(|r| r.id.0).collect();
1100            assert_eq!(fast, linear(&s, qs, qe));
1101        }
1102    }
1103
1104    #[test]
1105    fn batch_add_and_windowed_take() {
1106        let mut s = store();
1107        s.add_decoration(5..8, DecorationKind::SnippetStop { index: 0 }, Stickiness::AlwaysGrows);
1108        let ids =
1109            s.add_sorted_batch([0..2, 10..12, 20..22], DecorationKind::FindMatch, Stickiness::NeverGrows);
1110        assert_eq!(ids.len(), 3);
1111        // Store order is (start, id) regardless of insertion order.
1112        let starts: Vec<u32> = s.iter().map(|r| r.range.start).collect();
1113        assert_eq!(starts, vec![0, 5, 10, 20]);
1114        // Windowed take removes only matching kinds inside the window…
1115        let removed = s.take_matching_in(4..15, |r| matches!(r.kind, DecorationKind::FindMatch));
1116        assert_eq!(removed.len(), 1);
1117        assert_eq!(removed[0].range, 10..12);
1118        // …the snippet stop in-window and the match outside both survive.
1119        let left: Vec<u32> = s.iter().map(|r| r.range.start).collect();
1120        assert_eq!(left, vec![0, 5, 20]);
1121        // And queries stay exact after the batch removal.
1122        assert_eq!(s.decorations_in(0..u32::MAX).count(), 3);
1123    }
1124
1125    fn ins(at: u32, len: u32) -> Patch {
1126        Patch::single(Edit { old: at..at, new: at..at + len })
1127    }
1128
1129    fn del(start: u32, end: u32) -> Patch {
1130        Patch::single(Edit { old: start..end, new: start..start })
1131    }
1132
1133    // Apply `patch` to a single `stickiness` decoration over 4..8 and read back
1134    // its moved range. `AutoClosePair` keeps on collapse, so delete cases don't
1135    // vanish from under the test.
1136    fn moved(stickiness: Stickiness, patch: &Patch) -> Range<u32> {
1137        let mut s = store();
1138        let id = s.add_decoration(4..8, DecorationKind::AutoClosePair, stickiness);
1139        s.apply_patch(patch);
1140        s.decoration_range(id).unwrap()
1141    }
1142
1143    #[test]
1144    fn biases_are_the_four_named_pairs() {
1145        assert_eq!(Stickiness::AlwaysGrows.biases(), (Left, Right));
1146        assert_eq!(Stickiness::NeverGrows.biases(), (Right, Left));
1147        assert_eq!(Stickiness::GrowsOnlyBefore.biases(), (Left, Left));
1148        assert_eq!(Stickiness::GrowsOnlyAfter.biases(), (Right, Right));
1149    }
1150
1151    #[test]
1152    fn severity_orders_hint_to_error() {
1153        assert!(Severity::Hint < Severity::Info);
1154        assert!(Severity::Info < Severity::Warning);
1155        assert!(Severity::Warning < Severity::Error);
1156    }
1157
1158    #[test]
1159    fn only_find_match_drops_on_collapse() {
1160        assert_eq!(DecorationKind::FindMatch.empty_policy(), EmptyPolicy::Drop);
1161        assert_eq!(DecorationKind::AutoClosePair.empty_policy(), EmptyPolicy::Keep);
1162        assert_eq!(
1163            DecorationKind::SnippetStop { index: 0 }.empty_policy(),
1164            EmptyPolicy::Keep
1165        );
1166        assert_eq!(
1167            DecorationKind::Diagnostic {
1168                severity: Severity::Error,
1169                message: Arc::from("x"),
1170                code: None,
1171            }
1172            .empty_policy(),
1173            EmptyPolicy::Keep
1174        );
1175    }
1176
1177    #[test]
1178    fn ids_are_monotonic_and_never_reused() {
1179        let mut s = store();
1180        let a = s.add_decoration(0..1, DecorationKind::AutoClosePair, Stickiness::NeverGrows);
1181        let b = s.add_decoration(2..3, DecorationKind::AutoClosePair, Stickiness::NeverGrows);
1182        assert!(a < b);
1183        // Removing `a` does not free its id: the next add is strictly greater.
1184        s.take_decoration(a);
1185        let c = s.add_decoration(0..1, DecorationKind::AutoClosePair, Stickiness::NeverGrows);
1186        assert!(c > b);
1187    }
1188
1189    #[test]
1190    fn take_returns_the_range_then_forgets_it() {
1191        let mut s = store();
1192        let id = s.add_decoration(3..5, DecorationKind::AutoClosePair, Stickiness::NeverGrows);
1193        let taken = s.take_decoration(id).unwrap();
1194        assert_eq!(taken.range, 3..5);
1195        assert_eq!(s.decoration_range(id), None);
1196        assert!(s.take_decoration(id).is_none()); // idempotent
1197    }
1198
1199    #[test]
1200    fn set_stickiness_swaps_or_reports_gone() {
1201        let mut s = store();
1202        let id = s.add_decoration(0..1, DecorationKind::SnippetStop { index: 0 }, Stickiness::NeverGrows);
1203        assert!(s.set_decoration_stickiness(id, Stickiness::AlwaysGrows));
1204        // The swap is observable through the mover: typing at the end now grows.
1205        s.apply_patch(&ins(1, 2));
1206        assert_eq!(s.decoration_range(id), Some(0..3));
1207        // A gone id reports false.
1208        s.take_decoration(id);
1209        assert!(!s.set_decoration_stickiness(id, Stickiness::NeverGrows));
1210    }
1211
1212    #[test]
1213    fn decorations_in_counts_touching() {
1214        let mut s = store();
1215        let a = s.add_decoration(0..2, DecorationKind::AutoClosePair, Stickiness::NeverGrows);
1216        let b = s.add_decoration(5..5, DecorationKind::AutoClosePair, Stickiness::NeverGrows); // zero-width
1217        let c = s.add_decoration(8..10, DecorationKind::AutoClosePair, Stickiness::NeverGrows);
1218        // Query 2..8 touches `a` at 2, includes the zero-width `b` at 5, and
1219        // touches `c` at 8.
1220        let got: Vec<_> = s.decorations_in(2..8).map(|r| r.id).collect();
1221        assert_eq!(got, vec![a, b, c]);
1222        // A query strictly before everything hits nothing.
1223        assert_eq!(s.decorations_in(20..21).count(), 0);
1224    }
1225
1226    #[test]
1227    fn decorations_in_is_ascending_by_start_then_id() {
1228        let mut s = store();
1229        // Insert out of order; the store sorts.
1230        s.add_decoration(9..9, DecorationKind::AutoClosePair, Stickiness::NeverGrows);
1231        s.add_decoration(1..2, DecorationKind::AutoClosePair, Stickiness::NeverGrows);
1232        s.add_decoration(1..1, DecorationKind::AutoClosePair, Stickiness::NeverGrows);
1233        let starts: Vec<_> = s.decorations_in(0..100).map(|r| r.range.start).collect();
1234        assert_eq!(starts, vec![1, 1, 9]);
1235        // The two at start 1 tie-break on ascending id (insertion order here).
1236        let ids: Vec<_> = s.decorations_in(0..2).map(|r| r.id).collect();
1237        assert!(ids[0] < ids[1]);
1238    }
1239
1240    // --- The stickiness table: 4 modes × 5 edit shapes. ---
1241
1242    #[test]
1243    fn table_insert_at_start() {
1244        let p = ins(4, 2); // insert 2 bytes at the start edge (offset 4)
1245        assert_eq!(moved(Stickiness::AlwaysGrows, &p), 4..10); // Left start swallows
1246        assert_eq!(moved(Stickiness::NeverGrows, &p), 6..10); // Right start rejects
1247        assert_eq!(moved(Stickiness::GrowsOnlyBefore, &p), 4..10);
1248        assert_eq!(moved(Stickiness::GrowsOnlyAfter, &p), 6..10);
1249    }
1250
1251    #[test]
1252    fn table_insert_at_end() {
1253        let p = ins(8, 2); // insert 2 bytes at the end edge (offset 8)
1254        assert_eq!(moved(Stickiness::AlwaysGrows, &p), 4..10); // Right end swallows
1255        assert_eq!(moved(Stickiness::NeverGrows, &p), 4..8); // Left end rejects
1256        assert_eq!(moved(Stickiness::GrowsOnlyBefore, &p), 4..8);
1257        assert_eq!(moved(Stickiness::GrowsOnlyAfter, &p), 4..10);
1258    }
1259
1260    #[test]
1261    fn table_insert_inside_always_grows() {
1262        let p = ins(6, 2); // insert strictly inside 4..8
1263        for m in [
1264            Stickiness::AlwaysGrows,
1265            Stickiness::NeverGrows,
1266            Stickiness::GrowsOnlyBefore,
1267            Stickiness::GrowsOnlyAfter,
1268        ] {
1269            assert_eq!(moved(m, &p), 4..10, "{m:?} must grow on interior insert");
1270        }
1271    }
1272
1273    #[test]
1274    fn table_delete_around_collapses_without_inverting() {
1275        let p = del(2, 10); // delete a range that fully contains 4..8
1276        for m in [
1277            Stickiness::AlwaysGrows,
1278            Stickiness::NeverGrows,
1279            Stickiness::GrowsOnlyBefore,
1280            Stickiness::GrowsOnlyAfter,
1281        ] {
1282            let r = moved(m, &p);
1283            assert!(r.start <= r.end, "{m:?} inverted: {r:?}");
1284            assert_eq!(r, 2..2, "{m:?} collapses to the mapped end");
1285        }
1286    }
1287
1288    #[test]
1289    fn table_delete_prefix_pins_start_and_shifts_end() {
1290        let p = del(4, 6); // delete the first half of 4..8
1291        for m in [
1292            Stickiness::AlwaysGrows,
1293            Stickiness::NeverGrows,
1294            Stickiness::GrowsOnlyBefore,
1295            Stickiness::GrowsOnlyAfter,
1296        ] {
1297            // A deletion never drags the start boundary right; the end shifts -2.
1298            assert_eq!(moved(m, &p), 4..6, "{m:?}");
1299        }
1300    }
1301
1302    #[test]
1303    fn edit_before_a_range_shifts_it_rigidly() {
1304        let p = ins(0, 3); // insert 3 bytes before everything
1305        assert_eq!(moved(Stickiness::NeverGrows, &p), 7..11);
1306    }
1307
1308    #[test]
1309    fn apply_patch_drops_collapsed_find_match_keeps_others() {
1310        let mut s = store();
1311        let fm = s.add_decoration(4..8, DecorationKind::FindMatch, Stickiness::NeverGrows);
1312        let diag = s.add_decoration(
1313            4..8,
1314            DecorationKind::Diagnostic {
1315                severity: Severity::Error,
1316                message: Arc::from("boom"),
1317                code: None,
1318            },
1319            Stickiness::NeverGrows,
1320        );
1321        s.apply_patch(&del(2, 10)); // collapse both to 2..2
1322        assert_eq!(s.decoration_range(fm), None, "find match dropped on collapse");
1323        assert_eq!(
1324            s.decoration_range(diag),
1325            Some(2..2),
1326            "diagnostic kept (owner lifetime), rendered ≥1 cell later"
1327        );
1328    }
1329
1330    #[test]
1331    fn empty_patch_leaves_the_store_untouched() {
1332        let mut s = store();
1333        let id = s.add_decoration(4..8, DecorationKind::FindMatch, Stickiness::NeverGrows);
1334        s.apply_patch(&Patch::new());
1335        assert_eq!(s.decoration_range(id), Some(4..8));
1336    }
1337
1338    #[test]
1339    fn apply_patch_reestablishes_sort_order() {
1340        let mut s = store();
1341        // `a` sits before `b`; a big insert at the front of `a` pushes it past
1342        // `b` only if it grows past it — here they just shift and keep order.
1343        let a = s.add_decoration(0..1, DecorationKind::AutoClosePair, Stickiness::NeverGrows);
1344        let b = s.add_decoration(5..6, DecorationKind::AutoClosePair, Stickiness::NeverGrows);
1345        s.apply_patch(&ins(2, 10)); // between a and b
1346        let order: Vec<_> = s.iter().map(|r| r.id).collect();
1347        assert_eq!(order, vec![a, b]);
1348        assert_eq!(s.decoration_range(a), Some(0..1));
1349        assert_eq!(s.decoration_range(b), Some(15..16));
1350    }
1351
1352    // --- Diagnostics ingestion. ---
1353
1354    #[test]
1355    fn set_diagnostics_replaces_wholesale() {
1356        let mut s = store();
1357        let outcome = s.set_diagnostics(
1358            0,
1359            0,
1360            vec![
1361                Diagnostic::new(0..3, Severity::Warning, "one"),
1362                Diagnostic::new(5..8, Severity::Error, "two"),
1363            ],
1364        );
1365        assert_eq!(outcome, DiagnosticsOutcome::Applied { count: 2 });
1366        assert_eq!(s.len(), 2);
1367        // A second publish replaces the first set entirely.
1368        let outcome = s.set_diagnostics(0, 0, vec![Diagnostic::new(1..2, Severity::Hint, "solo")]);
1369        assert_eq!(outcome, DiagnosticsOutcome::Applied { count: 1 });
1370        assert_eq!(s.len(), 1);
1371        let only = s.iter().next().unwrap();
1372        assert_eq!(only.range, 1..2);
1373        assert_eq!(only.stickiness, Stickiness::NeverGrows);
1374    }
1375
1376    #[test]
1377    fn set_diagnostics_stale_drops_and_keeps_previous() {
1378        let mut s = store();
1379        s.set_diagnostics(0, 0, vec![Diagnostic::new(0..3, Severity::Error, "kept")]);
1380        // Published against revision 0, but the store is now at revision 2.
1381        let outcome = s.set_diagnostics(0, 2, vec![Diagnostic::new(0..3, Severity::Info, "stale")]);
1382        assert_eq!(outcome, DiagnosticsOutcome::Stale { current: 2 });
1383        // The previous set survived untouched.
1384        assert_eq!(s.len(), 1);
1385        let kept = s.iter().next().unwrap().clone();
1386        match &kept.kind {
1387            DecorationKind::Diagnostic { message, severity, .. } => {
1388                assert_eq!(&**message, "kept");
1389                assert_eq!(*severity, Severity::Error);
1390            }
1391            other => panic!("expected a diagnostic, got {other:?}"),
1392        }
1393    }
1394
1395    #[test]
1396    fn set_diagnostics_leaves_other_kinds_alone() {
1397        let mut s = store();
1398        let stop = s.add_decoration(9..9, DecorationKind::SnippetStop { index: 0 }, Stickiness::AlwaysGrows);
1399        s.set_diagnostics(0, 0, vec![Diagnostic::new(0..3, Severity::Error, "e")]);
1400        // Re-publishing diagnostics wipes only diagnostics; the snippet stop stays.
1401        s.set_diagnostics(0, 0, vec![Diagnostic::new(1..2, Severity::Warning, "w")]);
1402        assert_eq!(s.decoration_range(stop), Some(9..9));
1403        let diag_count = s
1404            .iter()
1405            .filter(|r| matches!(r.kind, DecorationKind::Diagnostic { .. }))
1406            .count();
1407        assert_eq!(diag_count, 1);
1408    }
1409
1410    #[test]
1411    fn diagnostic_content_rides_the_kind() {
1412        let mut s = store();
1413        s.set_diagnostics(
1414            3,
1415            3,
1416            vec![Diagnostic {
1417                span: 0..4,
1418                severity: Severity::Warning,
1419                message: "unused".to_string(),
1420                code: Some("W0612".to_string()),
1421            }],
1422        );
1423        let got = s.iter().next().unwrap().clone();
1424        match &got.kind {
1425            DecorationKind::Diagnostic { severity, message, code } => {
1426                assert_eq!(*severity, Severity::Warning);
1427                assert_eq!(&**message, "unused");
1428                assert_eq!(code.as_deref(), Some("W0612"));
1429            }
1430            other => panic!("expected a diagnostic, got {other:?}"),
1431        }
1432    }
1433
1434    #[test]
1435    fn diagnostics_ride_edits_between_publishes() {
1436        // Continuity: a squiggle stays glued to its code as the user types above
1437        // it, until the next publish re-sets it.
1438        let mut s = store();
1439        s.set_diagnostics(0, 0, vec![Diagnostic::new(10..14, Severity::Error, "here")]);
1440        let id = s.iter().next().unwrap().id;
1441        s.apply_patch(&ins(0, 5)); // type 5 bytes at the top of the file
1442        assert_eq!(s.decoration_range(id), Some(15..19));
1443    }
1444
1445    #[test]
1446    fn windowed_apply_patch_equals_naive_under_random_single_edits() {
1447        // The windowed single-edit mover must produce byte-identical state to the
1448        // naive whole-store remap for EVERY store shape and single edit —
1449        // straddlers reaching in from the left, collapses, in-span reorders, an
1450        // empty middle, an empty zone_c, and edits before / inside / after the
1451        // decorations. xorshift-seeded, deep-compared over both survival and range.
1452        let mut rng = 0xD1CE_5EED_u64;
1453        let mut next = move || {
1454            rng ^= rng << 13;
1455            rng ^= rng >> 7;
1456            rng ^= rng << 17;
1457            rng
1458        };
1459        let kind_of = |t: u64| -> (DecorationKind, Stickiness) {
1460            match t % 4 {
1461                0 => (DecorationKind::FindMatch, Stickiness::NeverGrows), // drops on collapse
1462                1 => (DecorationKind::AutoClosePair, Stickiness::AlwaysGrows),
1463                2 => (DecorationKind::SnippetStop { index: 0 }, Stickiness::GrowsOnlyBefore),
1464                _ => (
1465                    DecorationKind::Diagnostic {
1466                        severity: Severity::Error,
1467                        message: Arc::from("x"),
1468                        code: None,
1469                    },
1470                    Stickiness::GrowsOnlyAfter,
1471                ),
1472            }
1473        };
1474        // Project to (id, start, end): the mover carries kind/stickiness unchanged,
1475        // so this captures every observable it can affect (survival + position).
1476        let proj = |s: &DecorationStore| -> Vec<(u64, u32, u32)> {
1477            s.iter().map(|r| (r.id.0, r.range.start, r.range.end)).collect()
1478        };
1479        for trial in 0..4000u32 {
1480            let mut windowed = store();
1481            let mut naive = store();
1482            let n = next() % 40;
1483            for _ in 0..n {
1484                let a = (next() % 120) as u32;
1485                let (kind, stick) = kind_of(next());
1486                let mut len = (next() % 25) as u32;
1487                // A Drop-kind (FindMatch) decoration is never zero-width in practice
1488                // (a needle has length ≥ 1) and the store never holds a *collapsed*
1489                // one — it is dropped on the edit that collapses it and never added
1490                // empty. The windowed mover leans on that invariant to skip the
1491                // untouched bands, so honor it here (a zero-width KEEP decoration —
1492                // a snippet stop / diagnostic point — is fine and still exercised).
1493                if matches!(kind, DecorationKind::FindMatch) && len == 0 {
1494                    len = 1;
1495                }
1496                // Identical op sequence ⇒ identical minted ids in both stores.
1497                windowed.add_decoration(a..a + len, kind.clone(), stick);
1498                naive.add_decoration(a..a + len, kind, stick);
1499            }
1500            // A single edit `old = os..oe` → `new = os..os+ins` (ns == os).
1501            let os = (next() % 120) as u32;
1502            let oe = os + (next() % 15) as u32;
1503            let ins = (next() % 15) as u32;
1504            let patch = Patch::single(Edit { old: os..oe, new: os..os + ins });
1505            windowed.apply_patch(&patch); // single edit ⇒ windowed path
1506            naive.apply_patch_naive(&patch); // the reference
1507            assert_eq!(
1508                proj(&windowed),
1509                proj(&naive),
1510                "trial {trial}: edit {os}..{oe} → +{ins}"
1511            );
1512        }
1513    }
1514
1515    #[test]
1516    fn windowed_apply_patch_is_sublinear_in_store_size() {
1517        use crate::sum_tree::NODE_ALLOCS;
1518        // The delta-gap payoff, pinned: a single-edit commit on a store with N find
1519        // matches must allocate O(edit-window + log N) tree nodes — NOT O(N). The
1520        // naive whole-store rebuild allocates ~N/B leaves + spine, so it reads ~4×
1521        // across a 4× store; the windowed mover stays ~flat (only log growth). This
1522        // cell trips the moment `apply_patch` reverts to a whole-store rebuild.
1523        let build = |n: u32| -> DecorationStore {
1524            let mut s = store();
1525            let spans: Vec<Range<u32>> = (0..n).map(|i| (i * 10)..(i * 10 + 3)).collect();
1526            s.add_sorted_batch(spans, DecorationKind::FindMatch, Stickiness::NeverGrows);
1527            s
1528        };
1529        let allocs_for = |n: u32| -> f64 {
1530            let mut s = build(n);
1531            // Insert near the FRONT: the whole N-match suffix must shift — the case
1532            // that is O(N) with absolute offsets and O(log N) with delta-gap (one
1533            // reanchored seam gap moves every later match for free).
1534            let patch = Patch::single(Edit { old: 5..5, new: 5..6 });
1535            NODE_ALLOCS.with(|c| c.set(0));
1536            s.apply_patch(&patch);
1537            NODE_ALLOCS.with(std::cell::Cell::get) as f64
1538        };
1539        let (small, big) = (allocs_for(1000), allocs_for(4000));
1540        eprintln!("[decorations] apply_patch node allocs {small} -> {big}  ({:.2}x)", big / small);
1541        assert!(
1542            big <= small * 2.0,
1543            "apply_patch allocates superlinearly ({small} -> {big} nodes): the mover \
1544             reverted to a whole-store rebuild instead of shifting the suffix at one seam"
1545        );
1546    }
1547
1548    #[test]
1549    fn windowed_take_matching_in_equals_naive() {
1550        // The windowed removal must take exactly what a naive whole-store scan would
1551        // (touching ∧ pred), leave the rest in order with positions intact. Compared
1552        // against a plain Vec model over random stores and windows.
1553        let mut rng = 0x7A6E_1234_u64;
1554        let mut next = move || {
1555            rng ^= rng << 13;
1556            rng ^= rng >> 7;
1557            rng ^= rng << 17;
1558            rng
1559        };
1560        for trial in 0..3000u32 {
1561            let mut s = store();
1562            let n = next() % 40;
1563            for _ in 0..n {
1564                let a = (next() % 120) as u32;
1565                let len = (next() % 25) as u32;
1566                let (kind, stick) = match next() % 3 {
1567                    0 => (DecorationKind::FindMatch, Stickiness::NeverGrows),
1568                    1 => (DecorationKind::AutoClosePair, Stickiness::AlwaysGrows),
1569                    _ => (DecorationKind::SnippetStop { index: 0 }, Stickiness::NeverGrows),
1570                };
1571                s.add_decoration(a..a + len, kind, stick);
1572            }
1573            let before: Vec<(u64, u32, u32, bool)> = s
1574                .iter()
1575                .map(|r| {
1576                    (r.id.0, r.range.start, r.range.end, matches!(r.kind, DecorationKind::FindMatch))
1577                })
1578                .collect();
1579            let qs = (next() % 120) as u32;
1580            let qe = qs + (next() % 30) as u32;
1581            let removed: Vec<u64> = s
1582                .take_matching_in(qs..qe, |r| matches!(r.kind, DecorationKind::FindMatch))
1583                .iter()
1584                .map(|r| r.id.0)
1585                .collect();
1586            let touched = |st: u32, en: u32| st <= qe && en >= qs;
1587            let exp_removed: Vec<u64> = before
1588                .iter()
1589                .filter(|&&(_, st, en, f)| f && touched(st, en))
1590                .map(|&(id, ..)| id)
1591                .collect();
1592            let exp_surv: Vec<(u64, u32, u32)> = before
1593                .iter()
1594                .filter(|&&(_, st, en, f)| !(f && touched(st, en)))
1595                .map(|&(id, st, en, _)| (id, st, en))
1596                .collect();
1597            let got_surv: Vec<(u64, u32, u32)> =
1598                s.iter().map(|r| (r.id.0, r.range.start, r.range.end)).collect();
1599            assert_eq!(removed, exp_removed, "trial {trial}: removed for window {qs}..{qe}");
1600            assert_eq!(got_surv, exp_surv, "trial {trial}: survivors for window {qs}..{qe}");
1601        }
1602    }
1603
1604    #[test]
1605    fn windowed_take_matching_in_is_sublinear_in_store_size() {
1606        use crate::sum_tree::NODE_ALLOCS;
1607        // Find's per-commit repair: removing a small window's matches from an N-match
1608        // store must be O(window + log N) node allocs, not O(N) (a whole-store scan
1609        // + rebuild). Same allocation proof as the apply_patch cell.
1610        let build = |n: u32| -> DecorationStore {
1611            let mut s = store();
1612            let spans: Vec<Range<u32>> = (0..n).map(|i| (i * 10)..(i * 10 + 3)).collect();
1613            s.add_sorted_batch(spans, DecorationKind::FindMatch, Stickiness::NeverGrows);
1614            s
1615        };
1616        let allocs_for = |n: u32| -> f64 {
1617            let mut s = build(n);
1618            NODE_ALLOCS.with(|c| c.set(0));
1619            s.take_matching_in(30..45, |r| matches!(r.kind, DecorationKind::FindMatch));
1620            NODE_ALLOCS.with(std::cell::Cell::get) as f64
1621        };
1622        let (small, big) = (allocs_for(1000), allocs_for(4000));
1623        eprintln!("[decorations] take_matching_in node allocs {small} -> {big}  ({:.2}x)", big / small);
1624        assert!(
1625            big <= small * 2.0,
1626            "take_matching_in allocates superlinearly ({small} -> {big} nodes): it scanned \
1627             and rebuilt the whole store instead of splicing the window band"
1628        );
1629    }
1630
1631    // ─── Foundation oracles ─────────────────────────────────────────────────
1632
1633    fn xorshift(seed: u64) -> impl FnMut() -> u64 {
1634        let mut rng = seed;
1635        move || {
1636            rng ^= rng << 13;
1637            rng ^= rng >> 7;
1638            rng ^= rng << 17;
1639            rng
1640        }
1641    }
1642
1643    fn diag(sev: Severity) -> DecorationKind {
1644        DecorationKind::Diagnostic { severity: sev, message: Arc::from("x"), code: None }
1645    }
1646
1647    #[test]
1648    fn splice_batch_equals_naive() {
1649        // `splice_sorted_batch` (the windowed find-repair insert) must produce a
1650        // store byte-identical to the naive `to_vec()` + push-batch + `set_sorted`
1651        // whole rebuild, for every store shape and every ascending in-window batch
1652        // — the same mint-order, the same (start,id) placement, the same suffix.
1653        let mut next = xorshift(0xBEEF_1234);
1654        let kind_of = |t: u64| -> (DecorationKind, Stickiness) {
1655            match t % 4 {
1656                0 => (DecorationKind::FindMatch, Stickiness::NeverGrows),
1657                1 => (DecorationKind::AutoClosePair, Stickiness::AlwaysGrows),
1658                2 => (DecorationKind::SnippetStop { index: 0 }, Stickiness::GrowsOnlyBefore),
1659                _ => (diag(Severity::Warning), Stickiness::NeverGrows),
1660            }
1661        };
1662        let proj = |s: &DecorationStore| -> Vec<(u64, u32, u32)> {
1663            s.iter().map(|r| (r.id.0, r.range.start, r.range.end)).collect()
1664        };
1665        for trial in 0..4000u32 {
1666            let mut a = store();
1667            let n = next() % 40;
1668            for _ in 0..n {
1669                let start = (next() % 200) as u32;
1670                let len = (next() % 20) as u32;
1671                let (kind, stick) = kind_of(next());
1672                a.add_decoration(start..start + len, kind, stick);
1673            }
1674            let mut b = a.clone(); // shares next_id ⇒ identical minted ids
1675
1676            // A random ascending (possibly duplicate-start) in-window batch.
1677            let base = (next() % 180) as u32;
1678            let width = 1 + (next() % 30) as u32;
1679            let count = next() % 8;
1680            let mut starts: Vec<u32> =
1681                (0..count).map(|_| base + (next() % u64::from(width)) as u32).collect();
1682            starts.sort_unstable();
1683            let spans: Vec<Range<u32>> =
1684                starts.iter().map(|&s| s..s + (next() % 5) as u32).collect();
1685
1686            let ids_a =
1687                a.splice_sorted_batch(&spans, DecorationKind::FindMatch, Stickiness::NeverGrows);
1688
1689            // Naive reference: mint the same ids, push, whole-store re-sort.
1690            let mut v = b.to_vec();
1691            let mut ids_b = Vec::new();
1692            for span in &spans {
1693                let id = b.mint();
1694                ids_b.push(id);
1695                v.push(TrackedRange {
1696                    id,
1697                    range: span.clone(),
1698                    kind: DecorationKind::FindMatch,
1699                    stickiness: Stickiness::NeverGrows,
1700                });
1701            }
1702            if !spans.is_empty() {
1703                b.set_sorted(v);
1704            }
1705            assert_eq!(ids_a, ids_b, "trial {trial}: minted ids diverged");
1706            assert_eq!(proj(&a), proj(&b), "trial {trial}: batch {spans:?}");
1707        }
1708    }
1709
1710    #[test]
1711    fn find_count_rank_nth_agree_with_linear() {
1712        // `find_count` / `find_rank_before(x)` / `nth_find(r)` must equal a linear
1713        // walk over the FindMatch decorations (in (start,id) order) for every r and
1714        // a spread of offsets, over dense find/diagnostic/snippet interleavings.
1715        let mut next = xorshift(0xF19D_5EED);
1716        for trial in 0..3000u32 {
1717            let mut s = store();
1718            let n = next() % 60;
1719            for _ in 0..n {
1720                let start = (next() % 300) as u32;
1721                let len = (next() % 10) as u32;
1722                let (kind, stick) = match next() % 3 {
1723                    0 => (DecorationKind::FindMatch, Stickiness::NeverGrows),
1724                    1 => (diag(Severity::Error), Stickiness::NeverGrows),
1725                    _ => (DecorationKind::SnippetStop { index: 0 }, Stickiness::AlwaysGrows),
1726                };
1727                s.add_decoration(start..start + len, kind, stick);
1728            }
1729            let finds: Vec<(u64, u32, u32)> = s
1730                .iter()
1731                .filter(|r| matches!(r.kind, DecorationKind::FindMatch))
1732                .map(|r| (r.id.0, r.range.start, r.range.end))
1733                .collect();
1734            assert_eq!(s.find_count(), finds.len(), "trial {trial}: find_count");
1735            for off in [0u32, 1, 50, 100, 150, 200, 299, 300, u32::MAX] {
1736                let want = finds.iter().filter(|(_, st, _)| *st < off).count();
1737                assert_eq!(s.find_rank_before(off), want, "trial {trial}: rank before {off}");
1738            }
1739            for (r, want) in finds.iter().enumerate() {
1740                let (id, rng) = s.nth_find(r).expect("r < find_count");
1741                assert_eq!((id.0, rng.start, rng.end), *want, "trial {trial}: nth_find({r})");
1742            }
1743            assert!(s.nth_find(finds.len()).is_none(), "trial {trial}: nth_find past the end");
1744        }
1745    }
1746
1747    #[test]
1748    fn overview_reduce_equals_linear_scan() {
1749        // Both overview lanes must equal a naive per-bucket scan for random stores
1750        // and random monotonic bounds: diagnostic → (max encoded severity, offset
1751        // of the first item at that severity); find → the first FindMatch start.
1752        // Bucket membership is by START ∈ [bounds[b], bounds[b+1]).
1753        let mut next = xorshift(0x0FF3_1234);
1754        for trial in 0..2500u32 {
1755            let mut s = store();
1756            let n = next() % 60;
1757            for _ in 0..n {
1758                let start = (next() % 400) as u32;
1759                let len = (next() % 8) as u32;
1760                let (kind, stick) = match next() % 5 {
1761                    0 | 1 => {
1762                        let sev = match next() % 4 {
1763                            0 => Severity::Hint,
1764                            1 => Severity::Info,
1765                            2 => Severity::Warning,
1766                            _ => Severity::Error,
1767                        };
1768                        (diag(sev), Stickiness::NeverGrows)
1769                    }
1770                    2 | 3 => (DecorationKind::FindMatch, Stickiness::NeverGrows),
1771                    _ => (DecorationKind::SnippetStop { index: 0 }, Stickiness::AlwaysGrows),
1772                };
1773                s.add_decoration(start..start + len, kind, stick);
1774            }
1775            // Random ascending bounds (duplicates ⇒ empty buckets; may reach below
1776            // and above the populated offset range).
1777            let m = 2 + (next() % 8) as usize;
1778            let mut bounds: Vec<u32> = (0..m).map(|_| (next() % 450) as u32).collect();
1779            bounds.sort_unstable();
1780
1781            let items: Vec<TrackedRange> = s.iter().collect();
1782
1783            let mut diag_out = Vec::new();
1784            s.diagnostic_overview(&bounds, &mut diag_out);
1785            let want_diag: Vec<(u8, u32)> = bounds
1786                .windows(2)
1787                .map(|w| {
1788                    let (lo, hi) = (w[0], w[1]);
1789                    let mut max_sev = 0u8;
1790                    let mut off = 0u32;
1791                    for r in &items {
1792                        if r.range.start >= lo && r.range.start < hi {
1793                            if let DecorationKind::Diagnostic { severity, .. } = r.kind {
1794                                let sev = severity as u8 + 1;
1795                                if sev > max_sev {
1796                                    max_sev = sev;
1797                                    off = r.range.start;
1798                                }
1799                            }
1800                        }
1801                    }
1802                    (max_sev, off)
1803                })
1804                .collect();
1805            assert_eq!(diag_out, want_diag, "trial {trial}: diag, bounds {bounds:?}");
1806
1807            let mut find_out = Vec::new();
1808            s.find_overview(&bounds, &mut find_out);
1809            let want_find: Vec<Option<u32>> = bounds
1810                .windows(2)
1811                .map(|w| {
1812                    let (lo, hi) = (w[0], w[1]);
1813                    items
1814                        .iter()
1815                        .find(|r| {
1816                            matches!(r.kind, DecorationKind::FindMatch)
1817                                && r.range.start >= lo
1818                                && r.range.start < hi
1819                        })
1820                        .map(|r| r.range.start)
1821                })
1822                .collect();
1823            assert_eq!(find_out, want_find, "trial {trial}: find, bounds {bounds:?}");
1824        }
1825    }
1826
1827    #[test]
1828    fn splice_sorted_batch_is_sublinear_in_store_size() {
1829        use crate::sum_tree::NODE_ALLOCS;
1830        // The windowed find-repair insert must allocate O(window + log N) tree
1831        // nodes for a tiny in-window batch, NOT O(N) — a whole-store rebuild
1832        // (`to_vec()` + `set_sorted`) reads ~4× across a 4× store and trips this
1833        // cell; the windowed splice stays flat (only log growth).
1834        let build = |n: u32| -> DecorationStore {
1835            let mut s = store();
1836            let spans: Vec<Range<u32>> = (0..n).map(|i| (i * 10)..(i * 10 + 3)).collect();
1837            s.add_sorted_batch(spans, DecorationKind::FindMatch, Stickiness::NeverGrows);
1838            s
1839        };
1840        let allocs_for = |n: u32| -> f64 {
1841            let mut s = build(n);
1842            let spans = [35..37, 36..39, 44..46]; // three matches into the local band
1843            NODE_ALLOCS.with(|c| c.set(0));
1844            s.splice_sorted_batch(&spans, DecorationKind::FindMatch, Stickiness::NeverGrows);
1845            NODE_ALLOCS.with(std::cell::Cell::get) as f64
1846        };
1847        let (small, big) = (allocs_for(1000), allocs_for(4000));
1848        eprintln!("[decorations] splice_sorted_batch node allocs {small} -> {big}  ({:.2}x)", big / small);
1849        assert!(
1850            big <= small * 2.0,
1851            "splice_sorted_batch allocates superlinearly ({small} -> {big} nodes): it rebuilt \
1852             the whole store instead of splicing the window band"
1853        );
1854    }
1855}