Skip to main content

lattice_snippet/
modes.rs

1//! `snippet-completion-mode` (insert-completion.md §12, CSM.5).
2//!
3//! The `Mode` adapter + `SyncCompletionSource` impl that turn
4//! the snippet registry into a first-class completion source.
5//! Auto-activates on writable buffer kinds via
6//! `auto_activated_minors_for_buffer_kind` in
7//! `lattice-ui-tui::modes`; the source's contribution flows
8//! through CSM.3's `ActiveCompletionSources` cache and
9//! populates the popup alongside buffer-words / tree-sitter /
10//! LSP candidates.
11//!
12//! Placement: `lattice-snippet` is a leaf in the dep graph
13//! (neither `lattice-mode` nor `lattice-completion` depend on
14//! it), so adding `lattice-mode` + `lattice-completion` as
15//! upstream deps doesn't create a cycle. The mode + source
16//! live together in the feature crate -- the placement the
17//! mode-architecture rule asks for.
18
19use std::sync::{Arc, OnceLock};
20
21use arc_swap::ArcSwap;
22use lattice_completion::{
23    CandidateData, CandidateKind, CompletionSourceContribution, CompletionSourceKind,
24    InsertContext, RawCandidate, SourceId, SyncCompletionSource,
25};
26use lattice_grammar::{CommandRegistryHandle, Effect, ModalState, VisualKind};
27use lattice_mode::{
28    ActionContext, ActionHandler, ActionHandlerContribution, ActionHandlerRegistration,
29    ActionHandlerRegistryHandle, ActivationPolicy, BufferStoreHandle, CapabilitySet, Keymap,
30    KeymapEntry, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind, ModeRegistry, keymap_entry,
31};
32use lattice_protocol::position::{Position, Range};
33use lattice_protocol::selection::{Selection, SelectionSet, VisualMode};
34
35use crate::activation::SnippetActivationPolicyHandle;
36use crate::active::TabstopGroup;
37use crate::registry::SnippetRegistry;
38use crate::session::SnippetSessionHandle;
39
40/// Shared handle to a swappable snippet registry. The App owns
41/// one `Arc<SharedSnippetRegistry>`; the mode + source capture
42/// clones of the outer `Arc`. `:reload-snippets` calls `.store()`
43/// on the inner `ArcSwap` -- the mode keeps reading via the
44/// outer handle and sees the fresh data on the next `produce()`.
45pub type SharedSnippetRegistry = ArcSwap<SnippetRegistry>;
46
47/// Stable id for the snippet completion source. Must match
48/// `lattice_completion::SNIPPET_SOURCE_ID` -- the host's
49/// per-language allowlist + `:set
50/// completion.source.<id>.priority` key off this string.
51pub const SNIPPET_COMPLETION_SOURCE_ID: &str = lattice_completion::SNIPPET_SOURCE_ID;
52
53/// Extension-payload kind tag for snippet candidates. Carried
54/// in `RawCandidate::data` so the host's `snippet_meta_for`
55/// reader can route the payload back through
56/// `SnippetRegistry::by_name`. Value matches the host's
57/// historic `SNIPPET_COMPLETION_KIND_ID = 2` so the existing
58/// accept path keeps recognising snippet candidates without a
59/// flag day. Stable u32 -- changing it breaks every snippet
60/// candidate's accept path.
61pub const SNIPPET_PAYLOAD_KIND_ID: u32 = 2;
62
63/// The `SyncCompletionSource` impl that emits snippet
64/// candidates. Captures the registry as an `Arc` so cloning
65/// the contribution stays O(1); the registry itself is shared
66/// with the host's `App.snippet_registry`.
67#[derive(Debug, Clone)]
68pub struct SnippetCompletionSource {
69    pub registry: Arc<SharedSnippetRegistry>,
70}
71
72impl SyncCompletionSource for SnippetCompletionSource {
73    fn produce(&self, ctx: &InsertContext<'_>) -> Vec<RawCandidate> {
74        // Walk `matching_prefix` per language + the `"*"`
75        // bucket, build one candidate per matching snippet. The
76        // payload carries the snippet's name (a stable handle);
77        // the host's accept path resolves the body via
78        // `SnippetRegistry::by_name`.
79        let registry = self.registry.load();
80        let mut out: Vec<RawCandidate> = Vec::new();
81        for snip in registry.matching_prefix(ctx.language, ctx.query) {
82            let prefix = snip
83                .prefixes
84                .first()
85                .cloned()
86                .unwrap_or_else(|| snip.name.clone());
87            let display = match snip.description.as_deref() {
88                Some(d) if !d.is_empty() => format!("{prefix}  {d}"),
89                _ => prefix.clone(),
90            };
91            let mut cand = RawCandidate::plain(prefix, CandidateKind::Plain)
92                .with_source(SourceId::new(SNIPPET_COMPLETION_SOURCE_ID));
93            cand.display = display;
94            cand.data = CandidateData::Extension {
95                kind_id: SNIPPET_PAYLOAD_KIND_ID,
96                payload: snip.name.as_bytes().to_vec(),
97            };
98            out.push(cand);
99        }
100        out
101    }
102}
103
104/// `snippet-completion-mode` -- the `Mode` adapter that
105/// contributes [`SnippetCompletionSource`] when active.
106/// Marker mode otherwise; the contribution is the whole
107/// point.
108pub struct SnippetCompletionMode {
109    pub registry: Arc<SharedSnippetRegistry>,
110}
111
112impl SnippetCompletionMode {
113    pub fn mode_id() -> ModeId {
114        ModeId::new("snippet-completion-mode")
115    }
116}
117
118impl Mode for SnippetCompletionMode {
119    type Guard = ();
120    fn id(&self) -> ModeId {
121        Self::mode_id()
122    }
123    fn kind(&self) -> ModeKind {
124        ModeKind::Minor
125    }
126    // SN.3g: `options()` override removed — it returned the `Mode`
127    // trait default (`OptionOverrideSet::default()`), redundant noise.
128    fn required_capabilities(&self) -> CapabilitySet {
129        CapabilitySet::empty()
130    }
131    fn completion_sources(&self) -> Vec<CompletionSourceContribution> {
132        vec![CompletionSourceContribution {
133            accepts_non_word_query: false,
134            id: SourceId::new(SNIPPET_COMPLETION_SOURCE_ID),
135            // SN.3g: single source with the option default so the two
136            // can't drift (was a bare `150` literal duplicating
137            // `completion.source.snippet.priority`'s default). The
138            // option is `i64`; the contribution field is `u32`.
139            default_priority: lattice_config::COMPLETION_SOURCE_SNIPPET_DEFAULT_PRIORITY as u32,
140            auto_trigger: true,
141            trigger_chars: Vec::new(),
142            popup_filter_chord: None,
143            kind: CompletionSourceKind::Sync(Arc::new(SnippetCompletionSource {
144                registry: Arc::clone(&self.registry),
145            })),
146        }]
147    }
148    fn on_activate(&self, _ctx: ModeContext) -> LifecycleFuture<'_, ()> {
149        Box::pin(async { Ok(()) })
150    }
151}
152
153/// `snippet-mode` — the base "snippets enabled for this buffer"
154/// minor mode (SN.3). The language-aware feature gate: its
155/// [`ActivationPolicy`] decides which buffers get snippets, and it
156/// `implies` [`SnippetCompletionMode`] so the completion *source*
157/// rides the same gate (activating `snippet-mode` brings the
158/// source with it — no separate activation).
159///
160/// Three-mode decomposition (confirmed with the user 2026-06-14):
161/// - **`snippet-mode`** (this) — the gate; owns `<C-x><C-s>`
162///   direct-expand (SN.3c; today still host-bound).
163/// - **`snippet-completion-mode`** — provides the `gen:snippet`
164///   completion source only.
165/// - **`active-snippet-mode`** — in-flight placeholder nav
166///   (`<Tab>` / `<S-Tab>` / `<Esc>`), lit by the session-backed
167///   reconciler when a snippet expands.
168///
169/// SN.3b makes the policy config-driven: the mode reads a shared
170/// [`SnippetActivationPolicyHandle`] that the host folds
171/// `snippet.activation` / `snippet.languages` into at boot and on
172/// every `:set` of those keys. The cell defaults to
173/// `ActivationPolicy::Global` (behavior-preserving — the pre-SN.3
174/// language-blind activation on every Document), which is also the
175/// folded value when `snippet.activation = global` (the default).
176pub struct SnippetMode {
177    implies: Vec<ModeId>,
178    policy: SnippetActivationPolicyHandle,
179}
180
181impl SnippetMode {
182    pub fn mode_id() -> ModeId {
183        ModeId::new("snippet-mode")
184    }
185
186    /// Construct with a caller-owned shared policy cell. The host
187    /// (`register_snippet_modes`) creates the cell and keeps a clone
188    /// so boot + `:set` can re-fold config into it; this mode reads
189    /// the live value in [`activation_policy`](Mode::activation_policy).
190    pub fn with_policy(policy: SnippetActivationPolicyHandle) -> Self {
191        Self {
192            implies: vec![SnippetCompletionMode::mode_id()],
193            policy,
194        }
195    }
196
197    /// Construct with a fresh, default-`Global` policy cell. Used by
198    /// tests and any caller that doesn't wire live config folding;
199    /// the internal cell is private, so the policy is fixed at
200    /// `Global` for the mode's lifetime.
201    pub fn new() -> Self {
202        Self::with_policy(Arc::new(ArcSwap::from_pointee(ActivationPolicy::Global)))
203    }
204}
205
206impl Default for SnippetMode {
207    fn default() -> Self {
208        Self::new()
209    }
210}
211
212/// SN.3g: narrow a protocol `BufferId` (u64) to the core `BufferId`
213/// (u32) the `BufferStore` is keyed by. Centralizes the unchecked
214/// truncation — safe today (ids are small) but a footgun when inlined
215/// at each call site.
216fn core_buffer_id(id: lattice_protocol::ids::BufferId) -> lattice_core::BufferId {
217    lattice_core::BufferId(id.raw() as u32)
218}
219
220/// SN.3c.1: word-byte predicate for the `<C-x><C-s>` trigger-token
221/// scan. Mirrors the host's `is_word_char_byte` (`*` / `#` family)
222/// — `[A-Za-z0-9_]`. Kept local so the mode's expand handler owns
223/// its full scan logic without reaching into the host.
224fn is_snippet_word_byte(b: u8) -> bool {
225    b.is_ascii_alphanumeric() || b == b'_'
226}
227
228/// SN.3c.1: pure trigger-token scan. Given the cursor's line text
229/// and the cursor position, return the `replace_range` covering the
230/// word immediately before the cursor (`token-start..cursor`), or
231/// `None` when there is no word prefix. Kept pure (no buffer store)
232/// so the scan is unit-testable with a plain `&str` — mirrors how
233/// `snippet_group_cursor_effect` is extracted for the nav handlers.
234fn snippet_trigger_range(line_text: &str, cursor: Position) -> Option<Range> {
235    let bytes = line_text.as_bytes();
236    let cursor_byte = (cursor.byte as usize).min(bytes.len());
237    let mut start = cursor_byte;
238    while start > 0 && is_snippet_word_byte(bytes[start - 1]) {
239        start -= 1;
240    }
241    if start == cursor_byte {
242        return None;
243    }
244    Some(Range::new(Position::new(cursor.line, start as u32), cursor))
245}
246
247/// SN.3c.1: `snippet-mode`'s single Insert-mode binding —
248/// `<C-x><C-s>` → `action:snippet-expand`. Migrated off the
249/// Builtin Insert keymap (`lattice-host::keymap_insert`) so the
250/// chord choice lives with the mode that owns the behavior
251/// (`feedback_mode_owns_its_surface`). K.1.c scopes it to
252/// `snippet-mode`-active buffers.
253fn snippet_mode_keymap_entries() -> &'static [KeymapEntry] {
254    static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
255    ENTRIES.get_or_init(|| {
256        vec![keymap_entry!(
257            mode: Insert,
258            chord: "<C-x><C-s>",
259            doc: "Expand the snippet whose prefix matches the word before the cursor.",
260            cmd: "action:snippet-expand"
261        )]
262    })
263}
264
265impl Mode for SnippetMode {
266    type Guard = ();
267    fn id(&self) -> ModeId {
268        Self::mode_id()
269    }
270    fn kind(&self) -> ModeKind {
271        ModeKind::Minor
272    }
273    fn required_capabilities(&self) -> CapabilitySet {
274        CapabilitySet::empty()
275    }
276    /// SN.3b: read the live, host-folded policy from the shared
277    /// cell. Defaults to `Global` (snippets on every document
278    /// buffer); the snippet *source* still self-filters by language,
279    /// so `Global` means "each buffer sees its own language's
280    /// snippets", not "all snippets everywhere". The resolver calls
281    /// this on each `MajorEntered`, so a `:set snippet.activation`
282    /// takes effect for buffers opened afterward.
283    fn activation_policy(&self) -> ActivationPolicy {
284        (**self.policy.load()).clone()
285    }
286    /// Bring `snippet-completion-mode` (the source provider) along
287    /// whenever `snippet-mode` activates — the source is gated to
288    /// exactly the buffers where snippets are enabled.
289    fn implies(&self) -> &[ModeId] {
290        &self.implies
291    }
292    /// SN.3c.1: contribute the `<C-x><C-s>` direct-expand chord.
293    /// Registered at boot under `KeymapLayer::MinorMode("snippet-mode")`
294    /// (Insert mode); K.1.c gates it to `snippet-mode`-active buffers.
295    fn keymap(&self) -> Keymap {
296        Keymap::from_entries(snippet_mode_keymap_entries())
297    }
298    /// SN.3c.1: the *global* (buffer-agnostic) expand handler. Bound to
299    /// `action:snippet-expand`, registered ONCE at boot by the host's
300    /// `register_mode_action_handlers` walk (NOT per-`on_activate` —
301    /// `snippet-mode` is active on many buffers at once and the
302    /// `ActionHandlerRegistry` is keyed by `CommandId` alone, so a
303    /// per-activation registration would let one buffer closing evict
304    /// the handler for all the others; see
305    /// `feedback_effect_vocabulary_is_host_boundary`).
306    ///
307    /// The handler does ONLY the word-prefix scan: read the active
308    /// buffer's line at `ctx.cursor` via the `BufferStoreHandle`, walk
309    /// back over word bytes to the trigger token's start, and emit
310    /// `Effect::ExpandSnippet { replace_range: token-start..cursor }`.
311    /// The host owns resolution + expansion (language + registry +
312    /// variables + splice). Returns `None` (no effect) when there is no
313    /// word prefix at the cursor.
314    fn action_handlers(&self) -> Vec<ActionHandlerContribution> {
315        let handler: ActionHandler = Arc::new(|ctx: &ActionContext<'_>| -> Option<Effect> {
316            let store = ctx.services.get::<BufferStoreHandle>()?;
317            let buffer_id = core_buffer_id(ctx.buffer_id);
318            let handle = store.handle_for(buffer_id)?;
319            let line_text = handle
320                .snapshot()
321                .buffer
322                .line(ctx.cursor.line)
323                .unwrap_or_default();
324            // No word prefix → `None` (no effect); otherwise hand the
325            // host the trigger range to resolve + expand.
326            snippet_trigger_range(&line_text, ctx.cursor)
327                .map(|replace_range| Effect::ExpandSnippet { replace_range })
328        });
329        vec![ActionHandlerContribution {
330            action_name: "action:snippet-expand",
331            handler,
332        }]
333    }
334    fn on_activate(&self, _ctx: ModeContext) -> LifecycleFuture<'_, ()> {
335        Box::pin(async { Ok(()) })
336    }
337}
338
339fn snippet_active_keymap_entries() -> &'static [KeymapEntry] {
340    static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
341    ENTRIES.get_or_init(|| {
342        vec![
343            keymap_entry!(
344                mode: Insert,
345                chord: "<Tab>",
346                doc: "Move to the next placeholder in the active snippet session.",
347                cmd: "action:snippet-next-placeholder"
348            ),
349            keymap_entry!(
350                mode: Insert,
351                chord: "<S-Tab>",
352                doc: "Move to the previous placeholder in the active snippet session.",
353                cmd: "action:snippet-prev-placeholder"
354            ),
355            // SN.3c.2b: `fall_through: true` — leaving a snippet is
356            // augment-and-continue, not replace. The handler clears the
357            // session, then the dispatcher continues to whatever `<Esc>`
358            // natively does (builtin → exit insert, or the user's
359            // rebind). The mode never hardcodes the native meaning.
360            keymap_entry!(
361                mode: Insert,
362                chord: "<Esc>",
363                doc: "Leave the active snippet session, then exit insert (falls through to the native <Esc>).",
364                cmd: "action:snippet-leave",
365                fall_through: true
366            ),
367            // SN.3d.4: the SAME three bindings in Select mode. A
368            // placeholder with a non-empty default is focused in
369            // charwise Select (so the next printable overtypes it —
370            // `snippet_group_cursor_effect`), and these keep
371            // navigation + leave live there too. The host's Select
372            // dispatch (`keymap_select::translate_select`) consults
373            // active minor-mode layers exactly as Insert does, so a
374            // mode that selects a span owns its full chord surface in
375            // BOTH modes — no half-migration. `<Esc>` stays
376            // `fall_through`: the leave handler clears the session and
377            // the dispatcher continues to the native Select `<Esc>`
378            // (`ExitSelect` → Normal).
379            keymap_entry!(
380                mode: Select,
381                chord: "<Tab>",
382                doc: "Move to the next placeholder in the active snippet session.",
383                cmd: "action:snippet-next-placeholder"
384            ),
385            keymap_entry!(
386                mode: Select,
387                chord: "<S-Tab>",
388                doc: "Move to the previous placeholder in the active snippet session.",
389                cmd: "action:snippet-prev-placeholder"
390            ),
391            keymap_entry!(
392                mode: Select,
393                chord: "<Esc>",
394                doc: "Leave the active snippet session, then exit Select (falls through to the native <Esc>).",
395                cmd: "action:snippet-leave",
396                fall_through: true
397            ),
398        ]
399    })
400}
401
402/// `active-snippet-mode` — a transient minor mode activated while
403/// a snippet session is live on a buffer. Contributes the three
404/// Insert-mode bindings (`<Tab>` / `<S-Tab>` / `<Esc>`) via
405/// `Mode::keymap()`; K.2.4 registers them at startup under
406/// `KeymapLayer::MinorMode("active-snippet-mode")` and K.1.c gates
407/// them to buffers where the mode is active.
408///
409/// **SN.2b (2026-06-12):** the mode now owns the `<Tab>` /
410/// `<S-Tab>` placeholder-navigation handler *bodies*, not just the
411/// chord choice. The bodies were `Editor::do_snippet_next/prev_
412/// placeholder` in `lattice-host` — the half-migration
413/// `feedback_mode_owns_its_surface` forbids (keymap in the mode,
414/// handler in the host). They register here as
415/// `ActionContext -> Effect` closures on the `ActionHandlerRegistry`
416/// substrate (the path the project-search provider already uses),
417/// advancing the shared [`SnippetSession`](crate::SnippetSession)
418/// service and returning a cursor effect through the host's generic
419/// effect pipeline (see `snippet_group_cursor_effect`).
420///
421/// **SN.3d.3 (2026-06-15):** that cursor effect now consumes Select
422/// mode. A placeholder with a non-empty default returns
423/// `Effect::Many([EnterMode(Select(Charwise)), SelectionChange(span)])`
424/// so the default is SELECTED and the next printable key overtypes the
425/// whole thing (then drops to Insert). An empty tabstop still returns a
426/// bare `Effect::SelectionChange` cursor. The host's initial-expand
427/// focus (`Editor::expand_snippet`) mirrors this directly.
428///
429/// **SN.3c.2 (2026-06-14):** `<Esc>` (`action:snippet-leave`) now
430/// owns its body here too — a third per-buffer handler that clears
431/// the shared session and returns `Effect::EnterMode(Normal)`. The
432/// old `Editor::dispatch` `Action::SnippetLeave` arm (session clear +
433/// modal flip) + `Action::SnippetLeave` / `AppEffect::SnippetLeave`
434/// are gone (`feedback_mode_owns_its_surface`).
435///
436/// Replaces the old `push_layer` / `pop_layer` push mechanism
437/// (MO.3). The host's `sync_keymap_overlays` calls
438/// `activate_minor` / `deactivate_minor` by polling the shared
439/// session's `is_active()` — when the next-placeholder handler
440/// walks off `$0` and clears the session, the reconciler
441/// deactivates this mode, dropping the Guard (and with it the two
442/// `ActionHandlerRegistration` tokens). (That host poll is itself
443/// a snippet-specific seam slated to move into this crate as a
444/// typed session-lifecycle event — see the snippet activation
445/// slice plan.)
446pub struct SnippetActiveMode;
447
448impl SnippetActiveMode {
449    pub fn mode_id() -> ModeId {
450        ModeId::new("active-snippet-mode")
451    }
452}
453
454/// RAII guard for [`SnippetActiveMode`]. Holds the
455/// `ActionHandlerRegistration` tokens for the `<Tab>` / `<S-Tab>` /
456/// `<Esc>` handlers (SN.3c.2 added `<Esc>`); dropping it (on mode
457/// deactivation) drops the tokens, each of which unregisters its
458/// closure from the `ActionHandlerRegistry` so the chord falls
459/// through to "unhandled" once no snippet is live.
460pub struct SnippetActiveModeGuard {
461    _action_handler_registrations: Vec<ActionHandlerRegistration>,
462}
463
464/// Resolve the cursor effect for a newly-focused tabstop group:
465/// move the cursor to the start of the group's first mirror range.
466/// Mirrors the host's pre-SN.2b `move_cursor_to_snippet_group`
467/// (`byte_to_position` against the active document's snapshot),
468/// but returns an `Effect::SelectionChange` for the generic host
469/// pipeline instead of writing `editor.cursor` directly. `None`
470/// (no effect) when the group has no ranges or the byte offset
471/// doesn't map to a position. Takes the snapshot `buffer` (not the
472/// store) so the cursor math is unit-testable with a plain
473/// [`lattice_core::Buffer`].
474fn snippet_group_cursor_effect(
475    buffer: &lattice_core::Buffer,
476    group: &TabstopGroup,
477) -> Option<Effect> {
478    let first = group.ranges.first()?;
479    let start = buffer.byte_to_position(first.start).ok()?;
480    if first.end > first.start {
481        // SN.3d.3: a non-empty placeholder default is SELECTED so the
482        // next printable key overtypes the whole default in one keystroke
483        // (the edit then ripples to the group's mirrors through the
484        // existing tabstop tracking). Charwise Select with the head on the
485        // placeholder's LAST byte (inclusive-head convention) makes
486        // `selection_extent` span exactly `[start, end)`. `EnterMode`
487        // FIRST: the host's `Effect::SelectionChange` arm only adopts a
488        // span (and sets `visual_anchor`) once modal is Visual/Select, so
489        // the mode flip must land before the selection.
490        let head = buffer.byte_to_position(first.end - 1).ok()?;
491        let sel = Selection {
492            anchor: start,
493            head,
494            visual: Some(VisualMode::Charwise),
495        };
496        Some(Effect::Many(vec![
497            Effect::EnterMode(ModalState::Select(VisualKind::Charwise)),
498            Effect::SelectionChange(SelectionSet::single(sel)),
499        ]))
500    } else {
501        // Empty tabstop (`$1` / `${1:}`): nothing to overtype, so keep the
502        // bare Insert cursor — do NOT enter Select on a zero-width stop.
503        Some(Effect::CursorMove(start))
504    }
505}
506
507impl Mode for SnippetActiveMode {
508    type Guard = SnippetActiveModeGuard;
509    fn id(&self) -> ModeId {
510        Self::mode_id()
511    }
512    fn kind(&self) -> ModeKind {
513        ModeKind::Minor
514    }
515    fn required_capabilities(&self) -> CapabilitySet {
516        CapabilitySet::empty()
517    }
518    fn keymap(&self) -> Keymap {
519        Keymap::from_entries(snippet_active_keymap_entries())
520    }
521    fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
522        Box::pin(async move {
523            let mut registrations: Vec<ActionHandlerRegistration> = Vec::new();
524
525            // Resolve the shared session + buffer-store +
526            // command-registry + handler-registry services.
527            // Tolerate them missing (unit-test harnesses that
528            // activate the mode without full boot wiring): the
529            // mode is still "active" for keymap-scoping, the
530            // handlers just don't register and the chords fall
531            // through. The session handle is required for the
532            // handlers to do anything, so its absence skips
533            // registration entirely.
534            let session = ctx.service::<SnippetSessionHandle>().map(|a| (*a).clone());
535            let buffer_store = ctx.service::<BufferStoreHandle>().map(|a| (*a).clone());
536
537            if let (Some(session), Some(cmd_registry_arc), Some(action_handlers_arc)) = (
538                session,
539                ctx.service::<CommandRegistryHandle>(),
540                ctx.service::<ActionHandlerRegistryHandle>(),
541            ) {
542                // B3b: the service holds the `ArcSwap` handle; snapshot it
543                // wait-free once for the `action:` id lookups below.
544                let cmd_registry_snapshot = cmd_registry_arc.load();
545                let cmd_registry = &*cmd_registry_snapshot;
546                let action_handlers: ActionHandlerRegistryHandle = (*action_handlers_arc).clone();
547
548                // `<Tab>` — step to the next placeholder; clear the
549                // session (ending it) on walk-off-`$0`.
550                if let Some(id) = cmd_registry.id_by_name("action:snippet-next-placeholder") {
551                    let session = session.clone();
552                    let store = buffer_store.clone();
553                    let handler: ActionHandler =
554                        Arc::new(move |ctx: &ActionContext<'_>| -> Option<Effect> {
555                            let buffer_id = core_buffer_id(ctx.buffer_id);
556                            let group = session.with_mut(buffer_id, |s| {
557                                let active = s.as_mut()?;
558                                let next = active.next().cloned();
559                                if next.is_none() {
560                                    // Walked off `$0`: the session
561                                    // ends for this buffer. The host's
562                                    // overlay reconciler sees
563                                    // `is_active(buffer) == false` next
564                                    // cycle and deactivates this mode.
565                                    *s = None;
566                                }
567                                next
568                            })?;
569                            let store = store.as_ref()?;
570                            let handle = store.handle_for(buffer_id)?;
571                            snippet_group_cursor_effect(&handle.snapshot().buffer, &group)
572                        });
573                    registrations.push(action_handlers.register(id, handler));
574                }
575
576                // `<S-Tab>` — step to the previous placeholder.
577                // Never ends the session (`prev()` past the first
578                // group returns `None` → no-op).
579                if let Some(id) = cmd_registry.id_by_name("action:snippet-prev-placeholder") {
580                    let session = session.clone();
581                    let store = buffer_store.clone();
582                    let handler: ActionHandler =
583                        Arc::new(move |ctx: &ActionContext<'_>| -> Option<Effect> {
584                            let buffer_id = core_buffer_id(ctx.buffer_id);
585                            let group = session.with_mut(buffer_id, |s| {
586                                s.as_mut().and_then(|a| a.prev().cloned())
587                            })?;
588                            let store = store.as_ref()?;
589                            let handle = store.handle_for(buffer_id)?;
590                            snippet_group_cursor_effect(&handle.snapshot().buffer, &group)
591                        });
592                    registrations.push(action_handlers.register(id, handler));
593                }
594
595                // `<Esc>` — leave the snippet session (SN.3c.2).
596                // Per-buffer + session-tied (the binding only fires
597                // while a snippet is live on this buffer), so it lives
598                // here in `on_activate` alongside the nav handlers, not
599                // as a global handler. The body does ONLY the
600                // mode-specific part — clear the shared session (the
601                // host's overlay reconciler then sees
602                // `is_active() == false` and deactivates this mode,
603                // dropping these registrations). Exiting insert is NOT
604                // hardcoded here: SN.3c.2b marks the `<Esc>` binding
605                // `fall_through: true`, so after this handler runs the
606                // dispatcher re-resolves `<Esc>` against the layers
607                // below and runs the native binding too (builtin
608                // `<Esc>` → exit insert, or the user's rebind). The mode
609                // owns the augmentation; the host owns the native
610                // meaning. No buffer store needed.
611                if let Some(id) = cmd_registry.id_by_name("action:snippet-leave") {
612                    let session = session.clone();
613                    let handler: ActionHandler =
614                        Arc::new(move |ctx: &ActionContext<'_>| -> Option<Effect> {
615                            session.clear(core_buffer_id(ctx.buffer_id));
616                            Some(Effect::None)
617                        });
618                    registrations.push(action_handlers.register(id, handler));
619                }
620            } else {
621                // SN.3f: the handlers tolerate missing services (unit
622                // harnesses that activate the mode without full boot
623                // wiring), but a *production* boot that reaches here has
624                // a mis-wired ServiceRegistry and the snippet chords
625                // will dead — surface it. `debug!` not `info!` per
626                // `feedback_log_levels` (per-activation, opt-in via
627                // `--log-level debug`).
628                tracing::debug!(
629                    target: "lattice_snippet::modes",
630                    "active-snippet-mode: nav/leave handlers not registered — \
631                     SnippetSession / CommandRegistry / ActionHandlerRegistry \
632                     service absent; <Tab>/<S-Tab>/<Esc> will fall through"
633                );
634            }
635
636            Ok(SnippetActiveModeGuard {
637                _action_handler_registrations: registrations,
638            })
639        })
640    }
641}
642
643/// Register `snippet-completion-mode` against `registry` using
644/// `snippet_registry` as the contribution-side handle. The App
645/// shares the same `Arc<SnippetRegistry>` with this mode so
646/// source produce + host accept-path read the same data.
647/// Returns the shared [`SnippetActivationPolicyHandle`] the
648/// `snippet-mode` gate reads. The host stores a clone on `Editor`
649/// and folds `snippet.activation` / `snippet.languages` into it at
650/// boot + on `:set` (SN.3b); the default cell value is
651/// `ActivationPolicy::Global`.
652pub fn register_snippet_modes(
653    registry: &mut ModeRegistry,
654    snippet_registry: Arc<SharedSnippetRegistry>,
655) -> SnippetActivationPolicyHandle {
656    registry
657        .register(SnippetCompletionMode {
658            registry: snippet_registry,
659        })
660        .expect("snippet-completion-mode must register without conflict");
661    registry
662        .register(SnippetActiveMode)
663        .expect("active-snippet-mode must register without conflict");
664    // SN.3b: the `snippet-mode` gate. Reads a shared, host-folded
665    // policy cell (default `Global`); `implies snippet-completion-mode`
666    // so the source rides the gate. Registered AFTER its implied mode
667    // so the implies dependency resolves (the registry validates the
668    // implies tree at activation, but registering the dep first keeps
669    // the ordering obvious).
670    let policy: SnippetActivationPolicyHandle =
671        Arc::new(ArcSwap::from_pointee(ActivationPolicy::Global));
672    registry
673        .register(SnippetMode::with_policy(policy.clone()))
674        .expect("snippet-mode must register without conflict");
675    policy
676}
677
678#[cfg(test)]
679mod tests {
680    use super::*;
681    use crate::ActiveSnippet;
682    use crate::parse;
683    use crate::registry::Snippet;
684    use lattice_core::Buffer;
685    use lattice_protocol::Position;
686
687    fn make_registry() -> Arc<SharedSnippetRegistry> {
688        let mut r = SnippetRegistry::new();
689        r.insert(
690            "rust",
691            Snippet {
692                name: "for-loop".into(),
693                prefixes: vec!["for".into()],
694                body: parse("for ${1:i} in ${2:iter} {}").expect("parse"),
695                description: Some("for loop".into()),
696                scope: String::new(),
697            },
698        );
699        Arc::new(ArcSwap::from_pointee(r))
700    }
701
702    #[test]
703    fn source_produces_one_candidate_per_matching_snippet() {
704        let registry = make_registry();
705        let source = SnippetCompletionSource { registry };
706        let buffer = Buffer::empty();
707        let ctx = InsertContext {
708            buffer: &buffer,
709            cursor: Position::new(0, 3),
710            anchor: Position::new(0, 0),
711            query: "for",
712            trigger: &lattice_completion::CompletionTrigger::Manual,
713            case_sensitive: false,
714            language: "rust",
715            tree_sitter_symbols: &[],
716            path_context: false,
717            buffer_dir: None,
718            uri: None,
719            lsp_position: None,
720        };
721        let candidates = source.produce(&ctx);
722        assert_eq!(candidates.len(), 1);
723        let c = &candidates[0];
724        assert_eq!(c.text, "for");
725        assert!(c.display.contains("for loop"));
726        match &c.data {
727            CandidateData::Extension { kind_id, payload } => {
728                assert_eq!(*kind_id, SNIPPET_PAYLOAD_KIND_ID);
729                assert_eq!(std::str::from_utf8(payload).unwrap(), "for-loop");
730            }
731            other => panic!("unexpected data: {other:?}"),
732        }
733    }
734
735    #[test]
736    fn mode_contributes_the_source_with_expected_metadata() {
737        let registry = make_registry();
738        let mode = SnippetCompletionMode { registry };
739        let contributions = mode.completion_sources();
740        assert_eq!(contributions.len(), 1);
741        let c = &contributions[0];
742        assert_eq!(c.id.as_str(), SNIPPET_COMPLETION_SOURCE_ID);
743        assert_eq!(c.default_priority, 150);
744        assert!(c.popup_filter_chord.is_none(), "no filter chord per §12");
745        assert_eq!(c.kind.kind_label(), "sync");
746    }
747
748    #[test]
749    fn registers_under_canonical_mode_id() {
750        let mut registry = ModeRegistry::new();
751        let snippet_registry = make_registry();
752        register_snippet_modes(&mut registry, snippet_registry);
753        assert!(registry.is_registered(SnippetCompletionMode::mode_id()));
754    }
755
756    #[test]
757    fn active_snippet_mode_id_is_active_snippet_mode() {
758        assert_eq!(SnippetActiveMode::mode_id().as_str(), "active-snippet-mode");
759        assert_eq!(SnippetActiveMode.id(), SnippetActiveMode::mode_id());
760        assert_eq!(SnippetActiveMode.kind(), ModeKind::Minor);
761    }
762
763    #[test]
764    fn active_snippet_mode_keymap_has_insert_and_select_entries() {
765        use lattice_mode::BindingMode;
766        use lattice_mode::Mode as _;
767        let km = SnippetActiveMode.keymap();
768        // SN.3d.4: <Tab> / <S-Tab> / <Esc> in BOTH Insert and Select
769        // (a default-bearing placeholder is focused in Select).
770        assert_eq!(km.entries.len(), 6);
771        let select_count = km
772            .entries
773            .iter()
774            .filter(|e| e.modes.contains(&BindingMode::Select))
775            .count();
776        let insert_count = km
777            .entries
778            .iter()
779            .filter(|e| e.modes.contains(&BindingMode::Insert))
780            .count();
781        assert_eq!(insert_count, 3, "three Insert bindings");
782        assert_eq!(select_count, 3, "three Select bindings");
783    }
784
785    #[test]
786    fn active_snippet_mode_keymap_entries_have_expected_commands() {
787        use lattice_mode::Mode as _;
788        let km = SnippetActiveMode.keymap();
789        let cmds: Vec<_> = km.entries.iter().filter_map(|e| e.command).collect();
790        assert!(cmds.contains(&"action:snippet-next-placeholder"));
791        assert!(cmds.contains(&"action:snippet-prev-placeholder"));
792        assert!(cmds.contains(&"action:snippet-leave"));
793    }
794
795    #[test]
796    fn register_snippet_modes_registers_active_snippet_mode() {
797        let mut registry = ModeRegistry::new();
798        let snippet_registry = make_registry();
799        register_snippet_modes(&mut registry, snippet_registry);
800        assert!(registry.is_registered(SnippetActiveMode::mode_id()));
801    }
802
803    // ---- SN.3a: `snippet-mode` (the language-aware gate). ----
804
805    #[test]
806    fn snippet_mode_id_and_kind() {
807        assert_eq!(SnippetMode::mode_id().as_str(), "snippet-mode");
808        assert_eq!(SnippetMode::new().id(), SnippetMode::mode_id());
809        assert_eq!(SnippetMode::new().kind(), ModeKind::Minor);
810    }
811
812    #[test]
813    fn snippet_mode_default_policy_is_global() {
814        // The default cell is Global (behavior-preserving) — also
815        // the folded value for `snippet.activation = global`.
816        assert_eq!(
817            SnippetMode::new().activation_policy(),
818            ActivationPolicy::Global
819        );
820    }
821
822    #[test]
823    fn snippet_mode_reads_live_policy_from_shared_cell() {
824        // SN.3b: swapping the shared cell (as the host's boot /
825        // `:set` re-fold does) is reflected on the next
826        // `activation_policy()` read — no re-registration needed.
827        let cell: SnippetActivationPolicyHandle =
828            Arc::new(ArcSwap::from_pointee(ActivationPolicy::Global));
829        let mode = SnippetMode::with_policy(cell.clone());
830        assert_eq!(mode.activation_policy(), ActivationPolicy::Global);
831        cell.store(Arc::new(ActivationPolicy::Majors(vec![ModeId::new(
832            "rust-mode",
833        )])));
834        assert_eq!(
835            mode.activation_policy(),
836            ActivationPolicy::Majors(vec![ModeId::new("rust-mode")]),
837        );
838        cell.store(Arc::new(ActivationPolicy::Manual));
839        assert_eq!(mode.activation_policy(), ActivationPolicy::Manual);
840    }
841
842    #[test]
843    fn register_snippet_modes_returns_a_global_default_cell() {
844        let mut registry = ModeRegistry::new();
845        let policy = register_snippet_modes(&mut registry, make_registry());
846        assert_eq!(**policy.load(), ActivationPolicy::Global);
847    }
848
849    #[test]
850    fn snippet_mode_implies_the_completion_source_mode() {
851        let mode = SnippetMode::new();
852        assert_eq!(mode.implies(), &[SnippetCompletionMode::mode_id()][..]);
853    }
854
855    #[test]
856    fn register_snippet_modes_registers_snippet_mode() {
857        let mut registry = ModeRegistry::new();
858        register_snippet_modes(&mut registry, make_registry());
859        assert!(registry.is_registered(SnippetMode::mode_id()));
860        // Its implied dependency is registered too, so activation
861        // won't fail the implies-tree validation.
862        assert!(registry.is_registered(SnippetCompletionMode::mode_id()));
863    }
864
865    // ---- SN.3c.1: `snippet-mode` owns the `<C-x><C-s>` expand
866    //      trigger (chord + word-prefix scan; host owns the
867    //      resolution + expansion). ----
868
869    #[test]
870    fn snippet_mode_keymap_binds_ctrl_x_ctrl_s_to_expand() {
871        use lattice_mode::Mode as _;
872        let km = SnippetMode::new().keymap();
873        assert_eq!(km.entries.len(), 1);
874        let entry = &km.entries[0];
875        assert_eq!(entry.chord, "<C-x><C-s>");
876        assert_eq!(entry.command, Some("action:snippet-expand"));
877        assert_eq!(entry.modes, [lattice_mode::BindingMode::Insert].as_slice());
878    }
879
880    #[test]
881    fn snippet_mode_contributes_one_global_expand_handler() {
882        use lattice_mode::Mode as _;
883        let handlers = SnippetMode::new().action_handlers();
884        assert_eq!(handlers.len(), 1);
885        assert_eq!(handlers[0].action_name, "action:snippet-expand");
886    }
887
888    #[test]
889    fn trigger_range_covers_word_before_cursor() {
890        // "for" with the cursor after the `r` → replace_range
891        // (0,0)..(0,3) covering the whole token.
892        let r = snippet_trigger_range("for", Position::new(0, 3)).expect("word prefix");
893        assert_eq!(r, Range::new(Position::new(0, 0), Position::new(0, 3)));
894    }
895
896    #[test]
897    fn trigger_range_starts_at_word_boundary_not_line_start() {
898        // "let for" cursor after the second word → token starts at
899        // byte 4 (`f`), not the line start.
900        let r = snippet_trigger_range("let for", Position::new(0, 7)).expect("word prefix");
901        assert_eq!(r, Range::new(Position::new(0, 4), Position::new(0, 7)));
902    }
903
904    #[test]
905    fn trigger_range_is_none_without_a_word_prefix() {
906        // Cursor at column 0 → nothing before it.
907        assert!(snippet_trigger_range("for", Position::new(0, 0)).is_none());
908        // Cursor right after whitespace → no word byte behind it.
909        assert!(snippet_trigger_range("a ", Position::new(0, 2)).is_none());
910    }
911
912    /// Graceful: when the buffer store can't resolve the active
913    /// buffer (`handle_for` returns `None`), the expand handler is a
914    /// quiet no-op rather than a panic.
915    #[test]
916    fn expand_handler_is_a_no_op_when_buffer_unavailable() {
917        use lattice_mode::Mode as _;
918        let handler = SnippetMode::new().action_handlers().remove(0).handler;
919        let store: Arc<dyn lattice_mode::BufferStore> = Arc::new(NullBufferStore);
920        let mut services = lattice_mode::ServiceRegistry::new();
921        services.register::<BufferStoreHandle>(BufferStoreHandle::new(store));
922        let events = lattice_runtime::EventBus::new();
923        let ctx = ActionContext {
924            buffer_id: lattice_protocol::ids::BufferId::new(1),
925            cursor: Position::new(0, 3),
926            selection: None,
927            services: &services,
928            events: &events,
929            prompt_value: None,
930            args: lattice_grammar::Args::None,
931            buffer_locals: None,
932        };
933        assert!(handler(&ctx).is_none());
934    }
935
936    // ---- SN.2b: `active-snippet-mode` placeholder-navigation
937    //      handler bodies (relocated off the host). ----
938
939    /// SN.3d.3: a focused tabstop with a non-empty default (`"iter"`
940    /// at bytes 9..13 in `"for i in iter {}"`) SELECTS the default and
941    /// enters Select so a printable key overtypes it. `EnterMode` is
942    /// emitted before the `SelectionChange`. The charwise selection's
943    /// head sits on the LAST byte of the default (12), so the host's
944    /// `selection_extent` spans exactly `[9, 13)`.
945    #[test]
946    fn cursor_effect_selects_non_empty_default_and_enters_select() {
947        let buffer = Buffer::from_text("for i in iter {}");
948        let group = TabstopGroup {
949            index: 1,
950            ranges: Vec::from([9..13]),
951            has_default: true,
952            is_choice: false,
953        };
954        match snippet_group_cursor_effect(&buffer, &group).expect("effect") {
955            Effect::Many(effects) => {
956                assert!(
957                    matches!(
958                        effects.first(),
959                        Some(Effect::EnterMode(ModalState::Select(VisualKind::Charwise)))
960                    ),
961                    "must enter Select FIRST so the selection arm adopts the span"
962                );
963                match effects.get(1) {
964                    Some(Effect::SelectionChange(set)) => {
965                        let p = set.primary();
966                        assert_eq!(p.anchor, Position::new(0, 9), "anchor = default start");
967                        assert_eq!(p.head, Position::new(0, 12), "head = default's last byte");
968                    }
969                    other => panic!("expected SelectionChange second, got {other:?}"),
970                }
971            }
972            other => panic!("expected Effect::Many, got {other:?}"),
973        }
974    }
975
976    /// SN.3d.3: an EMPTY tabstop (`$1`, zero-width range) keeps the bare
977    /// Insert cursor — no Select, nothing to overtype.
978    #[test]
979    fn cursor_effect_empty_tabstop_keeps_bare_cursor() {
980        let buffer = Buffer::from_text("for i in iter {}");
981        let group = TabstopGroup {
982            index: 1,
983            ranges: Vec::from([4..4]),
984            has_default: false,
985            is_choice: false,
986        };
987        // A bare cursor is expressed as `Effect::CursorMove`, NOT as a
988        // zero-width `SelectionChange`. This assertion previously
989        // expected the latter and went stale when the bare-cursor
990        // representation changed; the test's INTENT (its name — "keeps
991        // bare cursor") was always the behaviour below, so only the
992        // encoding is updated here, not what's being guarded.
993        match snippet_group_cursor_effect(&buffer, &group).expect("effect") {
994            Effect::CursorMove(pos) => assert_eq!(pos, Position::new(0, 4)),
995            other => panic!("expected a bare CursorMove, got {other:?}"),
996        }
997    }
998
999    /// A group with no ranges produces no cursor effect.
1000    #[test]
1001    fn cursor_effect_is_none_for_empty_group() {
1002        let buffer = Buffer::from_text("abc");
1003        let group = TabstopGroup {
1004            index: 0,
1005            ranges: vec![],
1006            has_default: false,
1007            is_choice: false,
1008        };
1009        assert!(snippet_group_cursor_effect(&buffer, &group).is_none());
1010    }
1011
1012    /// Minimal `BufferStore` for the handler-dispatch test. The
1013    /// nav handlers advance the session *before* consulting the
1014    /// store, so a `None`-returning `handle_for` still exercises
1015    /// the full session state-machine (the migrated decision
1016    /// logic); only the cursor `Effect` short-circuits — which is
1017    /// covered separately by `cursor_effect_targets_first_range_start`.
1018    #[derive(Debug)]
1019    struct NullBufferStore;
1020
1021    impl lattice_mode::BufferStore for NullBufferStore {
1022        fn find_by_name(&self, _name: &str) -> Option<lattice_core::BufferId> {
1023            None
1024        }
1025        fn name_for(&self, _id: lattice_core::BufferId) -> Option<String> {
1026            None
1027        }
1028        fn handle_for(
1029            &self,
1030            _id: lattice_core::BufferId,
1031        ) -> Option<Arc<dyn lattice_runtime::Document>> {
1032            None
1033        }
1034        fn insert_document_buffer(
1035            &self,
1036            _id: lattice_core::BufferId,
1037            _kind: lattice_core::BufferKind,
1038            _handle: Arc<dyn lattice_runtime::Document>,
1039            _flags: lattice_core::BufferFlags,
1040            _name: Option<String>,
1041        ) {
1042        }
1043    }
1044
1045    /// Build a `ServiceRegistry` wired the way the host boot does
1046    /// for the snippet surface: shared session, null buffer store,
1047    /// a command registry carrying the two nav action commands
1048    /// (so `id_by_name` resolves), and a fresh action-handler
1049    /// registry. Returns the registry plus the session +
1050    /// action-handler + command-id handles the test inspects.
1051    #[allow(clippy::type_complexity)]
1052    fn wire_services() -> (
1053        Arc<lattice_mode::ServiceRegistry>,
1054        SnippetSessionHandle,
1055        ActionHandlerRegistryHandle,
1056        lattice_protocol::ids::CommandId,
1057        lattice_protocol::ids::CommandId,
1058        lattice_protocol::ids::CommandId,
1059    ) {
1060        use lattice_grammar::{ActionSpec, CommandRegistry};
1061
1062        let session: SnippetSessionHandle = Arc::new(crate::session::SnippetSession::new());
1063        let store: Arc<dyn lattice_mode::BufferStore> = Arc::new(NullBufferStore);
1064        let action_handlers: ActionHandlerRegistryHandle =
1065            Arc::new(lattice_mode::ActionHandlerRegistry::new());
1066
1067        let mut cmd = CommandRegistry::new();
1068        let next_id = cmd.register_action(
1069            "action:snippet-next-placeholder",
1070            "next placeholder",
1071            ActionSpec {
1072                apply: Arc::new(|_| Ok(Effect::None)),
1073                args_schema: vec![],
1074            },
1075        );
1076        let prev_id = cmd.register_action(
1077            "action:snippet-prev-placeholder",
1078            "prev placeholder",
1079            ActionSpec {
1080                apply: Arc::new(|_| Ok(Effect::None)),
1081                args_schema: vec![],
1082            },
1083        );
1084        // SN.3c.2: the `<Esc>` leave handler keys on this id.
1085        let leave_id = cmd.register_action(
1086            "action:snippet-leave",
1087            "leave snippet",
1088            ActionSpec {
1089                apply: Arc::new(|_| Ok(Effect::None)),
1090                args_schema: vec![],
1091            },
1092        );
1093
1094        let mut services = lattice_mode::ServiceRegistry::new();
1095        services.register::<SnippetSessionHandle>(session.clone());
1096        services.register::<BufferStoreHandle>(BufferStoreHandle::new(store));
1097        services.register::<CommandRegistryHandle>(Arc::new(arc_swap::ArcSwap::from_pointee(cmd)));
1098        services.register::<ActionHandlerRegistryHandle>(action_handlers.clone());
1099
1100        (
1101            Arc::new(services),
1102            session,
1103            action_handlers,
1104            next_id,
1105            prev_id,
1106            leave_id,
1107        )
1108    }
1109
1110    /// Install a freshly-expanded `for ${1:i} in ${2:iter} { $0 }`
1111    /// session focused on `$1` (mirrors `Editor::expand_snippet`'s
1112    /// `focus_first` step), so `<Tab>` starts from `$1`.
1113    fn install_active_session(session: &SnippetSessionHandle) {
1114        // Keyed under the same buffer `fire`'s `ActionContext` uses
1115        // (`BufferId::new(1)` → `core_buffer_id` → core `BufferId(1)`),
1116        // so the handlers and the test reads agree on the buffer.
1117        install_active_session_in(session, lattice_core::BufferId(1));
1118    }
1119
1120    /// Install a fresh `for ${1:i} in ${2:iter} { $0 }` session in
1121    /// `buffer`, focused on `$1` (SN.3e multi-buffer tests).
1122    fn install_active_session_in(session: &SnippetSessionHandle, buffer: lattice_core::BufferId) {
1123        let body = crate::parse("for ${1:i} in ${2:iter} { $0 }").expect("parse");
1124        let rendered = crate::render::render(&body, &crate::VariableContext::default());
1125        let mut active = ActiveSnippet::from_render(&rendered, 0);
1126        active.focus_first();
1127        session.set(buffer, active);
1128    }
1129
1130    fn fire(
1131        handlers: &ActionHandlerRegistryHandle,
1132        id: lattice_protocol::ids::CommandId,
1133        services: &lattice_mode::ServiceRegistry,
1134    ) -> Option<Effect> {
1135        fire_in(
1136            handlers,
1137            id,
1138            services,
1139            lattice_protocol::ids::BufferId::new(1),
1140        )
1141    }
1142
1143    /// As [`fire`], but the synthetic `ActionContext` reports `buffer`
1144    /// — so a handler keys its session lookup on that buffer (SN.3e).
1145    fn fire_in(
1146        handlers: &ActionHandlerRegistryHandle,
1147        id: lattice_protocol::ids::CommandId,
1148        services: &lattice_mode::ServiceRegistry,
1149        buffer: lattice_protocol::ids::BufferId,
1150    ) -> Option<Effect> {
1151        // The snippet handlers read only `ctx.buffer_id` (captured
1152        // session + store come from `on_activate`), so a throwaway
1153        // events bus + the wired services satisfy the context.
1154        let events = lattice_runtime::EventBus::new();
1155        let ctx = ActionContext {
1156            buffer_id: buffer,
1157            cursor: Position::new(0, 0),
1158            selection: None,
1159            services,
1160            events: &events,
1161            prompt_value: None,
1162            args: lattice_grammar::Args::None,
1163            buffer_locals: None,
1164        };
1165        let handler = handlers.lookup(id).expect("handler registered");
1166        handler(&ctx)
1167    }
1168
1169    fn activate_mode(services: Arc<lattice_mode::ServiceRegistry>) -> SnippetActiveModeGuard {
1170        let ctx = lattice_mode::ModeContext::new(
1171            lattice_protocol::ids::BufferId::new(1),
1172            SnippetActiveMode::mode_id(),
1173            Arc::new(lattice_config::ConfigRegistry::new()),
1174            Arc::new(lattice_runtime::EventBus::new()),
1175            services,
1176        );
1177        lattice_runtime::block_on(SnippetActiveMode.on_activate(ctx)).expect("activate")
1178    }
1179
1180    /// `on_activate` registers exactly the three handlers (`<Tab>` /
1181    /// `<S-Tab>` nav + SN.3c.2's `<Esc>` leave), and dropping the
1182    /// Guard unregisters them all.
1183    #[test]
1184    fn on_activate_registers_three_handlers_and_drop_unregisters() {
1185        let (services, _session, handlers, next_id, prev_id, leave_id) = wire_services();
1186        let guard = activate_mode(services);
1187        assert_eq!(handlers.registered_count(), 3);
1188        assert!(handlers.lookup(next_id).is_some());
1189        assert!(handlers.lookup(prev_id).is_some());
1190        assert!(handlers.lookup(leave_id).is_some());
1191        drop(guard);
1192        assert_eq!(handlers.registered_count(), 0);
1193        assert!(handlers.lookup(next_id).is_none());
1194        assert!(handlers.lookup(leave_id).is_none());
1195    }
1196
1197    /// SN.3f: when a required service is absent (here:
1198    /// `CommandRegistryHandle`), `on_activate` skips handler
1199    /// registration entirely — the mode still activates (guard
1200    /// returned, keymap-scoping works) but no handlers land, and it
1201    /// logs a `debug!` on the skip path. Asserts the skip via the
1202    /// (present) action-handler registry staying empty.
1203    #[test]
1204    fn on_activate_skips_registration_when_a_service_is_absent() {
1205        let session: SnippetSessionHandle = Arc::new(crate::session::SnippetSession::new());
1206        let action_handlers: ActionHandlerRegistryHandle =
1207            Arc::new(lattice_mode::ActionHandlerRegistry::new());
1208        let mut services = lattice_mode::ServiceRegistry::new();
1209        services.register::<SnippetSessionHandle>(session);
1210        services.register::<ActionHandlerRegistryHandle>(action_handlers.clone());
1211        // No `CommandRegistryHandle` registered → the `if let` fails →
1212        // the skip branch runs.
1213        let _guard = activate_mode(Arc::new(services));
1214        assert_eq!(action_handlers.registered_count(), 0);
1215    }
1216
1217    /// `<Tab>` walks `$1 -> $2 -> $0`, then a fourth fire walks off
1218    /// `$0` and ends the session — the migrated
1219    /// `do_snippet_next_placeholder` behaviour.
1220    #[test]
1221    fn next_handler_walks_through_groups_and_drops_on_zero() {
1222        let (services, session, handlers, next_id, _prev_id, _leave_id) = wire_services();
1223        let _guard = activate_mode(services.clone());
1224        install_active_session(&session);
1225
1226        let idx = |s: &SnippetSessionHandle| {
1227            s.with_mut(lattice_core::BufferId(1), |o| {
1228                o.as_ref().and_then(ActiveSnippet::current_index)
1229            })
1230        };
1231        assert_eq!(idx(&session), Some(1)); // focused on $1
1232        fire(&handlers, next_id, &services);
1233        assert_eq!(idx(&session), Some(2));
1234        fire(&handlers, next_id, &services);
1235        assert_eq!(idx(&session), Some(0)); // $0 exit tabstop
1236        fire(&handlers, next_id, &services);
1237        assert!(!session.is_active(lattice_core::BufferId(1))); // walked off $0 -> session ended
1238    }
1239
1240    /// `<S-Tab>` walks back a placeholder and never ends the
1241    /// session — the migrated `do_snippet_prev_placeholder`.
1242    #[test]
1243    fn prev_handler_walks_back() {
1244        let (services, session, handlers, next_id, prev_id, _leave_id) = wire_services();
1245        let _guard = activate_mode(services.clone());
1246        install_active_session(&session);
1247
1248        let idx = |s: &SnippetSessionHandle| {
1249            s.with_mut(lattice_core::BufferId(1), |o| {
1250                o.as_ref().and_then(ActiveSnippet::current_index)
1251            })
1252        };
1253        fire(&handlers, next_id, &services);
1254        assert_eq!(idx(&session), Some(2));
1255        fire(&handlers, prev_id, &services);
1256        assert_eq!(idx(&session), Some(1));
1257        assert!(session.is_active(lattice_core::BufferId(1)));
1258    }
1259
1260    /// With no live session the handlers are inert (no panic, no
1261    /// effect, session stays empty).
1262    #[test]
1263    fn handlers_are_noop_without_an_active_session() {
1264        let (services, session, handlers, next_id, prev_id, _leave_id) = wire_services();
1265        let _guard = activate_mode(services.clone());
1266        assert!(!session.is_active(lattice_core::BufferId(1)));
1267        assert!(fire(&handlers, next_id, &services).is_none());
1268        assert!(fire(&handlers, prev_id, &services).is_none());
1269        assert!(!session.is_active(lattice_core::BufferId(1)));
1270    }
1271
1272    /// SN.3c.2b: `<Esc>` (leave) clears the live session and returns
1273    /// `Effect::None` — the *only* job of the mode handler. Exiting
1274    /// insert is not its concern: the `<Esc>` binding is
1275    /// `fall_through: true`, so the dispatcher continues to the native
1276    /// `<Esc>` after this handler runs (covered by the host-level
1277    /// `dispatch_insert` fall-through test). The host's overlay
1278    /// reconciler sees `is_active() == false` next cycle and
1279    /// deactivates the mode.
1280    #[test]
1281    fn leave_handler_clears_session() {
1282        let (services, session, handlers, _next_id, _prev_id, leave_id) = wire_services();
1283        let _guard = activate_mode(services.clone());
1284        install_active_session(&session);
1285        assert!(session.is_active(lattice_core::BufferId(1)));
1286
1287        let effect = fire(&handlers, leave_id, &services);
1288        assert!(matches!(effect, Some(Effect::None)));
1289        assert!(!session.is_active(lattice_core::BufferId(1))); // session cleared
1290    }
1291
1292    /// SN.3e: two buffers each carry their own live session. `<Tab>`
1293    /// fired in buffer A advances A's tabstops only — B's session is
1294    /// untouched, and clearing one leaves the other live. Before SN.3e
1295    /// the single global slot misrouted this: starting a snippet in A
1296    /// then acting in B drove `<Tab>` against A's tabstops.
1297    #[test]
1298    fn sessions_are_isolated_per_buffer() {
1299        use lattice_core::BufferId as Core;
1300        use lattice_protocol::ids::BufferId as Proto;
1301
1302        let (services, session, handlers, next_id, _prev_id, _leave_id) = wire_services();
1303        let _guard = activate_mode(services.clone());
1304
1305        // Two buffers, two independent sessions, both focused on `$1`.
1306        install_active_session_in(&session, Core(1));
1307        install_active_session_in(&session, Core(2));
1308
1309        let idx = |s: &SnippetSessionHandle, b: Core| {
1310            s.with_mut(b, |o| o.as_ref().and_then(ActiveSnippet::current_index))
1311        };
1312        assert_eq!(idx(&session, Core(1)), Some(1));
1313        assert_eq!(idx(&session, Core(2)), Some(1));
1314
1315        // Advance A (`<Tab>` with `ctx.buffer_id` = proto 1). The
1316        // handler keys `with_mut` on core 1; B (core 2) is untouched.
1317        fire_in(&handlers, next_id, &services, Proto::new(1));
1318        assert_eq!(idx(&session, Core(1)), Some(2), "A advanced");
1319        assert_eq!(idx(&session, Core(2)), Some(1), "B never moved");
1320
1321        // Switch to B and advance — A stays put.
1322        fire_in(&handlers, next_id, &services, Proto::new(2));
1323        assert_eq!(idx(&session, Core(2)), Some(2), "B advanced");
1324        assert_eq!(idx(&session, Core(1)), Some(2), "A unchanged");
1325
1326        // Clearing A leaves B's session live.
1327        session.clear(Core(1));
1328        assert!(!session.is_active(Core(1)));
1329        assert!(session.is_active(Core(2)));
1330    }
1331}