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