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}