Skip to main content

lattice_host/
wasm_context.rs

1//! TC.3a — WASM sticky context: producer → per-buffer scope cache → resolver.
2//!
3//! The sibling of [`wasm_decorations`](crate::wasm_decorations), and it exists
4//! for the same reason: a plugin's producer runs OFF the render path (paramount
5//! goal #1) and the rest of the editor reads only a native cache. What differs
6//! is the *staleness key*, and the difference is the whole point of the design.
7//!
8//! Decorations are per-line marks keyed on the **document** version. Scopes are
9//! a pure function of the **parse tree**, so they are keyed on the syntax
10//! snapshot's version instead. A cursor move, a scroll, or an edit whose reparse
11//! has not landed yet all leave the cached scopes valid — which is what keeps
12//! the producer off the keystroke path entirely. Per-pane resolution
13//! ([`resolve_context`](lattice_cells::context::resolve_context)) then runs
14//! natively at cursor rate against this cache.
15//!
16//! This module owns:
17//!
18//! - [`ContextScopeCache`] — the per-buffer cache value (the scopes + the parse
19//!   version they were produced against).
20//! - [`WasmContextState`] — the bundle the [`Editor`] holds as one field.
21//! - [`Editor::maybe_refresh_wasm_context`] — the per-tick refresh pump.
22//!
23//! The producer trait + registry live in `lattice-mode`
24//! ([`AsyncContextSource`](lattice_mode::AsyncContextSource)) so this crate
25//! never depends on `lattice-plugin-host`. The loader (`drain_context`)
26//! registers the WASM producer; the host reads it here.
27//!
28//! Design: `docs/dev/architecture/treesitter-context.md`.
29
30use std::collections::HashMap;
31use std::sync::Arc;
32use std::sync::atomic::{AtomicU64, Ordering};
33
34use lattice_cells::context::ContextScope;
35use lattice_core::BufferId;
36use lattice_mode::ContextSourceRegistryHandle;
37
38use crate::editor::Editor;
39use crate::per_buffer_cache::{PerBufferCache, PerBufferCacheExt};
40
41/// Per-buffer cache of a context plugin's structural scopes.
42///
43/// The producer task writes this via `insert_for`; the host reads it wait-free
44/// when it resolves each pane's context lines.
45#[derive(Debug, Clone, Default)]
46pub struct ContextScopeCache {
47    /// **Parse** version the scopes were produced against — not the document
48    /// version. Scopes describe the tree, so an edit whose reparse has not
49    /// landed does not invalidate them; the strip shows the last coherent
50    /// structure rather than blanking, which is the eventual-consistency the UX
51    /// contract permits for content the user did not edit.
52    pub parse_version: u64,
53    /// Merged scopes from every registered producer for this buffer, sorted by
54    /// `scope_start` so the resolver's own sort is near-free.
55    pub scopes: Vec<ContextScope>,
56}
57
58/// The [`Editor`]'s cohesive WASM-context wiring. Bundled into one field so the
59/// boot struct literal grows by a single line. Every field defaults, so
60/// `Editor::default()` test fixtures get an inert context seam.
61#[derive(Debug)]
62pub struct WasmContextState {
63    /// Per-buffer scope cache the producer tasks write and the host reads.
64    pub cache: PerBufferCache<ContextScopeCache>,
65    /// The registered async context producers — a clone of the boot
66    /// [`ContextSourceRegistryHandle`] service the loader RCU-registers into.
67    /// `None` in `Editor::default()`; the refresh then no-ops.
68    pub registry: Option<ContextSourceRegistryHandle>,
69    /// Off-keystroke paint gate. A producer task bumps this on every cache
70    /// write so a scope arrival with no keystroke in flight still repaints.
71    pub generation: Arc<AtomicU64>,
72    /// Single-flight guard: the `(buffer, parse_version)` a refetch is already
73    /// in flight for, so a burst of ticks doesn't spawn duplicate producers.
74    pending: Option<(BufferId, u64)>,
75    /// Pointer identity of the last registry snapshot the refresh drove. The
76    /// registry `ArcSwap` swaps on every register/unregister, so a changed epoch
77    /// means producers were added or removed — forcing an immediate refresh so
78    /// a just-loaded plugin's scopes appear without waiting for an edit, and an
79    /// unloaded one's clear.
80    last_registry_epoch: usize,
81    /// TC.8a: the plugin's `context.*` options, resolved once per refresh pump
82    /// rather than per pane per publish.
83    ///
84    /// `resolve_sticky_context_lines` runs at cursor rate, once for every pane,
85    /// and `ConfigRegistry` reads take a `Mutex` — so reading six options there
86    /// would put six uncontended lock acquisitions on the keystroke path for
87    /// values that change only when the user runs `:set` or a plugin loads.
88    /// Both of those already wake this pump, so caching here costs nothing in
89    /// freshness.
90    ///
91    /// The viewport fields are NOT cached: they are per-pane, and the resolver
92    /// overwrites them from the pane it is resolving for.
93    pub options: lattice_cells::context::ContextOptions,
94    /// TC.8: `context.line-numbers` — a PAINT option, not a resolution one, so
95    /// it rides beside [`Self::options`] rather than inside it
96    /// (`resolve_context` has no business knowing about gutters).
97    pub line_numbers: bool,
98    /// TC.12: `context.separator` — the glyph repeated as a rule beneath the
99    /// strip. `None` (the default) is no rule.
100    pub separator: Option<char>,
101    /// TC.12: `context.anchor == "topline"`.
102    ///
103    /// The resolver anchors on the CURSOR by default. `topline` resolves
104    /// against the topline *as of before* this frame's reservation, which is
105    /// what breaks the reserve→shrink→retop→reserve cycle the design fragment
106    /// describes; here that falls out for free, because the host passes the
107    /// scroll value it already holds and reservation happens later.
108    pub anchor_topline: bool,
109    /// TC.5: `context.enabled` — the master switch `:context-toggle` flips.
110    ///
111    /// Consulted on the RESOLVE path so turning the feature off empties the
112    /// published list rather than skipping the publish: the reservation is
113    /// computed from that length, so a skipped publish would leave the rows
114    /// reserved and the text pushed down with nothing in the gap.
115    pub enabled: bool,
116}
117
118impl Default for WasmContextState {
119    fn default() -> Self {
120        Self {
121            cache: Default::default(),
122            registry: None,
123            generation: Default::default(),
124            pending: None,
125            last_registry_epoch: 0,
126            options: Default::default(),
127            // The option's registered default, spelled out because a DERIVED
128            // `false` would mean a strip built before the first refresh
129            // silently drops its numbers — a difference visible only in the
130            // first frames, which is the hardest kind to notice.
131            line_numbers: true,
132            separator: None,
133            anchor_topline: false,
134            enabled: true,
135        }
136    }
137}
138
139impl WasmContextState {
140    /// Construct the context state wired to the boot producer registry — the
141    /// boot path. `Editor::default()` uses the `Default` impl (no registry).
142    pub fn with_registry(registry: ContextSourceRegistryHandle) -> Self {
143        Self {
144            registry: Some(registry),
145            ..Default::default()
146        }
147    }
148
149    /// The cached scopes for `buffer`, or an empty slice when none have landed.
150    /// The read the per-pane resolution does every publish.
151    pub fn scopes_for(&self, buffer: BufferId) -> Arc<ContextScopeCache> {
152        self.cache.get_for(buffer).unwrap_or_default()
153    }
154}
155
156/// TC.13: cache `scopes` for `buffer_id` unless a NEWER parse's answer is
157/// already there. Returns whether the write happened.
158///
159/// Producers run off-thread, so a slow one can land after a newer parse's
160/// reply is already cached. The cache is keyed by parse version and the READ
161/// gate compares that key, but the write was unconditional — so a late reply
162/// replaced correct scopes with stale ones, pointing the strip at lines that
163/// have since moved. It self-heals on the next tick, and "wrong for one frame,
164/// then right" is exactly the flicker the UX contract vetoes.
165///
166/// A free function rather than a closure so the ordering rule is testable
167/// without racing two real producers, which is the kind of test that passes
168/// nine times in ten.
169pub fn store_if_current(
170    cache: &PerBufferCache<ContextScopeCache>,
171    buffer_id: BufferId,
172    parse_version: u64,
173    mut scopes: Vec<lattice_cells::context::ContextScope>,
174) -> bool {
175    use crate::per_buffer_cache::PerBufferCacheExt;
176    if let Some(current) = cache.get_for(buffer_id)
177        && current.parse_version > parse_version
178    {
179        tracing::debug!(
180            buffer = buffer_id.0,
181            stale = parse_version,
182            current = current.parse_version,
183            "dropping a context reply for a superseded parse"
184        );
185        return false;
186    }
187    // Sorted once here rather than on every per-pane resolution: the resolver
188    // runs at cursor rate, this runs per reparse.
189    scopes.sort_by_key(|s| s.scope_start);
190    cache.insert_for(
191        buffer_id,
192        ContextScopeCache {
193            parse_version,
194            scopes,
195        },
196    );
197    true
198}
199
200impl Editor {
201    /// Per-tick context refresh pump — the off-render-path drive.
202    ///
203    /// Called from `run_tick_pending` beside `maybe_refresh_wasm_decorations`.
204    /// Cheap when nothing changed (registry-epoch + parse-version gated). When a
205    /// refresh is due it spawns the registered producers on the background
206    /// runtime (NOT the actor thread), each writing the merged result into the
207    /// per-buffer cache, bumping the paint generation and waking the render
208    /// pipeline so the result lands WITHOUT a keypress.
209    ///
210    /// Graceful / no-blanking (§8): a producer whose call errs contributes
211    /// nothing, and the cache is overwritten only when at least one producer
212    /// answered — an all-error refresh keeps the prior scopes rather than
213    /// clearing them. A failed refresh must not read as the feature breaking.
214    pub fn maybe_refresh_wasm_context(&mut self) {
215        // Before the producer gate: the options are read even when no producer
216        // is registered yet, because a plugin registers its OPTIONS and its
217        // producer in the same load and the order between them is not ours to
218        // rely on.
219        self.refresh_context_options();
220        let Some(registry) = self.wasm_context.registry.clone() else {
221            return;
222        };
223        let snapshot_reg = registry.load_full();
224        let epoch = Arc::as_ptr(&snapshot_reg) as usize;
225        let registry_changed = epoch != self.wasm_context.last_registry_epoch;
226        let sources = snapshot_reg.sources();
227
228        if sources.is_empty() {
229            // Every producer unloaded: clear the stale cache so unloaded scopes
230            // stop painting, then record the epoch so we don't loop. Only when
231            // the registry actually changed — a steady-state editor with no
232            // context plugin takes the cheap path and never touches the cache.
233            if registry_changed {
234                self.wasm_context
235                    .cache
236                    .store(Arc::new(HashMap::<BufferId, Arc<ContextScopeCache>>::new()));
237                self.wasm_context.generation.fetch_add(1, Ordering::Relaxed);
238                self.wasm_context.last_registry_epoch = epoch;
239                self.wasm_context.pending = None;
240            }
241            return;
242        }
243
244        let buffer_id = self.document_buffer_id;
245        // The tree and the line count are acquired together so the two agree on
246        // version — the tree-sitter seam's §7 rule. A buffer with no parse still
247        // drives the producer: "no tree" is a normal state the guest answers
248        // with an empty set, and skipping would leave stale scopes painted after
249        // a language change.
250        let syntax = self
251            .document_syntax_for(buffer_id)
252            .map(|handle| handle.snapshot());
253        let parse_version = syntax.as_ref().map(|s| s.text_version()).unwrap_or(0);
254        let line_count = self.document.snapshot().buffer.content_line_count();
255
256        let cache_current = self
257            .wasm_context
258            .cache
259            .get_for(buffer_id)
260            .map(|c| c.parse_version == parse_version)
261            .unwrap_or(false);
262        if !registry_changed && cache_current {
263            return;
264        }
265        if !registry_changed && self.wasm_context.pending == Some((buffer_id, parse_version)) {
266            return;
267        }
268
269        self.wasm_context.last_registry_epoch = epoch;
270        self.wasm_context.pending = Some((buffer_id, parse_version));
271
272        let path = self.buffers.document_path(buffer_id);
273        let cache_slot = self.wasm_context.cache.clone();
274        let async_landed = self.async_landed.clone();
275        let generation = self.wasm_context.generation.clone();
276        // Type-erase for the native trait — `lattice-mode` must not name
277        // `lattice-syntax` (the `ActionContext::syntax` precedent); the
278        // plugin-host adapter downcasts on the far side.
279        let erased: Option<Arc<dyn std::any::Any + Send + Sync>> =
280            syntax.map(|s| s as Arc<dyn std::any::Any + Send + Sync>);
281
282        // Off the actor thread: the editor actor runs a current-thread runtime,
283        // so a plain `tokio::spawn` would land here. The shared background
284        // runtime hosts the channel round-trip to the plugin's context actor.
285        lattice_runtime::runtime::spawn_on_lsp_runtime(async move {
286            let mut merged: Vec<ContextScope> = Vec::new();
287            let mut any_ok = false;
288            for source in sources {
289                match source
290                    .produce(buffer_id.0 as u64, path.clone(), line_count, erased.clone())
291                    .await
292                {
293                    Ok(scopes) => {
294                        any_ok = true;
295                        merged.extend(scopes);
296                    }
297                    Err(reason) => {
298                        tracing::debug!(
299                            source = source.source_id(),
300                            error = %reason,
301                            "context producer errored; keeping prior scopes"
302                        );
303                    }
304                }
305            }
306            if any_ok {
307                // A late reply for a SUPERSEDED parse is dropped rather than
308                // written. Producers run off-thread and a slow one can land
309                // after a newer parse's reply has already been cached; writing
310                // unconditionally would replace correct scopes with stale ones
311                // and leave the strip pointing at lines that have since moved.
312                //
313                // It self-heals on the next tick (the stamp no longer matches,
314                // so the pump re-drives), but "wrong for one frame, then
315                // right" is precisely the flicker the UX contract vetoes.
316                if !store_if_current(&cache_slot, buffer_id, parse_version, merged) {
317                    return;
318                }
319                generation.fetch_add(1, Ordering::Relaxed);
320                // The wake is what makes the strip appear with no keypress. A
321                // bare cache write would sit until the user happened to press
322                // something, and the symptom reads as a rendering bug.
323                async_landed.notify_one();
324            }
325        });
326    }
327}
328
329impl Editor {
330    /// TC.3b — resolve the source lines this pane pins, for the publish that is
331    /// about to happen.
332    ///
333    /// Runs at cursor rate (every pane-inputs publish), so it must stay cheap:
334    /// a cache read plus [`resolve_context`], which is a linear scan over the
335    /// buffer's scopes and a sort of the small enclosing subset. It touches no
336    /// WASM — the producer that filled the cache ran off-thread on the last
337    /// reparse.
338    ///
339    /// The host resolving this (rather than each renderer) is what makes the
340    /// scroll model's reservation and the painted strip incapable of
341    /// disagreeing: both read the list this returns.
342    ///
343    /// Empty is the fast path and the overwhelmingly common one — no context
344    /// plugin loaded means no cached scopes means an empty `Arc<[u32]>` with no
345    /// allocation beyond the shared empty slice.
346    pub fn resolve_sticky_context_lines(
347        &self,
348        buffer_id: BufferId,
349        cursor_line: u32,
350        scroll: u32,
351        viewport_height: u32,
352    ) -> Arc<[u32]> {
353        if !self.wasm_context.enabled {
354            return Arc::from([] as [u32; 0]);
355        }
356        let cached = self.wasm_context.scopes_for(buffer_id);
357        if cached.scopes.is_empty() {
358            return Arc::from([] as [u32; 0]);
359        }
360        // The plugin's registered `context.*` options, cached by the refresh
361        // pump; only the per-pane viewport fields are filled in here.
362        let opts = lattice_cells::context::ContextOptions {
363            viewport_height,
364            viewport_top: scroll,
365            ..self.wasm_context.options
366        };
367        // TC.12: `context.anchor`. `cursor` (the default) asks "where am I";
368        // `topline` asks "what am I looking at". The topline passed here is
369        // this frame's pre-reservation value, which is what keeps the
370        // reserve→shrink→retop→reserve cycle from closing.
371        let anchor = if self.wasm_context.anchor_topline {
372            scroll
373        } else {
374            cursor_line
375        };
376        let lines = lattice_cells::context::resolve_context(&cached.scopes, anchor, &opts);
377        Arc::from(lines.into_boxed_slice())
378    }
379
380    /// Re-read the plugin's `treesitter-context.*` options into the cache.
381    ///
382    /// Every option is optional at every step: the plugin may not be loaded,
383    /// may not have registered that option, or may have registered it with a
384    /// type this cannot read. Each of those falls back to the compiled default
385    /// INDIVIDUALLY rather than abandoning the whole read — a plugin that
386    /// registers five of six options should get five honoured, not none.
387    ///
388    /// The names are the plugin id plus the option's own name, which is how
389    /// the config seam namespaces every plugin option. That coupling is the
390    /// cost of the host resolving a plugin's options natively (which is itself
391    /// the cost of keeping WASM off the scroll path); it is spelled out here
392    /// rather than spread across the reads.
393    fn refresh_context_options(&mut self) {
394        use lattice_cells::context::TrimScope;
395        const NS: &str = "treesitter-context";
396        let defaults = lattice_cells::context::ContextOptions::default();
397        let int = |name: &str, fallback: u32| -> u32 {
398            self.config
399                .get_int_by_name(&format!("{NS}.{name}"))
400                .and_then(|v| u32::try_from(v).ok())
401                .unwrap_or(fallback)
402        };
403        let trim = match self
404            .config
405            .get_string_by_name(&format!("{NS}.trim-scope"))
406            .as_deref()
407        {
408            Some("inner") => TrimScope::Inner,
409            Some("outer") => TrimScope::Outer,
410            // An unrecognised value keeps the default rather than erroring:
411            // the option is a plugin's free-form string, and a typo must not
412            // take the strip away.
413            _ => defaults.trim,
414        };
415        self.wasm_context.options = lattice_cells::context::ContextOptions {
416            max_lines: int("max-lines", defaults.max_lines),
417            trim,
418            multiline_threshold: int("multiline-threshold", defaults.multiline_threshold),
419            max_viewport_fraction: int("max-viewport-fraction", defaults.max_viewport_fraction),
420            // Per-pane; overwritten by the resolver.
421            ..defaults
422        };
423        // Defaults to ON: a strip whose rows have no line numbers is harder to
424        // act on than one that does, and the plugin registers it `true`. The
425        // fallback matches, so an unloaded plugin and a loaded one agree.
426        self.wasm_context.line_numbers = self
427            .config
428            .get_bool_by_name(&format!("{NS}.line-numbers"))
429            .unwrap_or(true);
430        // Only the FIRST character: the option is a rule glyph, and a
431        // multi-character value would make the rule's width depend on the
432        // pane's width in a way the user cannot predict.
433        self.wasm_context.separator = self
434            .config
435            .get_string_by_name(&format!("{NS}.separator"))
436            .and_then(|v| v.chars().next());
437        self.wasm_context.enabled = self
438            .config
439            .get_bool_by_name(&format!("{NS}.enabled"))
440            .unwrap_or(true);
441        self.wasm_context.anchor_topline = self
442            .config
443            .get_string_by_name(&format!("{NS}.anchor"))
444            .is_some_and(|v| v == "topline");
445    }
446}