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}