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}