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}