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}