Skip to main content

lattice_mode/
action_handler_registry.rs

1//! Action-handler registry — mode-contributed closures
2//! per `CommandId` (M.10.1).
3//!
4//! Per `feedback_mode_owns_its_surface` (CLAUDE.md standing
5//! rules, sharpened 2026-06-02) + `mode-architecture.md` §5.3,
6//! a mode owns BOTH the chord choice AND the action handler
7//! body. The keymap layer is contributed by `Mode::keymap()`;
8//! the handler closure is registered into this substrate from
9//! `Mode::on_activate`. The host's chord-resolved-action
10//! dispatcher consults this registry; the handler returns an
11//! `Effect` the host applies through the existing pipeline.
12//!
13//! Half-migration failure mode (the M.10 audit caught this):
14//! keymap layer in the mode but `Editor::do_<provider>_action`
15//! body in `lattice-host::dispatch`. This substrate exists so
16//! the handler body lives in the mode's owning crate, not the
17//! host.
18//!
19//! ## Shape
20//!
21//! Wait-free lookup via `arc-swap`. Register / unregister
22//! perform a copy-on-write via `ArcSwap::rcu` — O(N) per
23//! lifecycle event but registers are rare (per mode
24//! activation / deactivation, not per keystroke). Lookups
25//! happen per chord-dispatch — one `Arc` load + one
26//! `HashMap::get`.
27//!
28//! ## Lifecycle
29//!
30//! `register` returns an [`ActionHandlerRegistration`] RAII
31//! token. The mode's `Guard` carries one token per registered
32//! action; dropping the Guard drops the tokens, each of which
33//! unregisters its `CommandId`. Re-activation re-registers
34//! fresh closures.
35
36use std::collections::HashMap;
37use std::sync::Arc;
38
39use arc_swap::ArcSwap;
40use lattice_grammar::effect::Effect;
41use lattice_grammar::{ArgValue, Args};
42use lattice_protocol::ids::{BufferId, CommandId};
43use lattice_protocol::position::Position;
44use lattice_runtime::EventBus;
45
46use crate::services::ServiceRegistry;
47
48/// Read-only context handed to a mode-contributed action
49/// handler closure. Carries the bare minimum the host can
50/// supply on every invocation: the active buffer + cursor and
51/// shared subsystem registries the handler may need.
52///
53/// Borrows from App-owned state for the duration of the
54/// action dispatch — handlers do NOT outlive their context.
55pub struct ActionContext<'a> {
56    /// Active buffer at the moment the chord fired.
57    pub buffer_id: BufferId,
58    /// Active document cursor at the moment the chord fired.
59    pub cursor: Position,
60    /// The **active region** — the Visual/Select-mode selection
61    /// extent, normalised so `start <= end`. `None` in Normal mode and
62    /// on every non-chord firing path (prompt submit, transient item,
63    /// a `Confirm` yes-action) (MG.18e).
64    ///
65    /// Design §5.2's "Visual mode IS the active region" applied to mode
66    /// action handlers: a chord that fires with a selection up should
67    /// be able to act on it, the same way `Range::Selection` is the
68    /// default range argument for an ex-command. magit's region staging
69    /// is the first consumer — select some lines inside a hunk, press
70    /// `s`, stage only those.
71    ///
72    /// **Carries no visual kind.** A diff line is the unit of every
73    /// consumer so far, and the row span is all they read; adding
74    /// charwise/blockwise distinctions before something needs them
75    /// would be inventing a contract nobody is holding.
76    pub selection: Option<lattice_protocol::position::Range>,
77    /// Typed service registry. Handlers look up subsystem
78    /// handles they need (`MultibufferRegistryHandle`,
79    /// `ProjectSearchServiceHandle`, etc.) via
80    /// `ctx.services.get::<Foo>()`.
81    pub services: &'a ServiceRegistry,
82    /// Typed event bus. Handlers publish events that other
83    /// subsystems subscribe to (e.g.
84    /// `ProjectSearchRefreshed`).
85    pub events: &'a EventBus,
86    /// Set only when this handler fires as the submit callback of an
87    /// `Effect::OpenPrompt`-opened prompt (`Editor::do_prompt_line_submit`)
88    /// — the prompt buffer's typed content at the moment of submit.
89    /// `None` for every other firing path (chord dispatch, transient
90    /// item click, `Effect::Confirm`'s yes-action, ...).
91    pub prompt_value: Option<&'a str>,
92    /// Arguments the invocation carried (MG.17a).
93    ///
94    /// `Args::None` for a bare chord press. A transient item's flags
95    /// and arguments arrive here as `Args::List`, ordered by the
96    /// action's `args_schema` — the same shape the `:` line produces
97    /// for an ex-command, so one handler body serves both front-ends
98    /// instead of each surface growing its own accessor. Read it with
99    /// [`Self::flag`] / [`Self::arg_str`] rather than matching the
100    /// list positionally.
101    pub args: Args,
102    /// The active buffer's typed mode-owned locals, read-only, for
103    /// the duration of the dispatch. `Some` on the host chord-dispatch
104    /// path (where a mode handler may need to read its buffer's state —
105    /// oil's dir/snapshot, a file tree's entries — to resolve the entry
106    /// under the cursor); `None` on the auxiliary firing paths (prompt
107    /// submit, transient item, a `Confirm` yes-action) and wherever a
108    /// caller builds a context without a buffer-locals store (LM.1).
109    ///
110    /// Read it through [`Self::buffer_local`] rather than the field, so a
111    /// handler that runs with `None` degrades to "no such local" — the
112    /// same answer as an unseeded buffer — instead of a branch.
113    ///
114    /// Keeping the state in `buffer_locals` (rather than a separate
115    /// service) is deliberate: it stays enumerable by `:describe-buffer`
116    /// via `iter_descriptors`, so a mode owning per-buffer state does not
117    /// trade introspection for handler-reachability.
118    pub buffer_locals: Option<&'a crate::locals::BufferLocals>,
119}
120
121impl<'a> ActionContext<'a> {
122    /// Read one of the active buffer's mode-owned locals, if the
123    /// context carries a buffer-locals store and the local is seeded (LM.1).
124    ///
125    /// Total: `None` covers a context built without locals (an auxiliary
126    /// firing path) and a buffer that never seeded `T`, which a handler
127    /// treats the same way — there is nothing to act on.
128    pub fn buffer_local<T: crate::locals::BufferLocal>(&self) -> Option<&T> {
129        self.buffer_locals.and_then(|l| l.get::<T>())
130    }
131}
132
133impl ActionContext<'_> {
134    /// The boolean argument at `index` in the action's `args_schema`.
135    ///
136    /// `false` when the invocation carried no args (a bare chord), when
137    /// the index is past the end, or when that slot holds a non-bool —
138    /// a handler asking "was `--force` set?" wants `false` for all
139    /// three, not three different error paths.
140    pub fn flag(&self, index: usize) -> bool {
141        match self.args.as_list().and_then(|l| l.get(index)) {
142            Some(ArgValue::Bool(b)) => *b,
143            _ => false,
144        }
145    }
146
147    /// The project this action is acting in (PR.2).
148    ///
149    /// Design: `docs/dev/architecture/project-resolution.md` §5. This is
150    /// a method rather than a field so that "which project" is a
151    /// question every action handler can ask without the host having to
152    /// answer it on every dispatch — resolution is cached and most
153    /// handlers never ask.
154    ///
155    /// **Total**, matching `ProjectResolver::for_path`: a handler that
156    /// cannot express "no project" cannot get it wrong. The degradations
157    /// are both real answers, not errors:
158    ///
159    /// - a buffer with no path (scratch, `*messages*`, a terminal) has
160    ///   no tree to walk, so the working directory stands in;
161    /// - a partially-wired harness with no resolver registered falls
162    ///   back to the process working directory, which is what every
163    ///   consumer did before this existed.
164    pub fn project(&self) -> lattice_core::Project {
165        let Some(resolver) = self.services.get::<lattice_core::ProjectResolverHandle>() else {
166            // Not `warn!`: a test harness that wires only the services
167            // it exercises is the common caller, and this is exactly
168            // the pre-PR.2 behaviour rather than a degradation.
169            tracing::debug!("no project resolver registered; falling back to the process cwd");
170            return lattice_core::Project {
171                root: std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
172                kind: lattice_core::ProjectKind::Pwd,
173            };
174        };
175        // `ActionContext` carries a `lattice_protocol::BufferId`;
176        // `BufferStore` speaks `lattice_core::BufferId`. Distinct types
177        // over the same u32 — the `repl_mode` conversion precedent.
178        let path = self
179            .services
180            .get::<crate::buffer_store::BufferStoreHandle>()
181            .and_then(|store| store.path_for(lattice_core::BufferId(self.buffer_id.0 as u32)));
182        match path {
183            Some(path) => resolver.for_path(&path),
184            // No path to walk from. `for_path` on a relative empty path
185            // would resolve against pwd anyway, but going through the
186            // resolver keeps the pwd answer consistent with `:cd`
187            // rather than reading the process cwd directly.
188            None => resolver.for_path(std::path::Path::new("")),
189        }
190    }
191
192    /// The string argument at `index`, or `None` when absent or empty.
193    /// Empty is treated as absent because a transient argument left at
194    /// its default renders as an empty string.
195    pub fn arg_str(&self, index: usize) -> Option<&str> {
196        match self.args.as_list().and_then(|l| l.get(index)) {
197            Some(ArgValue::String(s)) | Some(ArgValue::Raw(s)) if !s.is_empty() => Some(s.as_str()),
198            _ => None,
199        }
200    }
201}
202
203/// Action handler closure shape. Returns an [`Effect`] when
204/// the handler wants the host to apply state mutation
205/// (open a file, change selection, etc.); `None` for
206/// fire-and-forget handlers (logging, publishing an event).
207///
208/// Closures are `Send + Sync` so the registry can be cloned
209/// freely across threads; they take a borrowed
210/// [`ActionContext`] for zero-copy access to live state.
211pub type ActionHandler = Arc<dyn Fn(&ActionContext<'_>) -> Option<Effect> + Send + Sync + 'static>;
212
213/// A *global* (buffer-agnostic) action-handler
214/// contribution declared by a mode via
215/// [`Mode::action_handlers`](crate::Mode::action_handlers) (SN.3c.0).
216///
217/// Use this for handlers whose body reads the active buffer /
218/// cursor / services from the [`ActionContext`] at call time and
219/// closes over no per-buffer state — they are registered ONCE at
220/// boot (the host walks every mode's `action_handlers()`,
221/// resolves `action_name` → `CommandId`, and registers the
222/// handler), and live for the app's lifetime. The owning chord's
223/// per-keystroke mode gating (K.1.c) still scopes *where the
224/// chord fires*; the handler itself is global.
225///
226/// Per-buffer handlers (tied to a live session / per-activation
227/// state) must NOT use this — they register in
228/// [`Mode::on_activate`](crate::Mode::on_activate) so their
229/// `ActionHandlerRegistration` token drops with the mode's Guard.
230/// See `feedback_effect_vocabulary_is_host_boundary`.
231#[derive(Clone)]
232pub struct ActionHandlerContribution {
233    /// Canonical command name (e.g. `"action:snippet-expand"`).
234    /// The host resolves this to a `CommandId` via the command
235    /// registry at registration time.
236    pub action_name: &'static str,
237    /// The handler closure registered for `action_name`.
238    pub handler: ActionHandler,
239}
240
241impl std::fmt::Debug for ActionHandlerContribution {
242    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
243        f.debug_struct("ActionHandlerContribution")
244            .field("action_name", &self.action_name)
245            .finish_non_exhaustive()
246    }
247}
248
249/// Typed handle for `ServiceRegistry`
250/// lookup. Boot registers a fresh `ActionHandlerRegistry` under
251/// this alias; modes pull it from `on_activate` via
252/// `ctx.service::<ActionHandlerRegistryHandle>()` (M.10.1.b, 2026-06-03).
253///
254/// Per `feedback_servicesregistry_arc_typeid`: register and
255/// lookup MUST use the same `T` for the TypeId hash to match.
256/// This alias guarantees the convention.
257pub type ActionHandlerRegistryHandle = Arc<ActionHandlerRegistry>;
258
259/// Wait-free registry of mode-contributed action handlers.
260///
261/// Stored behind an `Arc` and shared by reference: the host's
262/// chord dispatcher reads via [`lookup`](Self::lookup);
263/// modes register via [`register`](Self::register) during
264/// `Mode::on_activate` and unregister via the returned
265/// [`ActionHandlerRegistration`] token's `Drop` impl.
266///
267/// # Examples
268///
269/// The per-buffer path: a mode registers in `on_activate` and keeps the token
270/// in its Guard, so deactivation unregisters the body.
271///
272/// ```
273/// use std::sync::Arc;
274/// use lattice_grammar::effect::{EchoLevel, Effect};
275/// use lattice_mode::{ActionHandler, ActionHandlerRegistry};
276/// use lattice_protocol::ids::CommandId;
277///
278/// let registry = Arc::new(ActionHandlerRegistry::new());
279/// let refresh = CommandId::new(41); // resolved from "action:weather-refresh"
280///
281/// let body: ActionHandler = Arc::new(|ctx| {
282///     let text = format!("refreshing buffer {}", ctx.buffer_id);
283///     Some(Effect::Echo { level: EchoLevel::Info, text })
284/// });
285/// let token = registry.register(refresh, body); // lives in the mode's Guard
286/// assert!(registry.lookup(refresh).is_some());
287///
288/// drop(token); // the Guard dropped: the mode deactivated
289/// assert!(registry.lookup(refresh).is_none());
290/// ```
291pub struct ActionHandlerRegistry {
292    handlers: ArcSwap<HashMap<CommandId, ActionHandler>>,
293}
294
295impl ActionHandlerRegistry {
296    /// Construct an empty registry.
297    pub fn new() -> Self {
298        Self {
299            handlers: ArcSwap::from_pointee(HashMap::new()),
300        }
301    }
302
303    /// Register an action handler closure for `action_id`.
304    /// Returns an RAII registration token; the token's `Drop`
305    /// impl unregisters the handler. Modes accumulate tokens
306    /// in their `Guard` so deactivation drops them and the
307    /// chord falls through to "unhandled" naturally.
308    ///
309    /// If a handler is already registered for `action_id`,
310    /// the new closure replaces it. (Modes shouldn't collide
311    /// on `CommandId` in normal usage — IDs are per-action
312    /// and modes register distinct sets — but the
313    /// last-write-wins semantics keeps the substrate
314    /// behavior well-defined.)
315    pub fn register(
316        self: &Arc<Self>,
317        action_id: CommandId,
318        handler: ActionHandler,
319    ) -> ActionHandlerRegistration {
320        self.handlers.rcu(|map| {
321            let mut next = (**map).clone();
322            next.insert(action_id, handler.clone());
323            next
324        });
325        ActionHandlerRegistration {
326            registry: Arc::clone(self),
327            action_id,
328        }
329    }
330
331    /// Wait-free lookup. Returns a cloned `Arc` of the
332    /// handler closure (cheap — `Arc::clone` is one atomic
333    /// increment). The host's chord dispatcher calls this
334    /// once per chord resolution; calling the returned
335    /// handler is a direct `Fn` invocation.
336    pub fn lookup(&self, action_id: CommandId) -> Option<ActionHandler> {
337        self.handlers.load().get(&action_id).cloned()
338    }
339
340    /// Direct unregister. Normally called via
341    /// [`ActionHandlerRegistration::drop`]; exposed for
342    /// callers that need explicit lifetime control (e.g.
343    /// tests).
344    fn unregister(&self, action_id: CommandId) {
345        self.handlers.rcu(|map| {
346            let mut next = (**map).clone();
347            next.remove(&action_id);
348            next
349        });
350    }
351
352    /// Number of currently registered handlers. Test
353    /// affordance — production code shouldn't need this.
354    #[doc(hidden)]
355    pub fn registered_count(&self) -> usize {
356        self.handlers.load().len()
357    }
358}
359
360impl Default for ActionHandlerRegistry {
361    fn default() -> Self {
362        Self::new()
363    }
364}
365
366impl std::fmt::Debug for ActionHandlerRegistry {
367    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
368        f.debug_struct("ActionHandlerRegistry")
369            .field("registered_count", &self.registered_count())
370            .finish_non_exhaustive()
371    }
372}
373
374/// RAII registration token. Dropping it unregisters the
375/// handler. Modes typically aggregate these into their
376/// `Mode::Guard`; when the Guard drops on deactivation, every
377/// token drops, every handler unregisters.
378///
379/// `Send + 'static` so it fits the `Mode::Guard: Send +
380/// 'static` bound.
381pub struct ActionHandlerRegistration {
382    registry: Arc<ActionHandlerRegistry>,
383    action_id: CommandId,
384}
385
386impl ActionHandlerRegistration {
387    /// The `CommandId` this registration is bound to.
388    /// Test affordance.
389    #[doc(hidden)]
390    pub fn action_id(&self) -> CommandId {
391        self.action_id
392    }
393}
394
395impl Drop for ActionHandlerRegistration {
396    fn drop(&mut self) {
397        self.registry.unregister(self.action_id);
398    }
399}
400
401impl std::fmt::Debug for ActionHandlerRegistration {
402    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
403        f.debug_struct("ActionHandlerRegistration")
404            .field("action_id", &self.action_id)
405            .finish_non_exhaustive()
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    #![allow(clippy::unwrap_used)]
412    use super::*;
413
414    fn cid(raw: u64) -> CommandId {
415        CommandId::new(raw)
416    }
417
418    fn handler_returning_none() -> ActionHandler {
419        Arc::new(|_ctx: &ActionContext<'_>| None)
420    }
421
422    #[test]
423    fn register_then_lookup_returns_handler() {
424        let r = Arc::new(ActionHandlerRegistry::new());
425        let id = cid(1);
426        let _reg = r.register(id, handler_returning_none());
427        assert!(r.lookup(id).is_some());
428    }
429
430    #[test]
431    fn missing_lookup_returns_none() {
432        let r = Arc::new(ActionHandlerRegistry::new());
433        assert!(r.lookup(cid(42)).is_none());
434    }
435
436    #[test]
437    fn drop_registration_unregisters_handler() {
438        let r = Arc::new(ActionHandlerRegistry::new());
439        let id = cid(7);
440        let reg = r.register(id, handler_returning_none());
441        assert_eq!(r.registered_count(), 1);
442        drop(reg);
443        assert_eq!(r.registered_count(), 0);
444        assert!(r.lookup(id).is_none());
445    }
446
447    #[test]
448    fn multiple_handlers_coexist_and_drop_independently() {
449        let r = Arc::new(ActionHandlerRegistry::new());
450        let reg_a = r.register(cid(1), handler_returning_none());
451        let reg_b = r.register(cid(2), handler_returning_none());
452        let _reg_c = r.register(cid(3), handler_returning_none());
453        assert_eq!(r.registered_count(), 3);
454        drop(reg_a);
455        assert_eq!(r.registered_count(), 2);
456        assert!(r.lookup(cid(1)).is_none());
457        assert!(r.lookup(cid(2)).is_some());
458        assert!(r.lookup(cid(3)).is_some());
459        drop(reg_b);
460        assert_eq!(r.registered_count(), 1);
461    }
462
463    #[test]
464    fn second_register_for_same_id_replaces_previous() {
465        let r = Arc::new(ActionHandlerRegistry::new());
466        let id = cid(10);
467
468        // First handler always returns None; second returns
469        // a sentinel Effect we can detect.
470        let _reg_first = r.register(id, handler_returning_none());
471        let _reg_second = r.register(id, Arc::new(|_ctx: &ActionContext<'_>| Some(Effect::None)));
472
473        let h = r.lookup(id).unwrap();
474        let services = ServiceRegistry::new();
475        let events = EventBus::new();
476        let ctx = ActionContext {
477            buffer_id: BufferId::new(0),
478            cursor: Position::ZERO,
479            selection: None,
480            services: &services,
481            events: &events,
482            prompt_value: None,
483            args: lattice_grammar::Args::None,
484            buffer_locals: None,
485        };
486        let effect = h(&ctx);
487        assert!(matches!(effect, Some(Effect::None)));
488    }
489
490    /// LM.1: a mode handler reads its buffer's mode-owned local through
491    /// `ctx.buffer_local::<T>()`, and the accessor degrades to `None` on a
492    /// firing path that carries no locals — the mechanism the oil /
493    /// file-tree navigation handlers (LM.3/LM.4) rely on to resolve the
494    /// entry under the cursor.
495    #[test]
496    fn buffer_local_reads_the_active_buffers_local_and_degrades_to_none() {
497        use crate::locals::BufferLocals;
498        let services = ServiceRegistry::new();
499        let events = EventBus::new();
500
501        let mut locals = BufferLocals::default();
502        locals.insert(crate::BufferScopeDir(std::path::PathBuf::from("/scope")));
503
504        let ctx = ActionContext {
505            buffer_id: BufferId::new(0),
506            cursor: Position::ZERO,
507            selection: None,
508            services: &services,
509            events: &events,
510            prompt_value: None,
511            args: lattice_grammar::Args::None,
512            buffer_locals: Some(&locals),
513        };
514        assert_eq!(
515            ctx.buffer_local::<crate::BufferScopeDir>()
516                .map(|d| d.0.clone()),
517            Some(std::path::PathBuf::from("/scope")),
518            "a handler reads its buffer's mode-owned local through the context",
519        );
520
521        // An auxiliary firing path (prompt submit, transient) carries no
522        // locals; the accessor answers None rather than panicking.
523        let ctx_none = ActionContext {
524            buffer_id: BufferId::new(0),
525            cursor: Position::ZERO,
526            selection: None,
527            services: &services,
528            events: &events,
529            prompt_value: None,
530            args: lattice_grammar::Args::None,
531            buffer_locals: None,
532        };
533        assert!(ctx_none.buffer_local::<crate::BufferScopeDir>().is_none());
534    }
535
536    #[test]
537    fn registration_is_send_static() {
538        fn assert_send_static<T: Send + 'static>() {}
539        assert_send_static::<ActionHandlerRegistration>();
540    }
541
542    #[test]
543    fn registry_is_send_sync() {
544        fn assert_send_sync<T: Send + Sync>() {}
545        assert_send_sync::<ActionHandlerRegistry>();
546        assert_send_sync::<ActionHandler>();
547    }
548
549    #[test]
550    fn lookup_after_drop_chain_is_consistent() {
551        // Stress: register / drop interleavings preserve
552        // exact membership semantics.
553        let r = Arc::new(ActionHandlerRegistry::new());
554        let regs: Vec<_> = (0..16)
555            .map(|i| r.register(cid(i), handler_returning_none()))
556            .collect();
557        assert_eq!(r.registered_count(), 16);
558        // Drop every other registration.
559        let (keep, drop_these): (Vec<_>, Vec<_>) =
560            regs.into_iter().enumerate().partition(|(i, _)| i % 2 == 0);
561        drop(drop_these);
562        assert_eq!(r.registered_count(), 8);
563        for (i, _) in &keep {
564            assert!(r.lookup(cid(*i as u64)).is_some());
565        }
566        for i in (0..16).filter(|i| i % 2 != 0) {
567            assert!(r.lookup(cid(i)).is_none());
568        }
569    }
570}