Skip to main content

lattice_host/
overlay_worker.rs

1//! Background overlay worker — static-overlay bucketing off the UI thread.
2//!
3//! Phase 5.8.AF.5 / Slice X2.3 (origin); display-line B4.2 (gut + rename).
4//!
5//! ## Why this exists
6//!
7//! Per paramount goal #1 in `CLAUDE.md`:
8//!
9//! > **Performance.** UI thread does no I/O, no parsing, no shaping.
10//!
11//! This worker pre-buckets the active document's *static* overlay
12//! layers (hlsearch matches, LSP document-highlights, `:s///`
13//! substitute preview) into per-row quad lists so neither renderer
14//! peer has to walk every overlay range against every visible row
15//! inside its per-frame paint body.
16//!
17//! ## History (B4.2 gut + rename)
18//!
19//! Until display-line slice B4.2 this module was `highlights_worker`
20//! and carried TWO jobs:
21//!
22//! - the **span / row prepaint cache** (`VisibleSpans` /
23//!   `VisibleRows` / `RowPrepaint`, built by `build_rows` /
24//!   `build_rows_with_cache` / `weave_row`) — consumed by the
25//!   renderers' shaping path. The cells / display-matrix migration
26//!   (B-series) severed every read of that cache, so B4.2 deleted it
27//!   wholesale.
28//! - the **static-overlay bucket** (`bucket_static_overlays`) —
29//!   still consumed every frame by both renderers' overlay paint
30//!   paths (TUI `render.rs`, GPUI `editor_element.rs`). This is the
31//!   live half and is all that remains here.
32//!
33//! The module was renamed `overlay_worker` because it no longer
34//! produces highlights — only overlay quads.
35//!
36//! ## Design
37//!
38//! - Dispatch's `publish_render_state` populates
39//!   [`crate::render_state::SyntaxRenderState`] inputs
40//!   (`syntax_handle`, `scroll`, `viewport_height`, `fold_hash`,
41//!   `text_version`, `doc_highlights`, `static_overlay_version`)
42//!   and fires [`crate::editor::OverlayWake`]'s `Notify`.
43//! - The worker `notified().await`s the wake signal. `Notify` is
44//!   permit-style: a burst of publishes wakes the worker exactly
45//!   once, after which the worker re-reads the *latest* snapshot.
46//! - On wake the worker reads `render_state.load_full().syntax`,
47//!   constructs a [`crate::render_state::VisibleHighlightsKey`],
48//!   compares it against the key in the currently-published
49//!   [`crate::render_state::StaticOverlayQuads`], and short-circuits
50//!   on cache-hit.
51//! - On cache-miss with a current snapshot: re-buckets the overlay
52//!   layers into per-row quad lists and stores a fresh
53//!   `StaticOverlayQuads` into the durable
54//!   `syntax_static_overlay_quads_cell` `Arc<ArcSwap<…>>`.
55//! - On cache-miss with a *stale* snapshot (snapshot's
56//!   `text_version` < document's `text_version`): the worker applies
57//!   the stale-snapshot HOLD — it stores the *new* key but preserves
58//!   the previously-published quads, so a mid-edit window doesn't
59//!   recolour overlay backgrounds against pre-edit data.
60//!
61//! ## Renderer contract
62//!
63//! Renderer peers read with:
64//!
65//! ```text
66//! let rs = editor.render_state.load_full();
67//! let quads = rs.syntax.static_overlay_quads.load();
68//! // quads.quads[i] = per-row RowOverlayQuad list for visible line i
69//! ```
70//!
71//! Empty `quads` is the legal "no overlays yet" state (initial boot,
72//! no overlay layers active, or a pre-first-worker-tick window).
73//! Renderers paint no overlay backgrounds in that case.
74
75use std::sync::Arc;
76
77use arc_swap::ArcSwap;
78use tracing::{debug, trace};
79
80use crate::editor::OverlayWake;
81use crate::render_state::{
82    OverlayLayer, RenderState, RowOverlayQuad, StaticOverlayQuads, VisibleHighlightsKey,
83};
84
85/// Recompute decision the worker takes on a wake. Visible for
86/// testing; the production loop calls [`recompute`] directly.
87#[derive(Debug, PartialEq, Eq)]
88pub enum WorkerDecision {
89    /// `syntax_handle` is `None` — no language attached. Worker
90    /// clears the published overlay quads (so a previous language's
91    /// overlay buckets don't linger after a language detach).
92    Clear,
93    /// Current inputs match the already-published key. Worker
94    /// does nothing.
95    CacheHit,
96    /// Snapshot is behind the document
97    /// (`snapshot.text_version() < inputs.text_version`). Worker
98    /// stores the new key but preserves existing quads
99    /// (stale-snapshot HOLD).
100    StaleSnapshotHold,
101    /// Snapshot is current; worker re-bucketed the overlay layers
102    /// and stored fresh quads + key.
103    Recomputed,
104}
105
106/// Worker entry point spawned at boot. Loops forever, awaiting
107/// the wake `Notify`. Each wake re-reads the latest
108/// `RenderState.syntax` inputs and calls [`recompute`].
109///
110/// Spawn from `editor_boot` once `Editor` is constructed. Pass
111/// clones of `Editor::render_state` and
112/// `Editor::syntax_static_overlay_quads_cell` plus the wake `Notify`.
113pub async fn run(
114    render_state: Arc<ArcSwap<RenderState>>,
115    wake: OverlayWake,
116    static_overlay_quads_cell: Arc<ArcSwap<StaticOverlayQuads>>,
117    paint_request: Arc<tokio::sync::Notify>,
118) {
119    debug!(
120        target: "lattice_host::overlay_worker",
121        "overlay worker spawned"
122    );
123    let mut tick_count: u64 = 0;
124    loop {
125        // `notified().await` resolves when `notify_one()` is
126        // called (or immediately if a permit is stored).
127        // Permit-style coalescing: a burst of publishes wakes
128        // us once; we read the LATEST snapshot below, which
129        // captures the full effect of the burst.
130        wake.0.notified().await;
131        let t0 = std::time::Instant::now();
132        let decision = recompute(&render_state, &static_overlay_quads_cell);
133        let elapsed_us = t0.elapsed().as_micros();
134        tick_count += 1;
135        // X1b: tell the renderer it has fresh data to paint.
136        // Only on Recomputed -- CacheHit / StaleSnapshotHold leave
137        // quads bit-identical / unchanged for the renderer's
138        // purposes, so waking the peer would be a wasted frame.
139        // `Clear` (language detach) is also a content change worth
140        // a paint.
141        if matches!(decision, WorkerDecision::Recomputed | WorkerDecision::Clear) {
142            paint_request.notify_one();
143        }
144        trace!(
145            target: "lattice_host::overlay_worker",
146            tick = tick_count,
147            ?decision,
148            elapsed_us,
149            "overlay worker tick"
150        );
151    }
152}
153
154/// Pure synchronous recompute. Reads the current published
155/// `SyntaxRenderState`, decides whether to recompute, and updates
156/// `static_overlay_quads_cell` accordingly. Returns the decision
157/// taken so tests can assert cache-hit / stale-snapshot HOLD /
158/// recompute paths without driving the async loop.
159pub fn recompute(
160    render_state: &ArcSwap<RenderState>,
161    static_overlay_quads_cell: &ArcSwap<StaticOverlayQuads>,
162) -> WorkerDecision {
163    let rs = render_state.load_full();
164    let syntax = &rs.syntax;
165    let Some(handle) = syntax.syntax_handle.as_ref() else {
166        // No language attached. Clear published quads so a
167        // language detach doesn't leave stale overlay buckets.
168        let existing = static_overlay_quads_cell.load();
169        if existing.quads.is_empty() && existing.computed_for_key == VisibleHighlightsKey::default()
170        {
171            return WorkerDecision::Clear;
172        }
173        static_overlay_quads_cell.store(Arc::new(StaticOverlayQuads::default()));
174        return WorkerDecision::Clear;
175    };
176
177    let snap = handle.snapshot();
178    let snap_ptr = Arc::as_ptr(&snap) as usize;
179    let snap_text_version = snap.text_version();
180    let key = VisibleHighlightsKey {
181        buffer_id: rs.active_document.load().document_buffer_id,
182        snapshot_ptr: snap_ptr,
183        syntax_text_version: snap_text_version,
184        scroll: syntax.scroll,
185        viewport_height: syntax.viewport_height,
186        fold_hash: syntax.fold_hash,
187        inlay_version: syntax.inlay_version,
188        static_overlay_version: syntax.static_overlay_version,
189    };
190
191    let existing = static_overlay_quads_cell.load_full();
192    if existing.computed_for_key == key {
193        return WorkerDecision::CacheHit;
194    }
195
196    // Stale-snapshot HOLD: the parser hasn't caught up with the
197    // most recent edits yet. Compose a new StaticOverlayQuads that
198    // carries the NEW key (so we don't keep retrying for the same
199    // input on every wake) but PRESERVES the previously bucketed
200    // quads — re-bucketing against stale source-line extents would
201    // mis-place overlay backgrounds on unchanged-content lines.
202    if snap_text_version < syntax.text_version {
203        let existing_quads = static_overlay_quads_cell.load_full();
204        // Holding is only ever right for the SAME buffer. Across a switch the
205        // held quads are the previous document's byte offsets, and they paint
206        // onto whatever text now sits under them — the reported "hlsearch
207        // stays at the exact spot it was in the buffer before". A
208        // just-activated buffer has text before its parse lands, so it is
209        // reliably in this branch, and the first hold sticks: it stamps the
210        // NEW key, so every wake afterwards is a cache hit and the stale
211        // bucket never leaves on its own.
212        //
213        // Clearing rather than re-bucketing, because the reason not to
214        // re-bucket is unchanged — stale source-line extents mis-place the
215        // backgrounds. An empty bucket makes the renderer fall back to the
216        // live `all_matches` walk, which is correct for the new buffer; the
217        // next non-stale wake republishes properly.
218        if existing_quads.computed_for_key.buffer_id != key.buffer_id {
219            static_overlay_quads_cell.store(Arc::new(StaticOverlayQuads {
220                quads: Arc::from(Vec::new().into_boxed_slice()),
221                computed_for_key: key,
222            }));
223            return WorkerDecision::Clear;
224        }
225        let held_quads = StaticOverlayQuads {
226            quads: existing_quads.quads.clone(),
227            computed_for_key: key,
228        };
229        static_overlay_quads_cell.store(Arc::new(held_quads));
230        return WorkerDecision::StaleSnapshotHold;
231    }
232
233    // Cache miss + current snapshot. Re-bucket the overlay layers
234    // OFF the UI thread (this fn runs on the worker's tokio task).
235    //
236    // Slice X2.9: honour `end_line_override` so the fold-aware peer
237    // can stretch the overlay window past closed folds. Falls back
238    // to `scroll + viewport_height` (the pre-X2.9 shape) when the
239    // peer didn't request a stretch. This matches the row count the
240    // (now-deleted) `highlight_lines(start, end)` walk produced, so
241    // the bucket length stays aligned with the renderer's visible
242    // row window.
243    let start = syntax.scroll;
244    let default_end = syntax.scroll.saturating_add(syntax.viewport_height.max(1));
245    let end = syntax
246        .end_line_override
247        .unwrap_or(default_end)
248        .max(default_end);
249    let source = snap.source();
250    let row_count = visible_row_count(source, start, end);
251
252    // Perf plan B.2 slice B.2.a: bucket the published static-overlay
253    // layer payloads into per-row quad lists. Coordinates are in
254    // source utf-8 byte space (the renderer applies its own
255    // coordinate transform at prepaint). Empty payloads short-circuit
256    // to empty buckets — keeps the steady-state no-overlay path cheap.
257    // I.5.1: active-document substate is now an inner `ArcSwap`; load
258    // it once and read `substitute_preview` / `all_matches` off the guard.
259    let ad = rs.active_document.load();
260    let substitute_storage: Vec<lattice_protocol::position::Range>;
261    let substitute_matches: &[lattice_protocol::position::Range] =
262        match ad.substitute_preview.as_ref() {
263            Some(prev) => {
264                substitute_storage = prev.matches.to_vec();
265                &substitute_storage
266            }
267            None => &[],
268        };
269    let static_overlay_quads = bucket_static_overlays(
270        row_count,
271        start,
272        source,
273        &syntax.doc_highlights,
274        &ad.all_matches,
275        substitute_matches,
276    );
277
278    // Perf plan D.1: wrap the freshly-built `Vec` in `Arc<[T]>` at
279    // the cell-store boundary so subsequent HOLD reuse paths can
280    // clone the outer Arc instead of the inner Vec.
281    static_overlay_quads_cell.store(Arc::new(StaticOverlayQuads {
282        quads: Arc::from(static_overlay_quads.into_boxed_slice()),
283        computed_for_key: key,
284    }));
285    WorkerDecision::Recomputed
286}
287
288/// Count the visible rows in `[start, end)` against `source`,
289/// matching the row count the deleted `highlight_lines(start, end)`
290/// walk produced. `end` is clamped to `total_lines + 1` (the syntax
291/// snapshot's convention — a one-past-the-end empty trailing row is
292/// included) so the bucket length stays aligned with the renderer's
293/// visible row window even when the viewport extends past EOF.
294///
295/// `total_lines` is `newlines(source) + 1` (each `\n` terminates a
296/// line; the bytes after the last `\n` are the final line). An empty
297/// source has one line.
298fn visible_row_count(source: &[u8], start: u32, end: u32) -> usize {
299    if end <= start {
300        return 0;
301    }
302    let total_lines = (memchr::memchr_iter(b'\n', source).count() as u32) + 1;
303    let end = end.min(total_lines + 1);
304    if start >= end {
305        return 0;
306    }
307    (end - start) as usize
308}
309
310/// Perf plan B.2 slice B.2.a: bucket the three static-overlay layer
311/// payloads (`doc_highlights`, `all_matches`, `substitute_matches`)
312/// into per-row [`RowOverlayQuad`] lists.
313///
314/// `row_count` is the number of visible rows starting at `start`
315/// (visible buffer line `start + i` for row `i`); the worker derives
316/// it from the snapshot's source via [`visible_row_count`] so the
317/// bucket length matches the renderer's visible window. Output
318/// `quads[i]` carries every layer's ranges that intersect visible
319/// line `start + i`, in fixed precedence order
320/// `DocHighlight → AllMatches → Substitute`. The renderer
321/// interleaves the cursor-coupled layers (`visual`, `current_match`)
322/// at prepaint between `AllMatches` and `Substitute`.
323///
324/// Coordinates are in **source utf-8 byte space** — byte offsets
325/// into the row's source line text. The walk mirrors
326/// `lattice_ui_gpui::editor_element::push_range_quads` exactly so
327/// renderer-side merge code can consume the bucket bit-identical to
328/// its own per-frame walk.
329///
330/// Empty payloads on all three layers skip the bucket entirely —
331/// returns an empty `Vec` and the renderer's overlay path stays on
332/// the cheap "no static overlays" branch.
333///
334/// B4.2 refactor: this no longer takes `&[RowPrepaint]`. It only
335/// ever read each row's SOURCE byte extent (which it already
336/// re-derives via memchr on `source`); the styled / woven row
337/// contents were never used. It now takes the visible `row_count`
338/// directly and seeks the source itself, severing the dependency on
339/// the deleted prepaint cache.
340fn bucket_static_overlays(
341    row_count: usize,
342    start: u32,
343    source: &[u8],
344    doc_highlights: &[lattice_protocol::position::Range],
345    all_matches: &[lattice_protocol::position::Range],
346    substitute_matches: &[lattice_protocol::position::Range],
347) -> Vec<Vec<RowOverlayQuad>> {
348    if doc_highlights.is_empty() && all_matches.is_empty() && substitute_matches.is_empty() {
349        return Vec::new();
350    }
351    // Seek to `start`'s byte offset once, then advance per-row via
352    // memchr so per-row line_len lookups stay O(line_len) instead of
353    // O(scroll + i) like the renderer's per-frame walk used to be.
354    let mut byte_off: usize = 0;
355    let mut line_no: u32 = 0;
356    while line_no < start && byte_off < source.len() {
357        match memchr::memchr(b'\n', &source[byte_off..]) {
358            Some(nl) => {
359                byte_off += nl + 1;
360                line_no += 1;
361            }
362            None => {
363                byte_off = source.len();
364                break;
365            }
366        }
367    }
368    let mut out: Vec<Vec<RowOverlayQuad>> = Vec::with_capacity(row_count);
369    for i in 0..row_count {
370        let line_idx = start + i as u32;
371        let line_end = memchr::memchr(b'\n', &source[byte_off..])
372            .map(|n| byte_off + n)
373            .unwrap_or(source.len());
374        let line_len = line_end - byte_off;
375        let mut quads: Vec<RowOverlayQuad> = Vec::new();
376        push_layer_quads(
377            &mut quads,
378            doc_highlights,
379            OverlayLayer::DocHighlight,
380            line_idx,
381            line_len,
382        );
383        push_layer_quads(
384            &mut quads,
385            all_matches,
386            OverlayLayer::AllMatches,
387            line_idx,
388            line_len,
389        );
390        push_layer_quads(
391            &mut quads,
392            substitute_matches,
393            OverlayLayer::Substitute,
394            line_idx,
395            line_len,
396        );
397        out.push(quads);
398        byte_off = (line_end + 1).min(source.len());
399    }
400    out
401}
402
403/// Push one layer's intersecting ranges into `out` as tagged
404/// `RowOverlayQuad`s in **source utf-8 byte space**. Mirrors
405/// `editor_element::push_range_quads`'s line-bounds / byte-clamp
406/// rules, just without the byte→col conversion (the renderer does
407/// that on consumption — see [`RowOverlayQuad`] docs).
408fn push_layer_quads(
409    out: &mut Vec<RowOverlayQuad>,
410    ranges: &[lattice_protocol::position::Range],
411    layer: OverlayLayer,
412    line_idx: u32,
413    line_len: usize,
414) {
415    for r in ranges {
416        if line_idx < r.start.line || line_idx > r.end.line {
417            continue;
418        }
419        let start_byte = if line_idx == r.start.line {
420            (r.start.byte as usize).min(line_len)
421        } else {
422            0
423        };
424        let end_byte = if line_idx == r.end.line {
425            (r.end.byte as usize).min(line_len)
426        } else {
427            line_len
428        };
429        if end_byte <= start_byte {
430            continue;
431        }
432        out.push(RowOverlayQuad {
433            layer,
434            source_byte_start: start_byte as u32,
435            source_byte_end: end_byte as u32,
436        });
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    /// `recompute` with `syntax_handle: None` clears overlay quads —
445    /// mirrors the original `Clear` contract.
446    #[test]
447    fn recompute_with_no_handle_clears_quads() {
448        let rs: ArcSwap<RenderState> = ArcSwap::from_pointee(RenderState::default());
449        let overlay_cell: ArcSwap<StaticOverlayQuads> = ArcSwap::from_pointee(StaticOverlayQuads {
450            quads: Arc::from(vec![Vec::new()].into_boxed_slice()),
451            computed_for_key: VisibleHighlightsKey {
452                snapshot_ptr: 0xdead,
453                ..Default::default()
454            },
455        });
456        let decision = recompute(&rs, &overlay_cell);
457        assert_eq!(decision, WorkerDecision::Clear);
458        let after = overlay_cell.load();
459        assert!(after.quads.is_empty());
460        assert_eq!(after.computed_for_key, VisibleHighlightsKey::default());
461    }
462
463    /// Helper: build a `RenderState` carrying a seeded Rust
464    /// `SyntaxHandle` parsed against `text`, with the given inputs,
465    /// plus the static-overlay quads cell + the active-document
466    /// `all_matches` payload (for the AllMatches layer).
467    fn rs_with_rust(
468        text: &str,
469        scroll: u32,
470        viewport_height: u32,
471        fold_hash: u64,
472        text_version: u64,
473        end_line_override: Option<u32>,
474        doc_highlights: Vec<lattice_protocol::position::Range>,
475        all_matches: Vec<lattice_protocol::position::Range>,
476    ) -> (
477        ArcSwap<RenderState>,
478        Arc<lattice_syntax::SyntaxHandle>,
479        Arc<arc_swap::ArcSwap<StaticOverlayQuads>>,
480    ) {
481        let mut s = lattice_syntax::Syntax::for_language(lattice_syntax::Lang::Rust)
482            .unwrap()
483            .expect("rust grammar available in test build");
484        s.parse_at(text, text_version);
485        let handle = Arc::new(lattice_syntax::SyntaxHandle::seeded(s));
486        let overlay_cell = Arc::new(arc_swap::ArcSwap::from_pointee(
487            StaticOverlayQuads::default(),
488        ));
489        let static_overlay_version =
490            crate::render_state::static_overlay_state_version(&doc_highlights, &all_matches, &[]);
491        let active_document =
492            arc_swap::ArcSwap::from_pointee(crate::render_state::ActiveDocumentRenderState {
493                all_matches: Arc::from(all_matches.into_boxed_slice()),
494                ..crate::render_state::ActiveDocumentRenderState::default()
495            });
496        let rs = RenderState {
497            active_document: Arc::new(active_document),
498            syntax: Arc::new(crate::render_state::SyntaxRenderState {
499                syntax_handle: Some(handle.clone()),
500                scroll,
501                viewport_height,
502                end_line_override,
503                fold_hash,
504                text_version,
505                inlay_hints: Arc::from(
506                    Vec::<crate::render_state::InlayHintRow>::new().into_boxed_slice(),
507                ),
508                inlay_version: 0,
509                static_overlay_quads: overlay_cell.clone(),
510                doc_highlights: Arc::from(doc_highlights.into_boxed_slice()),
511                static_overlay_version,
512            }),
513            ..RenderState::default()
514        };
515        (ArcSwap::from_pointee(rs), handle, overlay_cell)
516    }
517
518    fn pos(line: u32, byte: u32) -> lattice_protocol::position::Position {
519        lattice_protocol::position::Position { line, byte }
520    }
521    fn rng(sl: u32, sb: u32, el: u32, eb: u32) -> lattice_protocol::position::Range {
522        lattice_protocol::position::Range {
523            start: pos(sl, sb),
524            end: pos(el, eb),
525        }
526    }
527
528    /// B4.2 GUARD TEST: the live overlay path must STILL produce
529    /// quads for a search-match after the `RowPrepaint` refactor.
530    /// This is the regression guard for the whole gut-and-rename:
531    /// deleting the dead span/row cache must NOT kill the
532    /// static-overlay bucket that both renderers paint every frame.
533    #[test]
534    fn recompute_buckets_search_match_quads_after_refactor() {
535        // A search match ("main") on line 0 of `fn main() {}`.
536        // Bytes: f=0 n=1 ' '=2 m=3 a=4 i=5 n=6 → "main" is 3..7.
537        let all_matches = vec![rng(0, 3, 0, 7)];
538        let (rs, _h, overlay_cell) =
539            rs_with_rust("fn main() {}", 0, 5, 0, 1, None, Vec::new(), all_matches);
540        let decision = recompute(&rs, &overlay_cell);
541        assert_eq!(decision, WorkerDecision::Recomputed);
542        let after = overlay_cell.load();
543        assert!(
544            !after.quads.is_empty(),
545            "overlay bucket must be populated for an active search match"
546        );
547        // Row 0 carries exactly one AllMatches quad covering 3..7.
548        let row0 = &after.quads[0];
549        assert_eq!(
550            row0.len(),
551            1,
552            "row 0 should have one match quad; got {row0:?}"
553        );
554        assert!(matches!(row0[0].layer, OverlayLayer::AllMatches));
555        assert_eq!((row0[0].source_byte_start, row0[0].source_byte_end), (3, 7));
556    }
557
558    /// Stamp a `RenderState` with the buffer its active document is. The
559    /// fixture builds an `ActiveDocumentRenderState::default()`, so without
560    /// this every fixture claims the same buffer and a cross-buffer test
561    /// would pass whether or not the code distinguished them.
562    fn set_buffer_id(rs: &ArcSwap<RenderState>, id: lattice_core::BufferId) {
563        let cur = rs.load_full();
564        let ad = cur.active_document.load_full();
565        rs.store(Arc::new(RenderState {
566            active_document: Arc::new(arc_swap::ArcSwap::from_pointee(
567                crate::render_state::ActiveDocumentRenderState {
568                    document_buffer_id: id,
569                    ..(*ad).clone()
570                },
571            )),
572            ..(*cur).clone()
573        }));
574    }
575
576    /// **A buffer switch must not leave the previous buffer's overlay quads
577    /// published.**
578    ///
579    /// Reported: opening a synthetic buffer (magit) leaves the hlsearch
580    /// highlight painted at the exact offsets it had in the buffer before it.
581    /// The host clears `all_matches` on the swap seam correctly — that was the
582    /// tab/pane fix — but the RENDERER paints from this bucket and only falls
583    /// back to the live walk when the bucket is empty.
584    ///
585    /// The trigger is the stale-snapshot HOLD: a freshly-activated buffer has
586    /// text before its parse lands, so `snap_text_version < text_version`, and
587    /// the hold path re-publishes the PREVIOUS quads stamped with the NEW key.
588    /// Every wake after that is a cache hit, so the stale bucket never leaves.
589    /// `VisibleHighlightsKey` carries no buffer identity, so nothing in the
590    /// key can tell the two apart.
591    #[test]
592    fn a_stale_snapshot_after_a_buffer_switch_does_not_hold_the_old_quads() {
593        // Buffer A: a search match on line 0.
594        let (rs_a, _h_a, overlay_cell) = rs_with_rust(
595            "fn main() {}",
596            0,
597            5,
598            0,
599            1,
600            None,
601            Vec::new(),
602            vec![rng(0, 3, 0, 7)],
603        );
604        set_buffer_id(&rs_a, lattice_core::BufferId(7));
605        assert_eq!(recompute(&rs_a, &overlay_cell), WorkerDecision::Recomputed);
606        assert!(
607            !overlay_cell.load().quads.is_empty(),
608            "sanity: buffer A published quads"
609        );
610        assert_eq!(
611            overlay_cell.load().computed_for_key.buffer_id,
612            lattice_core::BufferId(7),
613            "sanity: the quads are stamped with buffer A's id"
614        );
615
616        // Buffer B: a DIFFERENT document with NO matches, whose parse has not
617        // caught up (`text_version` 9 against a snapshot parsed at 1). This is
618        // exactly the state a just-activated synthetic buffer is in.
619        let (rs_b, _h_b, _unused) = rs_with_rust(
620            "totally different text
621",
622            0,
623            5,
624            0,
625            1,
626            None,
627            Vec::new(),
628            Vec::new(),
629        );
630        set_buffer_id(&rs_b, lattice_core::BufferId(8));
631        {
632            let cur = rs_b.load_full();
633            let mut syntax = (*cur.syntax).clone();
634            syntax.text_version = 9;
635            rs_b.store(Arc::new(RenderState {
636                syntax: Arc::new(syntax),
637                ..(*cur).clone()
638            }));
639        }
640
641        let _ = recompute(&rs_b, &overlay_cell);
642        let after = overlay_cell.load();
643        assert!(
644            after.quads.iter().all(|row| row.is_empty()),
645            "buffer B has no matches, so nothing may be published for it; \
646             these are buffer A's offsets about to be painted onto B: {:?}",
647            after.quads,
648        );
649    }
650
651    /// Cache-hit short-circuit: a second `recompute` with the same
652    /// inputs sees `computed_for_key == key` and returns `CacheHit`
653    /// without re-bucketing or churning the cell.
654    #[test]
655    fn recompute_with_unchanged_key_is_cache_hit() {
656        let all_matches = vec![rng(0, 3, 0, 7)];
657        let (rs, _h, overlay_cell) =
658            rs_with_rust("fn main() {}", 0, 5, 0, 1, None, Vec::new(), all_matches);
659        assert_eq!(recompute(&rs, &overlay_cell), WorkerDecision::Recomputed);
660        let first_ptr = Arc::as_ptr(&overlay_cell.load_full());
661        assert_eq!(recompute(&rs, &overlay_cell), WorkerDecision::CacheHit);
662        let second_ptr = Arc::as_ptr(&overlay_cell.load_full());
663        assert_eq!(first_ptr, second_ptr, "cache-hit must not store a new Arc");
664    }
665
666    /// Calling `recompute` twice with `None`-handle inputs takes the
667    /// `Clear` path each time but only stores when needed
668    /// (idempotent — no Arc churn on a redundant Clear).
669    #[test]
670    fn repeated_clear_is_idempotent() {
671        let rs: ArcSwap<RenderState> = ArcSwap::from_pointee(RenderState::default());
672        let overlay_cell: ArcSwap<StaticOverlayQuads> =
673            ArcSwap::from_pointee(StaticOverlayQuads::default());
674        assert_eq!(recompute(&rs, &overlay_cell), WorkerDecision::Clear);
675        let first = Arc::as_ptr(&overlay_cell.load_full());
676        assert_eq!(recompute(&rs, &overlay_cell), WorkerDecision::Clear);
677        let second = Arc::as_ptr(&overlay_cell.load_full());
678        assert_eq!(
679            first, second,
680            "redundant Clear must not churn the quads Arc"
681        );
682    }
683
684    // ---- bucket_static_overlays unit tests (adapted from the
685    //      RowPrepaint signature to the row_count signature) ----
686
687    /// Empty payloads on all three layers short-circuit to an empty
688    /// bucket — keeps the steady-state no-overlay path cheap.
689    #[test]
690    fn bucket_static_overlays_all_empty_returns_empty() {
691        let out = bucket_static_overlays(1, 0, b"let x = 1;", &[], &[], &[]);
692        assert!(out.is_empty());
693    }
694
695    /// Single-line range on each layer: the per-row bucket carries
696    /// one tagged quad per layer in fixed precedence order.
697    #[test]
698    fn bucket_static_overlays_tags_each_layer() {
699        let src = b"hello world";
700        let dh = vec![rng(0, 0, 0, 5)]; // covers "hello"
701        let am = vec![rng(0, 6, 0, 11)]; // covers "world"
702        let sb = vec![rng(0, 0, 0, 11)]; // covers all
703        let out = bucket_static_overlays(1, 0, src, &dh, &am, &sb);
704        assert_eq!(out.len(), 1);
705        let row = &out[0];
706        // Three quads, one per layer, in precedence order.
707        assert_eq!(row.len(), 3);
708        assert!(matches!(row[0].layer, OverlayLayer::DocHighlight));
709        assert_eq!((row[0].source_byte_start, row[0].source_byte_end), (0, 5));
710        assert!(matches!(row[1].layer, OverlayLayer::AllMatches));
711        assert_eq!((row[1].source_byte_start, row[1].source_byte_end), (6, 11));
712        assert!(matches!(row[2].layer, OverlayLayer::Substitute));
713        assert_eq!((row[2].source_byte_start, row[2].source_byte_end), (0, 11));
714    }
715
716    /// Rows outside a range's `[start.line, end.line]` get an empty
717    /// quad list.
718    #[test]
719    fn bucket_static_overlays_skips_rows_outside_range() {
720        let src = b"a\nb\nc";
721        let dh = vec![rng(1, 0, 1, 1)]; // only line 1
722        let out = bucket_static_overlays(3, 0, src, &dh, &[], &[]);
723        assert_eq!(out.len(), 3);
724        assert!(out[0].is_empty());
725        assert_eq!(out[1].len(), 1);
726        assert!(out[2].is_empty());
727    }
728
729    /// Source-byte coordinates are emitted as raw source bytes
730    /// regardless of any renderer-side coordinate transform.
731    #[test]
732    fn bucket_static_overlays_emits_source_bytes() {
733        let src = b"hello";
734        let dh = vec![rng(0, 0, 0, 3)]; // first 3 source bytes
735        let out = bucket_static_overlays(1, 0, src, &dh, &[], &[]);
736        let row = &out[0];
737        assert_eq!(row.len(), 1);
738        assert_eq!((row[0].source_byte_start, row[0].source_byte_end), (0, 3));
739    }
740
741    /// Multi-line range crossing the row in the middle: covers the
742    /// row's full source line.
743    #[test]
744    fn bucket_static_overlays_multi_line_middle_row_full_line() {
745        let src = b"first\nmiddle\nlast";
746        let dh = vec![rng(0, 2, 2, 1)];
747        let out = bucket_static_overlays(3, 0, src, &dh, &[], &[]);
748        assert_eq!(out.len(), 3);
749        // Row 0: from byte 2 to end → 2..5.
750        assert_eq!(
751            (out[0][0].source_byte_start, out[0][0].source_byte_end),
752            (2, 5)
753        );
754        // Row 1: full line → 0..6.
755        assert_eq!(
756            (out[1][0].source_byte_start, out[1][0].source_byte_end),
757            (0, 6)
758        );
759        // Row 2: from 0 to byte 1 → 0..1.
760        assert_eq!(
761            (out[2][0].source_byte_start, out[2][0].source_byte_end),
762            (0, 1)
763        );
764    }
765
766    /// `static_overlay_state_version` is deterministic per payload
767    /// and bumps on any layer change; cross-layer permutations
768    /// don't collide.
769    #[test]
770    fn static_overlay_state_version_is_stable_and_distinct() {
771        use crate::render_state::static_overlay_state_version;
772        let a = vec![rng(0, 0, 0, 3)];
773        let b = vec![rng(0, 0, 0, 5)];
774        // All empty → 0.
775        assert_eq!(static_overlay_state_version(&[], &[], &[]), 0);
776        // Deterministic.
777        assert_eq!(
778            static_overlay_state_version(&a, &b, &[]),
779            static_overlay_state_version(&a, &b, &[])
780        );
781        // Layer permutation → distinct (a in dh vs a in all_matches).
782        assert_ne!(
783            static_overlay_state_version(&a, &[], &[]),
784            static_overlay_state_version(&[], &a, &[])
785        );
786    }
787
788    /// `visible_row_count` matches the deleted `highlight_lines`
789    /// row-count semantics: `min(end, total_lines + 1) - start`.
790    #[test]
791    fn visible_row_count_clamps_to_source_lines() {
792        // 3 lines: "a\nb\nc" → total_lines = 3.
793        let src = b"a\nb\nc";
794        // Viewport fully inside the file.
795        assert_eq!(visible_row_count(src, 0, 3), 3);
796        // Viewport extends past EOF: clamps to total_lines + 1 = 4.
797        assert_eq!(visible_row_count(src, 0, 100), 4);
798        // start past EOF.
799        assert_eq!(visible_row_count(src, 10, 12), 0);
800        // empty / inverted window.
801        assert_eq!(visible_row_count(src, 5, 5), 0);
802        // empty source is one line.
803        assert_eq!(visible_row_count(b"", 0, 5), 2);
804    }
805}