Skip to main content

lattice_host/
keymap_mode_contributions.rs

1//! K.2.4 — host translation pass for mode-contributed keymaps.
2//!
3//! Walks the [`ModeRegistry`] (boot path) or a single newly-
4//! registered mode (dynamic path) and merges each mode's
5//! [`Keymap`] contribution into the host's [`KeymapHandle`]
6//! at `KeymapLayer::MinorMode(mode.id())`.
7//!
8//! See [`keymap-architecture.md` §11.3] for the design
9//! contract. The K.2 substrate (K.2.1 chord primitives, K.2.2
10//! `BindingMode`, K.2.3 real `Keymap`, K.2.4.A.0.1 catalog
11//! relocation, K.2.4.A.0.2 entry-form `Keymap::from_entries`)
12//! is in place; this slice is the plumbing that calls
13//! `Mode::keymap()`, resolves any table-form entries against
14//! the [`CommandRegistry`], and inserts the result into the
15//! matcher trie. Once K.2.5 promotes the multibuffer +
16//! project-search bindings into their owning mode crates, this
17//! pass is what makes those bindings reachable; until then it
18//! runs over the registry without finding any non-default
19//! `Keymap`s (every mode still returns `Keymap::default()`).
20//!
21//! ## Resolution model
22//!
23//! Two declaration paths share the same insertion path:
24//!
25//! - **Chain form** (`Keymap::bindings`): already typed
26//!   [`KeymapBinding`]s carrying a [`CommandInvocation`]. Used
27//!   directly.
28//! - **Table form** (`Keymap::entries`): static-catalog rows
29//!   built via the [`keymap_entry!`] macro. Each entry carries
30//!   a canonical command-name string (`"motion:line-down"`,
31//!   …). The pass resolves the name against the
32//!   [`CommandRegistry`] at registration time to mint a
33//!   [`CommandInvocation`]; the entry's `doc`, `source`, and
34//!   parsed chord flow into the resulting [`KeymapBinding`].
35//!   Entries whose name doesn't resolve log a
36//!   `tracing::warn!` and skip (matches the existing
37//!   catalog-drift convention); entries with
38//!   `command = None` (synthetic actions like `PushDigit`)
39//!   skip silently — they're informational catalog rows for
40//!   `:describe-key` and `:keymap`, not dispatchable bindings.
41//!
42//! [`keymap-architecture.md` §11.3]: docs/dev/architecture/keymap-architecture.md
43
44use std::collections::HashMap;
45use std::sync::Arc;
46
47use lattice_grammar::{CommandInvocation, CommandRegistry};
48use lattice_mode::{BindingMode, DynMode, KeymapBinding, ModeId, ModeRegistry};
49use lattice_protocol::ChordPattern;
50
51use crate::keymap_registry::{KeymapHandle, PushLayerKind};
52use crate::keymap_trie::{BoundCommand, KeymapLayer, KeymapTrie};
53
54/// Walk every mode in `registry`, call `Mode::keymap()`, and
55/// push each non-empty contribution as a `MinorMode(mode_id)`
56/// layer on `handle`.
57///
58/// Boot-path entry. Runs exactly once at editor boot, after
59/// the `ModeRegistry` is fully populated (so every mode that
60/// registered before boot completion contributes its bindings
61/// before the first keystroke). Modes whose `keymap()` returns
62/// `Keymap::default()` (the empty contribution) are skipped —
63/// no layer is pushed for them, so registry merge cost stays
64/// O(modes-with-bindings), not O(all-modes).
65///
66/// `command_registry` is needed to resolve any table-form
67/// entries (`Keymap::entries`) into typed
68/// [`CommandInvocation`]s; chain-form bindings
69/// (`Keymap::bindings`) skip the registry walk because they
70/// already carry an invocation.
71///
72/// Re-running the pass is safe (`push_layer` for a
73/// `MinorMode(mode_id)` is idempotent-on-identity per K.1.b:
74/// re-pushing the same `mode_id` replaces the layer's
75/// bindings rather than minting a sibling).
76pub fn translate_mode_keymaps(
77    handle: &KeymapHandle,
78    registry: &ModeRegistry,
79    command_registry: &CommandRegistry,
80) {
81    for (mode_id, mode) in registry.iter() {
82        push_mode_keymap(handle, mode_id, &mode, command_registry);
83    }
84}
85
86/// Translate one mode's `Keymap` contribution and push it as
87/// a layer. Symmetric with [`translate_mode_keymaps`] but
88/// scoped to a single mode — the entry point a future plugin
89/// host or dynamic `ModeRegistry::register` call site uses to
90/// hot-attach a newly-loaded mode without re-walking the full
91/// registry.
92///
93/// Today no production caller exists for the dynamic path
94/// (plugins are post-1.0); the helper is provided here so the
95/// translation logic has one shape rather than two
96/// drift-prone copies.
97pub fn translate_mode_keymap(
98    handle: &KeymapHandle,
99    mode_id: ModeId,
100    mode: &Arc<dyn DynMode>,
101    command_registry: &CommandRegistry,
102) {
103    push_mode_keymap(handle, mode_id, mode, command_registry);
104}
105
106fn push_mode_keymap(
107    handle: &KeymapHandle,
108    mode_id: ModeId,
109    mode: &Arc<dyn DynMode>,
110    command_registry: &CommandRegistry,
111) {
112    let keymap = mode.keymap();
113    if keymap.bindings.is_empty() && keymap.entries.is_empty() {
114        return;
115    }
116    // Concatenate chain-form bindings with resolved table-form
117    // entries so a single grouping pass writes one trie per
118    // BindingMode regardless of which path each binding came
119    // through.
120    let mut all_bindings: Vec<KeymapBinding> = keymap.bindings.clone();
121    all_bindings.extend(resolve_entries_into_bindings(
122        handle,
123        &keymap.entries,
124        command_registry,
125    ));
126    if all_bindings.is_empty() {
127        // Every entry was either synthetic (`command = None`)
128        // or unresolvable against the registry. Don't push an
129        // empty layer.
130        return;
131    }
132
133    let layer = match mode.kind() {
134        lattice_mode::ModeKind::Major => KeymapLayer::MajorMode(mode_id),
135        lattice_mode::ModeKind::Minor => KeymapLayer::MinorMode(mode_id),
136    };
137
138    let bindings_by_mode = group_bindings_into_tries(&all_bindings, layer);
139
140    let push_layer = match mode.kind() {
141        lattice_mode::ModeKind::Major => PushLayerKind::MajorMode(mode_id),
142        lattice_mode::ModeKind::Minor => PushLayerKind::MinorMode(mode_id),
143    };
144
145    handle.push_layer(push_layer, format!("{mode_id}"), bindings_by_mode);
146}
147
148/// Group a slice of bindings into one [`KeymapTrie`] per
149/// [`BindingMode`], wrapping each `KeymapBinding` in a
150/// [`BoundCommand`] at given [`KeymapLayer`]
151///
152/// Factored out so unit tests can exercise the grouping +
153/// `BoundCommand` construction without going through the
154/// registry mutex. Operates on a flat `&[KeymapBinding]`
155/// (not `&Keymap`) so [`push_mode_keymap`] can pre-flatten the
156/// chain form + resolved table form into one list.
157fn group_bindings_into_tries(
158    bindings: &[KeymapBinding],
159    layer: KeymapLayer,
160) -> HashMap<BindingMode, KeymapTrie> {
161    let mut by_mode: HashMap<BindingMode, KeymapTrie> = HashMap::new();
162    for binding in bindings {
163        let bound = Arc::new(
164            BoundCommand::from_invocation(binding.command.clone(), binding.source.clone(), layer)
165                // SN.3c.2b: propagate augment-and-continue onto the stored
166                // BoundCommand the lookup returns.
167                .with_fall_through(binding.fall_through),
168        );
169        by_mode
170            .entry(binding.mode)
171            .or_default()
172            .insert(&binding.chords, bound);
173    }
174    by_mode
175}
176
177/// Walk a mode's table-form entries, resolve each against the
178/// [`CommandRegistry`], and emit one [`KeymapBinding`] per
179/// resolvable entry.
180///
181/// Per-entry behaviour:
182///
183/// - `entry.command == None` → silent skip (synthetic catalog
184///   row, not a dispatchable binding).
185/// - `entry.command == Some(name)` and the registry has no
186///   `name` → `tracing::warn!` and skip (catalog-drift; the
187///   binding declares an invocation no command implements).
188/// - chord string fails [`lattice_protocol::parse_chord_sequence`]
189///   → `tracing::warn!` and skip (declaratively invalid;
190///   shouldn't happen since the `keymap_entry!` macro doesn't
191///   validate, but defensive).
192/// - Otherwise → build a [`KeymapBinding`] whose `mode` /
193///   `chords` / `command` come from the entry + registry
194///   resolution, and whose `source` and `doc` are carried
195///   through from the entry's macro-captured fields.
196fn resolve_entries_into_bindings(
197    handle: &KeymapHandle,
198    entries: &[&'static lattice_mode::KeymapEntry],
199    command_registry: &CommandRegistry,
200) -> Vec<KeymapBinding> {
201    let mut out = Vec::with_capacity(entries.len());
202    for entry in entries {
203        let Some(name) = entry.command else {
204            // Synthetic action (`PushDigit`, `SetPending`, …) —
205            // informational catalog row, no dispatchable
206            // binding. Skip silently.
207            continue;
208        };
209        let Some(cmd_id) = command_registry.id_by_name(name) else {
210            tracing::warn!(
211                chord = entry.chord,
212                command = name,
213                modes = ?entry.modes,
214                "keymap_entry: command name not registered in CommandRegistry; skipping binding",
215            );
216            continue;
217        };
218        // TB.0: `<leader>` is expanded HERE for the same reason
219        // `try_bind_chord_string` expands it — it is the choke point this
220        // path funnels through, and OM.2b's rule is that `<leader>` means
221        // the configured leader on every binding route. Until this line
222        // existed it meant nothing on the native-mode route: the token
223        // reached `parse_chord_sequence` as an unknown special key, the
224        // parse failed, and the binding was dropped below with a `warn`.
225        // No native mode had used `<leader>` yet, which is the only reason
226        // that never showed up as a dead chord.
227        let expanded = handle.expand_leader(entry.chord);
228        let chords: Vec<ChordPattern> = match lattice_protocol::parse_chord_sequence(&expanded) {
229            Ok(parsed) => parsed.into_iter().map(ChordPattern::Literal).collect(),
230            Err(err) => {
231                tracing::warn!(
232                    chord = entry.chord,
233                    expanded = %expanded,
234                    error = %err,
235                    modes = ?entry.modes,
236                    "keymap_entry: chord string failed to parse; skipping binding",
237                );
238                continue;
239            }
240        };
241        // B-field fan-out: an entry carries `modes: &[BindingMode]`
242        // (single-mode rows are a one-element slice). Emit one binding
243        // per mode — the registry trie is per-mode, so a multi-mode
244        // entry becomes N bindings sharing chord / command / source.
245        for &mode in entry.modes {
246            let binding = KeymapBinding::new(
247                mode,
248                chords.clone(),
249                CommandInvocation::of(cmd_id),
250                entry.source().clone(),
251            )
252            .with_doc(entry.doc)
253            // SN.3c.2b: carry the entry's augment-and-continue flag
254            // through to the BoundCommand so the dispatcher can re-resolve.
255            .with_fall_through(entry.fall_through);
256            out.push(binding);
257        }
258    }
259    out
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    use std::sync::OnceLock;
267
268    use lattice_grammar::{CommandInvocation, CommandRegistry, SourceLocation};
269    use lattice_mode::{
270        Keymap, KeymapBinding, KeymapEntry, LifecycleFuture, Mode, ModeContext, ModeKind,
271    };
272    use lattice_protocol::ids::CommandId;
273    use lattice_protocol::{ChordPattern, KeyChord};
274
275    use crate::keymap_registry::KeymapHandle;
276    use crate::keymap_trie::LookupResult;
277
278    fn synthetic_invocation(raw: u64) -> CommandInvocation {
279        CommandInvocation::of(CommandId::new(raw))
280    }
281
282    fn here() -> SourceLocation {
283        SourceLocation::synthetic("keymap_mode_contributions::tests")
284    }
285
286    /// Fresh empty `CommandRegistry`. Sufficient for chain-form
287    /// tests that don't go through name resolution.
288    fn empty_command_registry() -> CommandRegistry {
289        CommandRegistry::new()
290    }
291
292    /// `CommandRegistry` populated with the standard vim grammar
293    /// builtins (motions / operators / text-objects). Used by
294    /// table-form resolution tests that point entries at known
295    /// command names like `motion:line-down`.
296    fn registry_with_builtins() -> CommandRegistry {
297        let mut r = CommandRegistry::new();
298        let _ = lattice_grammar::builtins::populate(&mut r);
299        r
300    }
301
302    /// K.2.4.A.0.3 fixture — table-form entry whose canonical
303    /// command name resolves against the builtin registry.
304    fn fixture_table_form_entries() -> &'static [KeymapEntry] {
305        static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
306        ENTRIES.get_or_init(|| {
307            vec![lattice_mode::keymap_entry! {
308                mode: Normal, chord: "z", doc: "Move down (table-form fixture)",
309                cmd: "motion:line-down"
310            }]
311        })
312    }
313
314    /// K.2.4.A.0.3 fixture — synthetic catalog row with no
315    /// command (informational; not dispatchable).
316    fn fixture_synthetic_entries() -> &'static [KeymapEntry] {
317        static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
318        ENTRIES.get_or_init(|| {
319            vec![lattice_mode::keymap_entry! {
320                mode: Normal, chord: "z", doc: "Synthetic entry (no cmd)"
321            }]
322        })
323    }
324
325    /// K.2.4.A.0.3 fixture — entry pointing at a command name
326    /// the registry doesn't know. Resolver should warn+skip.
327    fn fixture_unresolvable_entries() -> &'static [KeymapEntry] {
328        static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
329        ENTRIES.get_or_init(|| {
330            vec![lattice_mode::keymap_entry! {
331                mode: Normal, chord: "z", doc: "Points at nonexistent command",
332                cmd: "test:nonexistent-command-xyz"
333            }]
334        })
335    }
336
337    /// Test mode whose only purpose is to return a configurable
338    /// `Keymap`. Only `id`, `kind`, `keymap`, and `on_activate`
339    /// need real behavior; everything else falls through to the
340    /// trait's defaults.
341    struct TestMode {
342        id: ModeId,
343        keymap: Keymap,
344    }
345
346    impl Mode for TestMode {
347        type Guard = ();
348        fn id(&self) -> ModeId {
349            self.id
350        }
351        fn kind(&self) -> ModeKind {
352            ModeKind::Minor
353        }
354        fn keymap(&self) -> Keymap {
355            self.keymap.clone()
356        }
357        fn on_activate(&self, _ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
358            Box::pin(async { Ok(()) })
359        }
360    }
361
362    fn test_mode(id_str: &str, keymap: Keymap) -> TestMode {
363        // Mode ids must end in `-mode` (enforced at registration). Append
364        // the suffix here so each fixture call-site doesn't have to.
365        let id = if id_str.ends_with("-mode") {
366            ModeId::new(id_str)
367        } else {
368            ModeId::new(&format!("{id_str}-mode"))
369        };
370        TestMode { id, keymap }
371    }
372
373    fn lookup(
374        handle: &KeymapHandle,
375        mode: BindingMode,
376        active: &[ModeId],
377        chords: &[KeyChord],
378    ) -> LookupResult {
379        handle.lookup_with_context(mode, chords, active)
380    }
381
382    #[test]
383    fn translate_skips_modes_with_default_keymap() {
384        let h = KeymapHandle::new();
385        let mut registry = ModeRegistry::new();
386        let mode_id = registry
387            .register(test_mode("test-mode/empty", Keymap::default()))
388            .expect("register");
389
390        translate_mode_keymaps(&h, &registry, &empty_command_registry());
391
392        // Nothing should fire: no layer pushed, no minor binding.
393        let result = lookup(&h, BindingMode::Normal, &[mode_id], &[KeyChord::char('a')]);
394        assert!(matches!(result, LookupResult::Unbound));
395    }
396
397    #[test]
398    fn translate_pushes_single_binding_reachable_via_registry() {
399        let h = KeymapHandle::new();
400        let cmd = synthetic_invocation(42);
401        let binding = KeymapBinding::new(
402            BindingMode::Normal,
403            vec![ChordPattern::Literal(KeyChord::char('z'))],
404            cmd.clone(),
405            here(),
406        );
407        let keymap = Keymap::new().bind(binding);
408        let mut registry = ModeRegistry::new();
409        let mode_id = registry
410            .register(test_mode("test-mode/single", keymap))
411            .expect("register");
412
413        translate_mode_keymaps(&h, &registry, &empty_command_registry());
414
415        let result = lookup(&h, BindingMode::Normal, &[mode_id], &[KeyChord::char('z')]);
416        match result {
417            LookupResult::Bound { command, .. } => {
418                assert_eq!(command.command, cmd);
419                assert_eq!(command.layer, KeymapLayer::MinorMode(mode_id));
420            }
421            other => panic!("expected Bound, got {other:?}"),
422        }
423    }
424
425    #[test]
426    fn translate_groups_bindings_across_binding_modes() {
427        let h = KeymapHandle::new();
428        let normal_cmd = synthetic_invocation(1);
429        let visual_cmd = synthetic_invocation(2);
430        let keymap = Keymap::new()
431            .bind(KeymapBinding::new(
432                BindingMode::Normal,
433                vec![ChordPattern::Literal(KeyChord::char('x'))],
434                normal_cmd.clone(),
435                here(),
436            ))
437            .bind(KeymapBinding::new(
438                BindingMode::Visual,
439                vec![ChordPattern::Literal(KeyChord::char('x'))],
440                visual_cmd.clone(),
441                here(),
442            ));
443        let mut registry = ModeRegistry::new();
444        let mode_id = registry
445            .register(test_mode("test-mode/multi", keymap))
446            .expect("register");
447
448        translate_mode_keymaps(&h, &registry, &empty_command_registry());
449
450        match lookup(&h, BindingMode::Normal, &[mode_id], &[KeyChord::char('x')]) {
451            LookupResult::Bound { command, .. } => {
452                assert_eq!(command.command, normal_cmd, "Normal-mode binding");
453            }
454            other => panic!("expected Normal Bound, got {other:?}"),
455        }
456        match lookup(&h, BindingMode::Visual, &[mode_id], &[KeyChord::char('x')]) {
457            LookupResult::Bound { command, .. } => {
458                assert_eq!(command.command, visual_cmd, "Visual-mode binding");
459            }
460            other => panic!("expected Visual Bound, got {other:?}"),
461        }
462    }
463
464    /// TB.0: `<leader>` means the same thing on every binding path.
465    ///
466    /// It did not. `try_bind_chord_string` — the plugin-mode, plugin
467    /// `register-binding` and `init.rs` route — expands it at the single
468    /// choke point OM.2b built for exactly this. The NATIVE mode-keymap
469    /// route parsed `entry.chord` raw, so `<leader>` reached
470    /// `parse_chord_sequence` as an unknown special key, failed, and the
471    /// binding was dropped with a `warn` — a chord that does nothing, and
472    /// nothing on screen to say why.
473    ///
474    /// No native mode had used `<leader>` yet, which is the only reason
475    /// this never fired. `table-mode` is the first, and it inherits org's
476    /// `<leader>t…` set from the mode it takes over from.
477    #[test]
478    fn a_native_mode_can_bind_a_leader_chord() {
479        static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
480        let entries = ENTRIES.get_or_init(|| {
481            vec![lattice_mode::keymap_entry! {
482                mode: Normal,
483                chord: "<leader>z",
484                doc: "TB.0 leader fixture",
485                cmd: "motion:line-down"
486            }]
487        });
488        let h = KeymapHandle::new();
489        let keymap = Keymap::from_entries(entries.as_slice());
490        let mut registry = ModeRegistry::new();
491        let mode_id = registry
492            .register(test_mode("test-mode/leader", keymap))
493            .expect("register");
494
495        translate_mode_keymaps(&h, &registry, &registry_with_builtins());
496
497        // The DEFAULT leader is `<Space>`, and the expansion happens at
498        // bind time — so the trie must hold `<Space>z`, not a literal
499        // `<leader>` nobody can type.
500        match lookup(
501            &h,
502            BindingMode::Normal,
503            &[mode_id],
504            &[KeyChord::char(' '), KeyChord::char('z')],
505        ) {
506            LookupResult::Bound { .. } => {}
507            other => panic!("`<leader>z` must reach the trie as `<Space>z`, got {other:?}"),
508        }
509    }
510
511    #[test]
512    fn multi_mode_entry_fans_out_into_one_binding_per_mode() {
513        // B-field: a SINGLE `mode: [Normal, Visual]` catalog entry must
514        // resolve in BOTH modes — `resolve_entries_into_bindings` fans the
515        // entry's `modes` slice into one KeymapBinding per mode.
516        static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
517        let entries = ENTRIES.get_or_init(|| {
518            vec![lattice_mode::keymap_entry! {
519                mode: [Normal, Visual],
520                chord: "z",
521                doc: "B-field multi-mode fixture",
522                cmd: "motion:line-down"
523            }]
524        });
525        let h = KeymapHandle::new();
526        let keymap = Keymap::from_entries(entries.as_slice());
527        let mut registry = ModeRegistry::new();
528        let mode_id = registry
529            .register(test_mode("test-mode/bfield", keymap))
530            .expect("register");
531
532        translate_mode_keymaps(&h, &registry, &registry_with_builtins());
533
534        for mode in [BindingMode::Normal, BindingMode::Visual] {
535            assert!(
536                matches!(
537                    lookup(&h, mode, &[mode_id], &[KeyChord::char('z')]),
538                    LookupResult::Bound { .. }
539                ),
540                "multi-mode entry must bind in {mode:?}"
541            );
542        }
543    }
544
545    #[test]
546    fn translate_emacs_prefix_sequence_via_bind_chord() {
547        // End-to-end the <C-x>pp idiom: bind_chord parses the
548        // string into three literals, K.2.4 inserts them at
549        // MinorMode layer, the registry walks the path.
550        let h = KeymapHandle::new();
551        let cmd = synthetic_invocation(99);
552        let keymap = Keymap::new().bind_chord(BindingMode::Normal, "<C-x>pp", cmd.clone());
553        let mut registry = ModeRegistry::new();
554        let mode_id = registry
555            .register(test_mode("test-mode/emacs", keymap))
556            .expect("register");
557
558        translate_mode_keymaps(&h, &registry, &empty_command_registry());
559
560        // First chord is partial.
561        let partial = lookup(&h, BindingMode::Normal, &[mode_id], &[KeyChord::ctrl('x')]);
562        assert!(matches!(partial, LookupResult::Partial), "after <C-x>");
563        // Two chords in is still partial.
564        let still_partial = lookup(
565            &h,
566            BindingMode::Normal,
567            &[mode_id],
568            &[KeyChord::ctrl('x'), KeyChord::char('p')],
569        );
570        assert!(
571            matches!(still_partial, LookupResult::Partial),
572            "after <C-x>p"
573        );
574        // Third chord terminates.
575        let result = lookup(
576            &h,
577            BindingMode::Normal,
578            &[mode_id],
579            &[
580                KeyChord::ctrl('x'),
581                KeyChord::char('p'),
582                KeyChord::char('p'),
583            ],
584        );
585        match result {
586            LookupResult::Bound { command, .. } => {
587                assert_eq!(command.command, cmd);
588                assert_eq!(command.layer, KeymapLayer::MinorMode(mode_id));
589            }
590            other => panic!("expected Bound on <C-x>pp, got {other:?}"),
591        }
592    }
593
594    #[test]
595    fn single_mode_translation_matches_bulk_pass() {
596        // Round-trip the dynamic-path entry point against the
597        // bulk pass: both should produce the same observable
598        // registry state for the same mode.
599        let bulk_handle = KeymapHandle::new();
600        let single_handle = KeymapHandle::new();
601        let cmd = synthetic_invocation(7);
602        let keymap = Keymap::new().bind_chord(BindingMode::Normal, "gd", cmd.clone());
603        let mut registry = ModeRegistry::new();
604        let mode_id = registry
605            .register(test_mode("test-mode/parity", keymap))
606            .expect("register");
607
608        translate_mode_keymaps(&bulk_handle, &registry, &empty_command_registry());
609        let mode_arc = registry.get(mode_id).expect("registered mode");
610        translate_mode_keymap(
611            &single_handle,
612            mode_id,
613            &mode_arc,
614            &empty_command_registry(),
615        );
616
617        let chord_path = [KeyChord::char('g'), KeyChord::char('d')];
618        let bulk_result = lookup(&bulk_handle, BindingMode::Normal, &[mode_id], &chord_path);
619        let single_result = lookup(&single_handle, BindingMode::Normal, &[mode_id], &chord_path);
620        match (bulk_result, single_result) {
621            (
622                LookupResult::Bound {
623                    command: bulk_cmd, ..
624                },
625                LookupResult::Bound {
626                    command: single_cmd,
627                    ..
628                },
629            ) => {
630                assert_eq!(bulk_cmd.command, cmd);
631                assert_eq!(single_cmd.command, cmd);
632                assert_eq!(bulk_cmd.layer, single_cmd.layer);
633            }
634            other => panic!("bulk vs single divergence: {other:?}"),
635        }
636    }
637
638    // ---- K.2.4.A.0.3: table-form entry resolution ----
639
640    #[test]
641    fn translate_resolves_table_form_entries_via_command_registry() {
642        // Happy path: an entry pointing at a builtin command
643        // name resolves through the registry, parses its chord
644        // string, and lands as a `Bound` lookup. The CommandId
645        // on the resolved binding matches the registry's
646        // canonical lookup for the same name.
647        let h = KeymapHandle::new();
648        let cmd_registry = registry_with_builtins();
649        let expected_id = cmd_registry
650            .id_by_name("motion:line-down")
651            .expect("motion:line-down should be registered by builtins");
652
653        let keymap = Keymap::from_entries(fixture_table_form_entries());
654        let mut registry = ModeRegistry::new();
655        let mode_id = registry
656            .register(test_mode("test-mode/entries-resolve", keymap))
657            .expect("register");
658
659        translate_mode_keymaps(&h, &registry, &cmd_registry);
660
661        match lookup(&h, BindingMode::Normal, &[mode_id], &[KeyChord::char('z')]) {
662            LookupResult::Bound { command, .. } => {
663                assert_eq!(command.command.command, expected_id);
664                assert_eq!(command.layer, KeymapLayer::MinorMode(mode_id));
665            }
666            other => panic!("expected Bound, got {other:?}"),
667        }
668    }
669
670    #[test]
671    fn translate_skips_synthetic_entries_with_no_command() {
672        // Entry with `command = None` is informational
673        // (PushDigit / SetPending / etc.); the resolver skips
674        // silently. Mode contributes only synthetic entries →
675        // no dispatchable binding → no layer push → Unbound
676        // lookup.
677        let h = KeymapHandle::new();
678        let cmd_registry = registry_with_builtins();
679        let keymap = Keymap::from_entries(fixture_synthetic_entries());
680        let mut registry = ModeRegistry::new();
681        let mode_id = registry
682            .register(test_mode("test-mode/synthetic", keymap))
683            .expect("register");
684
685        translate_mode_keymaps(&h, &registry, &cmd_registry);
686
687        let result = lookup(&h, BindingMode::Normal, &[mode_id], &[KeyChord::char('z')]);
688        assert!(matches!(result, LookupResult::Unbound));
689    }
690
691    #[test]
692    fn translate_warns_and_skips_unresolvable_entry_names() {
693        // Entry's canonical command name isn't in the registry
694        // (catalog drift). Resolver logs a `tracing::warn!` and
695        // skips; nothing dispatchable contributes, layer skipped.
696        let h = KeymapHandle::new();
697        let cmd_registry = empty_command_registry();
698        let keymap = Keymap::from_entries(fixture_unresolvable_entries());
699        let mut registry = ModeRegistry::new();
700        let mode_id = registry
701            .register(test_mode("test-mode/unresolvable", keymap))
702            .expect("register");
703
704        translate_mode_keymaps(&h, &registry, &cmd_registry);
705
706        let result = lookup(&h, BindingMode::Normal, &[mode_id], &[KeyChord::char('z')]);
707        assert!(matches!(result, LookupResult::Unbound));
708    }
709
710    // ---- K.2.4.A.0.4: chain form + table form composability ----
711
712    #[test]
713    fn translate_combines_chain_form_with_table_form_entries() {
714        // A mode whose `Mode::keymap()` returns a Keymap built
715        // from BOTH paths in one chain — typical real-world
716        // shape K.2.5 will adopt for multibuffer-mode (static
717        // table for the bulk of the bindings + a few
718        // dynamically-named bindings from the mode's own
719        // CommandInvocations). After translation, BOTH the
720        // entry-resolved 'z' chord AND the chain-form '<C-r>'
721        // chord must lookup as Bound.
722        let h = KeymapHandle::new();
723        let cmd_registry = registry_with_builtins();
724        let chain_cmd = synthetic_invocation(123);
725
726        let keymap = Keymap::from_entries(fixture_table_form_entries()).bind_chord(
727            BindingMode::Normal,
728            "<C-r>",
729            chain_cmd.clone(),
730        );
731
732        let mut registry = ModeRegistry::new();
733        let mode_id = registry
734            .register(test_mode("test-mode/combined", keymap))
735            .expect("register");
736
737        translate_mode_keymaps(&h, &registry, &cmd_registry);
738
739        // Entry-form chord 'z' fires (resolved through registry).
740        let expected_id = cmd_registry
741            .id_by_name("motion:line-down")
742            .expect("motion:line-down should be registered by builtins");
743        match lookup(&h, BindingMode::Normal, &[mode_id], &[KeyChord::char('z')]) {
744            LookupResult::Bound { command, .. } => {
745                assert_eq!(command.command.command, expected_id, "entry-form");
746                assert_eq!(command.layer, KeymapLayer::MinorMode(mode_id));
747            }
748            other => panic!("expected Bound for entry-form 'z', got {other:?}"),
749        }
750
751        // Chain-form chord '<C-r>' fires (typed invocation
752        // direct, no resolution needed).
753        match lookup(&h, BindingMode::Normal, &[mode_id], &[KeyChord::ctrl('r')]) {
754            LookupResult::Bound { command, .. } => {
755                assert_eq!(command.command, chain_cmd, "chain-form");
756                assert_eq!(command.layer, KeymapLayer::MinorMode(mode_id));
757            }
758            other => panic!("expected Bound for chain-form '<C-r>', got {other:?}"),
759        }
760    }
761}