lattice_snippet/session.rs
1//! SN.2: the live snippet-expansion session, relocated off the host
2//! `Editor` so the mode-owned placeholder-navigation handlers can
3//! reach it.
4//!
5//! While a snippet is expanding, exactly one [`ActiveSnippet`] is
6//! live (the tabstop groups + the focused index). Before SN.2 this
7//! lived as `Editor.active_snippet: Option<ActiveSnippet>` and the
8//! `<Tab>` / `<S-Tab>` handlers were `Editor::do_snippet_*` methods.
9//! Per `feedback_mode_owns_its_surface`, the handler bodies belong to
10//! the mode that owns the chords (`SnippetActiveMode`). The
11//! `ActionHandlerRegistry` seam hands a handler only an
12//! [`ActionContext`](lattice_mode::ActionContext) (no `&mut Editor`),
13//! so the session it mutates must live somewhere both the host (which
14//! still creates the session on expand — SN.3 moves that too) and the
15//! mode handler can reach: this service, registered in the
16//! `ServiceRegistry` under [`SnippetSessionHandle`].
17//!
18//! SN.3e: the session is keyed **by buffer**
19//! (`HashMap<BufferId, ActiveSnippet>`), not a single global slot.
20//! Before SN.3e a snippet started in buffer A then switching to B lit
21//! `active-snippet-mode` on B and routed `<Tab>` to A's tabstops
22//! against B's cursor (the predicate + slot were buffer-agnostic).
23//! Snippet state is buffer-local (everything-is-a-buffer ⇒ per-buffer,
24//! not a singleton), so every operation names its buffer: handlers use
25//! `core_buffer_id(ctx.buffer_id)`, host paths use the document buffer.
26//!
27//! One `Mutex` guards the map. Snippet operations are rare (per
28//! `<Tab>`, never per render), so the lock is uncontended and off any
29//! hot path.
30
31use std::collections::HashMap;
32use std::sync::{Arc, Mutex};
33
34use lattice_core::BufferId;
35
36use crate::active::ActiveSnippet;
37
38/// The live snippet sessions, keyed by the buffer each is expanding in
39/// (a buffer is absent from the map when no snippet is live there).
40#[derive(Default)]
41pub struct SnippetSession {
42 inner: Mutex<HashMap<BufferId, ActiveSnippet>>,
43}
44
45impl std::fmt::Debug for SnippetSession {
46 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47 // `try_lock` so a `{:?}` on the owning `Editor` can never
48 // deadlock against a concurrent navigation lock.
49 let active = self.inner.try_lock().map(|g| g.len());
50 f.debug_struct("SnippetSession")
51 .field("active_buffers", &active)
52 .finish()
53 }
54}
55
56/// Shared handle, registered in `ServiceRegistry` and held by the
57/// host. Per `feedback_servicesregistry_arc_typeid`: register and
58/// look up under this exact alias so the `TypeId` matches.
59pub type SnippetSessionHandle = Arc<SnippetSession>;
60
61impl SnippetSession {
62 pub fn new() -> Self {
63 Self::default()
64 }
65
66 /// `true` while a snippet is expanding **in `buffer`**. Backs the
67 /// host's many readers (keymap overlay sync, render state, …),
68 /// each of which names the buffer it cares about so a session in
69 /// one buffer never activates the mode in another.
70 pub fn is_active(&self, buffer: BufferId) -> bool {
71 self.lock().contains_key(&buffer)
72 }
73
74 /// Install a freshly-expanded session in `buffer`, replacing any
75 /// prior session for that same buffer.
76 pub fn set(&self, buffer: BufferId, active: ActiveSnippet) {
77 self.lock().insert(buffer, active);
78 }
79
80 /// End the session in `buffer` (reached `$0`, or the buffer/mode
81 /// tore down). A no-op if `buffer` had no live session.
82 pub fn clear(&self, buffer: BufferId) {
83 self.lock().remove(&buffer);
84 }
85
86 /// Mutate the live session for `buffer` in place. The closure sees
87 /// an `Option` so it can advance the focused tabstop *and* end the
88 /// session (set it to `None`) in one critical section — setting it
89 /// to `None` removes the buffer's entry from the map.
90 pub fn with_mut<R>(
91 &self,
92 buffer: BufferId,
93 f: impl FnOnce(&mut Option<ActiveSnippet>) -> R,
94 ) -> R {
95 let mut map = self.lock();
96 // Present the entry as an `Option` so the existing closure
97 // contract (`*s = None` ends the session) is preserved exactly.
98 let mut slot = map.remove(&buffer);
99 let r = f(&mut slot);
100 if let Some(active) = slot {
101 map.insert(buffer, active);
102 }
103 r
104 }
105
106 fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<BufferId, ActiveSnippet>> {
107 self.inner.lock().expect("SnippetSession mutex poisoned")
108 }
109}
110
111/// Predicate for the host's generic session-backed-minor
112/// reconciler: `active-snippet-mode` should be active on the
113/// active buffer exactly while a snippet session is live. The host
114/// pairs this with `SnippetActiveMode::mode_id()` at boot and
115/// reconciles it each overlay-sync cycle — so the host's generic
116/// sync carries no `snippet_session.is_active()` literal. Keeps the
117/// "when is my mode active?" policy in this crate per
118/// `feedback_mode_owns_its_surface`.
119pub fn snippet_active_predicate(
120 session: SnippetSessionHandle,
121) -> Arc<dyn Fn(BufferId) -> bool + Send + Sync> {
122 Arc::new(move |buffer| session.is_active(buffer))
123}
124
125#[cfg(test)]
126mod tests {
127 #![allow(clippy::unwrap_used)]
128 use super::*;
129
130 fn buf(n: u32) -> BufferId {
131 BufferId(n)
132 }
133
134 #[test]
135 fn empty_session_is_inactive() {
136 let s = SnippetSession::new();
137 assert!(!s.is_active(buf(0)));
138 s.clear(buf(0));
139 assert!(!s.is_active(buf(0)));
140 }
141
142 #[test]
143 fn unknown_buffer_is_inactive_never_panics() {
144 let s = SnippetSession::new();
145 // `is_active` / `clear` / `with_mut` on a buffer with no live
146 // session are graceful no-ops (SN.3e.2 graceful contract).
147 assert!(!s.is_active(buf(42)));
148 s.clear(buf(42));
149 let seen = s.with_mut(buf(42), |slot| slot.is_some());
150 assert!(!seen);
151 assert!(!s.is_active(buf(42)));
152 }
153}