Skip to main content

lattice_keymap/
trie.rs

1//! `KeymapTrie` -- the lookup data structure the keymap registry
2//! consults on the keystroke path. Audit slice 8.b of the M3
3//! refactor; see `docs/dev/architecture/keymap-architecture.md` for the design.
4//!
5//! ## Shape
6//!
7//! Each `TrieNode` carries:
8//!
9//! - `children: HashMap<KeyChord, TrieNode>` -- exact-match
10//!   descents indexed by chord. `gd`'s second descent comes
11//!   from `children[KeyChord::char('d')]`.
12//! - `char_wildcard: Option<Box<TrieNode>>` -- "any single
13//!   printable char" descent. Used for marks (`'a`), registers
14//!   (`"a`), find-char (`fX` / `FX` / `tX` / `TX`), macro
15//!   names (`@a` / `qa`). Subsumes today's
16//!   `BindingMode::AfterMark` / `AfterRegister` / `AfterFindChar`
17//!   special-case states.
18//! - `binding: Option<Arc<BoundCommand>>` -- terminal binding at
19//!   this depth, if any. Internal nodes hold `None`.
20//!
21//! Lookup walks chord-by-chord. Exact `children` match wins; if
22//! absent, fall back to `char_wildcard` (capturing the matched
23//! char). Returns:
24//!
25//! - `Bound` when the walk ends at a node with a terminal
26//!   binding (and the input is exhausted).
27//! - `Partial` when the walk ends at an internal node with no
28//!   terminal but with children -- caller waits for the next
29//!   chord.
30//! - `Unbound` when no descent matches at some point.
31//!
32//! ## Performance
33//!
34//! Lookup is `O(prefix_length)` `HashMap` lookups. With ~500
35//! bindings per layer and chord depths of 1-3, the inner cost
36//! is two `HashMap::get` calls plus a few branches; bench
37//! `keymap_trie_lookup_*` rows in `BENCHMARKS.md` measure it.
38//! Allocation-free on the hot path -- the `Vec<char>` of
39//! captured wildcards is built only when the path actually
40//! crosses a wildcard, which is rare.
41//!
42//! ## Mutation
43//!
44//! `insert` / `remove` / `merge_over` are off the hot path
45//! (registry construction, layer push/pop, `:bind` invocations).
46//! They take `&mut self`; the registry handle (slice 8.c) wraps
47//! the trie in `Arc<ArcSwap<KeymapTrie>>` so wait-free reads
48//! coexist with these `&mut`-bound mutations -- the registry
49//! builds a new trie, swaps the cell, and the old trie drops
50//! once readers release their `Arc`.
51
52use std::collections::HashMap;
53use std::sync::Arc;
54
55use crate::ModeId;
56use lattice_grammar::{CommandInvocation, SourceLocation};
57
58// K.2.1: `ChordPattern` moved to `lattice-protocol` alongside
59// `KeyChord` so mode crates can construct registration paths
60// without depending on `lattice-host`. Re-exported below for
61// the existing `use crate::keymap_trie::ChordPattern` callers
62// (`keymap_registry.rs`, `keymap_replace.rs`,
63// `multibuffer_keymap.rs`). The matcher engine
64// (`KeymapTrie`, `KeymapLayer`, `BoundCommand`) now lives in `lattice-keymap::trie`.
65pub use lattice_protocol::ChordPattern;
66
67use lattice_protocol::{KeyChord, KeyKind, KeyMods};
68
69/// Where in the five-layer model (DESIGN.md §5.2.3) a binding
70/// originated. Higher value wins on cross-layer conflict; the
71/// trie itself doesn't enforce this, the registry does at merge
72/// time.
73///
74/// The derived `Ord` is declaration order (`Builtin < MajorMode(_) <
75/// MinorMode(_) < User < Buffer`, same-kind mode layers by mode name),
76/// and is the order the registry stores and reports layers in. At
77/// lookup time, though, an *active* `MajorMode` / `MinorMode` layer is
78/// overlaid above `User` and `Buffer` — see
79/// [`KeymapHandle::lookup_with_context`](crate::KeymapHandle::lookup_with_context).
80///
81/// K.1.b (2026-05-30): `MinorMode` now carries a typed
82/// [`ModeId`] instead of an opaque `u32`. The layer's
83/// identity = the mode's identity; one layer per mode (not
84/// per-push). A re-push for the same `ModeId` replaces the
85/// layer's bindings rather than minting a new layer.
86/// `OwnedLayer` capability keys off `ModeId` so user /
87/// plugin bindings targeting a specific mode's keymap go
88/// into that mode's layer and live + die with the mode's
89/// activation lifecycle (matching emacs's `(:map foo-mode-map ...)`).
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
91pub enum KeymapLayer {
92    /// Built-in vim default keymap. Lowest priority; user /
93    /// plugin bindings shadow these.
94    Builtin,
95    /// Major-mode keymap (rust, markdown, ...).
96    MajorMode(ModeId),
97    /// Active minor-mode keymap. The `ModeId` is the layer's
98    /// identity — push for the same mode is idempotent on the
99    /// layer (bindings replaced, not appended as a sibling).
100    /// Cross-mode order at merge time comes from
101    /// `active_modes[active_buffer]` (K.1.c), not from the
102    /// `ModeId` ordering.
103    MinorMode(ModeId),
104    /// User config (`init.rs`).
105    User,
106    /// Per-buffer ad-hoc binding (`:nmap <buffer>`).
107    Buffer,
108}
109
110/// What the trie returns at a terminal node.
111///
112/// Carries enough provenance for `:describe-key` and enough
113/// information for the dispatcher to fire the binding:
114/// - `command` -- the typed `CommandInvocation` to dispatch.
115/// - `source` -- where the binding was registered (catalog
116///   entry, user `init.rs:42`, plugin `foo.wit:7`).
117/// - `layer` -- priority tier for tie-break / shadowing.
118#[derive(Debug, Clone)]
119pub struct BoundCommand {
120    /// The typed invocation the dispatcher runs when the chord resolves.
121    pub command: CommandInvocation,
122    /// Where the binding was registered, for `:describe-key` provenance.
123    pub source: SourceLocation,
124    /// The layer the binding was created for. Set at construction by the
125    /// caller; [`KeymapHandle::bind`](crate::KeymapHandle::bind) sets it to
126    /// the layer it binds into.
127    pub layer: KeymapLayer,
128    /// SN.3c.2b: `:map`-style augment-and-continue. When `true`, the
129    /// dispatcher runs this binding's action AND THEN re-resolves the
130    /// same chord against the layers below this one (this binding's
131    /// `layer` mode peeled out of the active set), running the native
132    /// binding too. `false` (default) = the binding fully handles the
133    /// chord and stops, the normal shadowing behavior. Unlike vim's
134    /// `:map` recursion this is structurally bounded — each hop peels a
135    /// layer, terminating at `Builtin`, so it cannot loop. Declared on
136    /// the owning `KeymapEntry`; the host owns the re-resolution.
137    pub fall_through: bool,
138}
139
140impl BoundCommand {
141    /// Construct a binding that dispatches via the
142    /// `CommandInvocation`. `fall_through` defaults to `false` (the
143    /// binding fully handles its chord); use [`Self::with_fall_through`]
144    /// to opt into augment-and-continue.
145    pub fn from_invocation(
146        command: CommandInvocation,
147        source: SourceLocation,
148        layer: KeymapLayer,
149    ) -> Self {
150        Self {
151            command,
152            source,
153            layer,
154            fall_through: false,
155        }
156    }
157
158    /// SN.3c.2b: set the augment-and-continue flag (see
159    /// [`BoundCommand::fall_through`]). Builder form so the many
160    /// existing `from_invocation` call sites stay source-compatible.
161    pub fn with_fall_through(mut self, fall_through: bool) -> Self {
162        self.fall_through = fall_through;
163        self
164    }
165}
166
167/// Lookup outcome.
168#[derive(Debug, Clone)]
169pub enum LookupResult {
170    /// Walk terminated at a node with a terminal binding.
171    /// `captured` records every char absorbed by `CharLiteral`
172    /// wildcards along the path -- empty for binding paths
173    /// without wildcards (the common case).
174    Bound {
175        /// The binding at the terminal node.
176        command: Arc<BoundCommand>,
177        /// Chars matched by `{char}` wildcards, in path order.
178        captured: Vec<char>,
179    },
180    /// Walk consumed every input chord but landed at an
181    /// internal node (children present, no terminal). Caller
182    /// stays in pending state and waits for the next chord.
183    Partial,
184    /// Walk hit a node with no descent matching the next
185    /// chord. Caller falls through (Insert mode literal text,
186    /// Normal mode no-op, etc.).
187    Unbound,
188}
189
190/// WK.1: one immediate child of a trie node, as which-key sees it.
191///
192/// **Owned, not borrowed.** The composite trie
193/// `KeymapHandle::continuations_with_context` folds is a local temporary
194/// (`lookup_with_context` builds one per call), so a view holding
195/// `&TrieNode` could not outlive the fold. Owning also keeps `TrieNode`
196/// private, which it is today.
197#[derive(Debug, Clone)]
198pub struct ChildView {
199    /// The chord that descends to this child. For the wildcard slot this
200    /// is meaningless and the caller reads `NodeView::wildcard` instead.
201    pub chord: KeyChord,
202    /// The binding sitting AT the child, if it is a terminal node.
203    pub binding: Option<Arc<BoundCommand>>,
204    /// How many bindings live strictly BELOW the child. Non-zero means
205    /// the child is itself a prefix; which-key renders `+N`.
206    pub descendants: usize,
207}
208
209/// WK.1: the immediate children of the node a prefix names — which-key's
210/// view of the trie, as distinct from `:describe-key`'s
211/// [`KeymapTrie::walk_continuations`], which wants the whole subtree with
212/// provenance. Same trie, different questions.
213#[derive(Debug, Clone, Default)]
214pub struct NodeView {
215    /// Exact-match descents, in unspecified order — the caller sorts
216    /// (`children` is a `HashMap`, so an explicit total order is
217    /// required or the grid reshuffles between openings of the same
218    /// prefix).
219    pub children: Vec<ChildView>,
220    /// The `{char}` wildcard descent, if this node has one.
221    pub wildcard: Option<ChildView>,
222    /// A binding at the node ITSELF: the prefix is also bound, vim's
223    /// `d`-is-an-operator-and-a-prefix case. Reported in the popup's
224    /// footer rather than as a row.
225    pub terminal: Option<Arc<BoundCommand>>,
226}
227
228impl NodeView {
229    /// Nothing to show: no children and no wildcard. A node like this
230    /// suppresses the popup rather than rendering an empty box.
231    pub fn is_empty(&self) -> bool {
232        self.children.is_empty() && self.wildcard.is_none()
233    }
234}
235
236#[derive(Debug, Default)]
237struct TrieNode {
238    children: HashMap<KeyChord, TrieNode>,
239    char_wildcard: Option<Box<TrieNode>>,
240    binding: Option<Arc<BoundCommand>>,
241}
242
243impl Clone for TrieNode {
244    fn clone(&self) -> Self {
245        Self {
246            children: self.children.clone(),
247            char_wildcard: self.char_wildcard.clone(),
248            binding: self.binding.clone(),
249        }
250    }
251}
252
253/// One layer's worth of bindings, indexed for
254/// `O(prefix_length)` lookup.
255///
256/// A plain value type: no locking, no layering. The
257/// [`KeymapRegistry`](crate::KeymapRegistry) keeps one per
258/// `(layer, BindingMode)` and merges them with [`Self::merge_over`];
259/// callers build one directly to hand a whole layer to
260/// [`KeymapHandle::push_layer`](crate::KeymapHandle::push_layer).
261///
262/// # Examples
263///
264/// ```
265/// use std::sync::Arc;
266/// use lattice_grammar::{CommandId, CommandInvocation, SourceLocation};
267/// use lattice_keymap::{BoundCommand, ChordPattern, KeymapLayer, KeymapTrie, LookupResult};
268/// use lattice_protocol::KeyChord;
269///
270/// let bound = |id| Arc::new(BoundCommand::from_invocation(
271///     CommandInvocation::of(CommandId::new(id)),
272///     SourceLocation::synthetic("doc"),
273///     KeymapLayer::Builtin,
274/// ));
275/// let lit = |c| ChordPattern::Literal(KeyChord::char(c));
276///
277/// let mut trie = KeymapTrie::new();
278/// trie.insert(&[lit('g'), lit('g')], bound(1));             // gg
279/// trie.insert(&[lit('f'), ChordPattern::CharLiteral], bound(2)); // f{char}
280///
281/// let k = KeyChord::char;
282/// assert!(matches!(trie.lookup(&[k('g')]), LookupResult::Partial));
283/// assert!(matches!(trie.lookup(&[k('g'), k('g')]), LookupResult::Bound { .. }));
284/// assert!(matches!(trie.lookup(&[k('q')]), LookupResult::Unbound));
285/// // The wildcard captures the typed char...
286/// match trie.lookup(&[k('f'), k('x')]) {
287///     LookupResult::Bound { captured, .. } => assert_eq!(captured, vec!['x']),
288///     other => panic!("{other:?}"),
289/// }
290/// // ...but never a modified chord.
291/// assert!(matches!(trie.lookup(&[k('f'), KeyChord::ctrl('x')]), LookupResult::Unbound));
292/// assert_eq!(trie.binding_count(), 2);
293/// ```
294#[derive(Debug, Clone, Default)]
295pub struct KeymapTrie {
296    root: TrieNode,
297}
298
299impl KeymapTrie {
300    /// Empty trie. Use `insert` to populate.
301    pub fn new() -> Self {
302        Self::default()
303    }
304
305    /// Register `bound` at the chord path `path`. Replaces any
306    /// existing binding at the same path (last-bind-wins within
307    /// a single trie / layer). Empty path is a no-op (no
308    /// "bind nothing").
309    pub fn insert(&mut self, path: &[ChordPattern], bound: Arc<BoundCommand>) {
310        if path.is_empty() {
311            return;
312        }
313        let mut node = &mut self.root;
314        for seg in path {
315            node = match seg {
316                ChordPattern::Literal(chord) => node.children.entry(*chord).or_default(),
317                ChordPattern::CharLiteral => node
318                    .char_wildcard
319                    .get_or_insert_with(|| Box::new(TrieNode::default())),
320            };
321        }
322        node.binding = Some(bound);
323    }
324
325    /// Remove the binding at `path` if present. Returns the
326    /// dropped `Arc<BoundCommand>` for callers that want to
327    /// surface "what was unbound" in echo messages. Does not
328    /// prune empty intermediate nodes -- a future bind at the
329    /// same prefix should reuse them, and the wasted nodes are
330    /// bounded by the trie's lifetime.
331    pub fn remove(&mut self, path: &[ChordPattern]) -> Option<Arc<BoundCommand>> {
332        let mut node = &mut self.root;
333        for seg in path {
334            node = match seg {
335                ChordPattern::Literal(chord) => node.children.get_mut(chord)?,
336                ChordPattern::CharLiteral => node.char_wildcard.as_deref_mut()?,
337            };
338        }
339        node.binding.take()
340    }
341
342    /// VM.4: the binding registered at EXACTLY `path`, if any.
343    ///
344    /// Not [`Self::lookup`], and the difference is why this exists. `lookup`
345    /// answers "what does this sequence of PRESSED chords resolve to", and
346    /// falls back to the `{char}` wildcard when no exact child matches, so it
347    /// can't tell the registration `[f, {char}]` from `[f, x]`, and it can't
348    /// be asked about a pattern path at all. The motion mirror needs "is this
349    /// exact registration slot occupied", which is a question about PATTERNS.
350    /// Here a `CharLiteral` segment walks the wildcard slot and only that slot,
351    /// never as a fallback.
352    pub fn get(&self, path: &[ChordPattern]) -> Option<&Arc<BoundCommand>> {
353        let mut node = &self.root;
354        for seg in path {
355            node = match seg {
356                ChordPattern::Literal(chord) => node.children.get(chord)?,
357                ChordPattern::CharLiteral => node.char_wildcard.as_deref()?,
358            };
359        }
360        node.binding.as_ref()
361    }
362
363    /// Walk the input chord sequence and return what we found.
364    ///
365    /// Lookup precedence at each depth: exact `children` match
366    /// first; if absent, fall back to `char_wildcard` when the
367    /// input chord is a bare printable char (no modifiers).
368    /// Modifier-bearing chords (`<C-x>`) never match the
369    /// wildcard -- the wildcard's job is "any single typed
370    /// char" for marks / registers / find-char.
371    pub fn lookup(&self, chords: &[KeyChord]) -> LookupResult {
372        let mut node = &self.root;
373        let mut captured: Vec<char> = Vec::new();
374        for chord in chords {
375            if let Some(next) = node.children.get(chord) {
376                node = next;
377                continue;
378            }
379            // Wildcard fallback: only bare chars (no
380            // modifiers) qualify. `<C-x>` does not match a
381            // wildcard intended for `'a` / `"a` / `fX`.
382            if chord.mods.is_empty()
383                && let KeyKind::Char(c) = chord.key
384                && let Some(wild) = node.char_wildcard.as_deref()
385            {
386                captured.push(c);
387                node = wild;
388                continue;
389            }
390            return LookupResult::Unbound;
391        }
392        match node.binding.as_ref() {
393            Some(b) => LookupResult::Bound {
394                command: Arc::clone(b),
395                captured,
396            },
397            None => {
398                if node.children.is_empty() && node.char_wildcard.is_none() {
399                    LookupResult::Unbound
400                } else {
401                    LookupResult::Partial
402                }
403            }
404        }
405    }
406
407    /// Descend `chords` and return the node reached, or `None` when
408    /// the path leaves the trie. Same precedence as [`Self::lookup`]
409    /// (exact child first, then `char_wildcard` for a bare char), so
410    /// the two agree on which node a sequence names.
411    fn descend(&self, chords: &[KeyChord]) -> Option<&TrieNode> {
412        let mut node = &self.root;
413        for chord in chords {
414            if let Some(next) = node.children.get(chord) {
415                node = next;
416                continue;
417            }
418            if chord.mods.is_empty()
419                && matches!(chord.key, KeyKind::Char(_))
420                && let Some(wild) = node.char_wildcard.as_deref()
421            {
422                node = wild;
423                continue;
424            }
425            return None;
426        }
427        Some(node)
428    }
429
430    /// DK.4: walk every binding registered strictly BELOW `prefix`,
431    /// invoking `f` with the SUFFIX path (the chords that follow
432    /// `prefix`) and the binding at that path.
433    ///
434    /// This is the subtree `:describe-key` renders when the chord it
435    /// was asked about is a prefix rather than a binding — the answer
436    /// "`<C-c><C-x>` is not bound" is true and useless, "it is a prefix
437    /// with eight continuations" is the answer the user came for.
438    ///
439    /// A binding sitting exactly AT `prefix` is not emitted; that is
440    /// the [`Self::lookup`] answer and is reported separately.
441    ///
442    /// O(subtree) walk. Telemetry path; never on the keystroke path.
443    pub fn walk_continuations<F>(&self, prefix: &[KeyChord], mut f: F)
444    where
445        F: FnMut(&[ChordPattern], &Arc<BoundCommand>),
446    {
447        let Some(node) = self.descend(prefix) else {
448            return;
449        };
450        let mut path: Vec<ChordPattern> = Vec::new();
451        for (chord, child) in &node.children {
452            path.push(ChordPattern::Literal(*chord));
453            walk_node(child, &mut path, &mut f);
454            path.pop();
455        }
456        if let Some(wild) = node.char_wildcard.as_deref() {
457            path.push(ChordPattern::CharLiteral);
458            walk_node(wild, &mut path, &mut f);
459            path.pop();
460        }
461    }
462
463    /// WK.1: the immediate children of the node `prefix` names, as a
464    /// [`NodeView`]. `None` when the prefix leaves the trie — which-key
465    /// shows nothing for a chord the keymap does not know.
466    ///
467    /// Distinct from [`Self::walk_continuations`], which flattens the
468    /// whole subtree for `:describe-key`. Which-key wants one row per
469    /// next keystroke, so it needs depth one plus a count of what hangs
470    /// off each child.
471    ///
472    /// O(children + subtree) — the descendant counts walk each child's
473    /// subtree once. Runs on the actor thread after the idle delay,
474    /// never on the keystroke path.
475    pub fn node_view(&self, prefix: &[KeyChord]) -> Option<NodeView> {
476        let node = self.descend(prefix)?;
477        let children = node
478            .children
479            .iter()
480            .map(|(chord, child)| ChildView {
481                chord: *chord,
482                binding: child.binding.clone(),
483                descendants: count_node(child) - usize::from(child.binding.is_some()),
484            })
485            .collect();
486        let wildcard = node.char_wildcard.as_deref().map(|wild| ChildView {
487            // The wildcard has no chord of its own; callers render it as
488            // `{char}` and read this slot rather than the field.
489            chord: KeyChord::char('\0'),
490            binding: wild.binding.clone(),
491            descendants: count_node(wild) - usize::from(wild.binding.is_some()),
492        });
493        Some(NodeView {
494            children,
495            wildcard,
496            terminal: node.binding.clone(),
497        })
498    }
499
500    /// Overlay `other` on top of `self`. `other`'s bindings win
501    /// on conflict; the merge is structural so paths in `other`
502    /// that don't conflict simply add to `self`'s tree.
503    ///
504    /// # Examples
505    ///
506    /// ```
507    /// use std::sync::Arc;
508    /// use lattice_grammar::{CommandId, CommandInvocation, SourceLocation};
509    /// use lattice_keymap::{BoundCommand, ChordPattern, KeymapLayer, KeymapTrie};
510    /// use lattice_protocol::KeyChord;
511    ///
512    /// let bound = |id, layer| Arc::new(BoundCommand::from_invocation(
513    ///     CommandInvocation::of(CommandId::new(id)), SourceLocation::synthetic("doc"), layer,
514    /// ));
515    /// let x = [ChordPattern::Literal(KeyChord::char('x'))];
516    /// let y = [ChordPattern::Literal(KeyChord::char('y'))];
517    ///
518    /// let mut base = KeymapTrie::new();
519    /// base.insert(&x, bound(1, KeymapLayer::Builtin));
520    /// base.insert(&y, bound(2, KeymapLayer::Builtin));
521    /// let mut user = KeymapTrie::new();
522    /// user.insert(&x, bound(3, KeymapLayer::User));
523    ///
524    /// base.merge_over(&user);
525    /// assert_eq!(base.get(&x).unwrap().layer, KeymapLayer::User); // overridden
526    /// assert_eq!(base.get(&y).unwrap().layer, KeymapLayer::Builtin); // kept
527    /// ```
528    ///
529    /// The registry's layer-stack collapse calls this in
530    /// priority order (lowest first) so the highest-priority
531    /// layer's bindings end up authoritative. See
532    /// `docs/dev/architecture/keymap-architecture.md` §2 + §4 (layer-merge on
533    /// write, not on read).
534    pub fn merge_over(&mut self, other: &KeymapTrie) {
535        merge_node(&mut self.root, &other.root);
536    }
537
538    /// Number of terminal bindings in the trie. O(N) walk;
539    /// useful for tests + registry telemetry, not on the hot
540    /// path.
541    pub fn binding_count(&self) -> usize {
542        count_node(&self.root)
543    }
544
545    /// MARG.2 (2026-06-03): walk every terminal binding,
546    /// invoking `f` with the chord path that reaches it and
547    /// the `Arc<BoundCommand>` at that path. Used by the
548    /// reverse-keymap-cache builder in `KeymapRegistry` to
549    /// produce a `command_name → Vec<KeyChord>` map for the
550    /// keybinding annotator (see
551    /// `docs/dev/architecture/marginalia.md` §6).
552    ///
553    /// Path slice is borrowed; the closure must capture-by-
554    /// clone if it wants to retain the chord sequence. O(N)
555    /// over the bound-chord count — same cost class as
556    /// [`Self::binding_count`], not on the hot path.
557    pub fn walk_bindings<F>(&self, mut f: F)
558    where
559        F: FnMut(&[ChordPattern], &Arc<BoundCommand>),
560    {
561        let mut path: Vec<ChordPattern> = Vec::new();
562        walk_node(&self.root, &mut path, &mut f);
563    }
564}
565
566fn walk_node<F>(node: &TrieNode, path: &mut Vec<ChordPattern>, f: &mut F)
567where
568    F: FnMut(&[ChordPattern], &Arc<BoundCommand>),
569{
570    if let Some(b) = node.binding.as_ref() {
571        f(path.as_slice(), b);
572    }
573    for (chord, child) in &node.children {
574        path.push(ChordPattern::Literal(*chord));
575        walk_node(child, path, f);
576        path.pop();
577    }
578    if let Some(wild) = node.char_wildcard.as_deref() {
579        path.push(ChordPattern::CharLiteral);
580        walk_node(wild, path, f);
581        path.pop();
582    }
583}
584
585fn merge_node(dst: &mut TrieNode, src: &TrieNode) {
586    if let Some(b) = src.binding.as_ref() {
587        dst.binding = Some(Arc::clone(b));
588    }
589    for (chord, src_child) in &src.children {
590        let dst_child = dst.children.entry(*chord).or_default();
591        merge_node(dst_child, src_child);
592    }
593    if let Some(src_wild) = src.char_wildcard.as_deref() {
594        let dst_wild = dst
595            .char_wildcard
596            .get_or_insert_with(|| Box::new(TrieNode::default()));
597        merge_node(dst_wild, src_wild);
598    }
599}
600
601fn count_node(node: &TrieNode) -> usize {
602    let mut n = if node.binding.is_some() { 1 } else { 0 };
603    for child in node.children.values() {
604        n += count_node(child);
605    }
606    if let Some(wild) = node.char_wildcard.as_deref() {
607        n += count_node(wild);
608    }
609    n
610}
611
612// `KeyMods::is_empty` is `pub const` on `KeyChord::mods`; this
613// helper is only here to silence an unused-import warning if
614// future revisions of this file stop using `KeyMods` directly.
615#[allow(dead_code)]
616fn _assert_mods_used(m: KeyMods) -> bool {
617    m.is_empty()
618}
619
620#[cfg(test)]
621mod tests {
622    #![allow(clippy::unwrap_used, clippy::panic)]
623    use super::*;
624    use lattice_protocol::ids::CommandId;
625
626    fn fake_bound(label: &'static str) -> Arc<BoundCommand> {
627        // Tests don't dispatch the invocation; they just verify
628        // the trie returns the right `Arc<BoundCommand>` at the
629        // right path.
630        let _ = label;
631        Arc::new(BoundCommand::from_invocation(
632            CommandInvocation::of(CommandId::new(0)),
633            SourceLocation::synthetic("test"),
634            KeymapLayer::Builtin,
635        ))
636    }
637
638    fn lit(c: char) -> ChordPattern {
639        ChordPattern::Literal(KeyChord::char(c))
640    }
641
642    fn ctrl_lit(c: char) -> ChordPattern {
643        ChordPattern::Literal(KeyChord::ctrl(c))
644    }
645
646    fn pressed(c: char) -> KeyChord {
647        KeyChord::char(c)
648    }
649
650    #[test]
651    fn lookup_returns_bound_at_terminal() {
652        let mut t = KeymapTrie::new();
653        let bound = fake_bound("dd");
654        t.insert(&[lit('d'), lit('d')], Arc::clone(&bound));
655
656        let r = t.lookup(&[pressed('d'), pressed('d')]);
657        match r {
658            LookupResult::Bound { command, captured } => {
659                assert!(Arc::ptr_eq(&command, &bound));
660                assert!(captured.is_empty());
661            }
662            other => panic!("expected Bound, got {other:?}"),
663        }
664    }
665
666    #[test]
667    fn lookup_returns_partial_at_internal_node() {
668        let mut t = KeymapTrie::new();
669        t.insert(&[lit('g'), lit('d')], fake_bound("gd"));
670
671        let r = t.lookup(&[pressed('g')]);
672        assert!(matches!(r, LookupResult::Partial), "got {r:?}");
673    }
674
675    #[test]
676    fn lookup_returns_unbound_when_no_descent_matches() {
677        let mut t = KeymapTrie::new();
678        t.insert(&[lit('g'), lit('d')], fake_bound("gd"));
679
680        // `q` has no entry at root.
681        let r = t.lookup(&[pressed('q')]);
682        assert!(matches!(r, LookupResult::Unbound), "got {r:?}");
683
684        // `g` then `q` -- `g` is a partial node, but `q` doesn't
685        // descend from it.
686        let r = t.lookup(&[pressed('g'), pressed('q')]);
687        assert!(matches!(r, LookupResult::Unbound), "got {r:?}");
688    }
689
690    #[test]
691    fn lookup_walks_wildcard_and_captures_char() {
692        let mut t = KeymapTrie::new();
693        t.insert(
694            &[lit('f'), ChordPattern::CharLiteral],
695            fake_bound("find_char"),
696        );
697
698        let r = t.lookup(&[pressed('f'), pressed('x')]);
699        match r {
700            LookupResult::Bound { captured, .. } => {
701                assert_eq!(captured, vec!['x']);
702            }
703            other => panic!("expected Bound, got {other:?}"),
704        }
705
706        // Different char -> still bound, captures that char.
707        let r = t.lookup(&[pressed('f'), pressed('Q')]);
708        match r {
709            LookupResult::Bound { captured, .. } => {
710                assert_eq!(captured, vec!['Q']);
711            }
712            other => panic!("expected Bound, got {other:?}"),
713        }
714    }
715
716    #[test]
717    fn lookup_prefers_exact_match_over_wildcard() {
718        let mut t = KeymapTrie::new();
719        t.insert(
720            &[lit('f'), ChordPattern::CharLiteral],
721            fake_bound("find_char_wild"),
722        );
723        let exact = fake_bound("find_char_q_specific");
724        t.insert(&[lit('f'), lit('q')], Arc::clone(&exact));
725
726        // `f q` -- exact match wins.
727        let r = t.lookup(&[pressed('f'), pressed('q')]);
728        match r {
729            LookupResult::Bound { command, captured } => {
730                assert!(Arc::ptr_eq(&command, &exact));
731                assert!(captured.is_empty(), "exact-match path captures nothing");
732            }
733            other => panic!("expected Bound, got {other:?}"),
734        }
735
736        // `f x` -- falls through to wildcard.
737        let r = t.lookup(&[pressed('f'), pressed('x')]);
738        match r {
739            LookupResult::Bound { captured, .. } => {
740                assert_eq!(captured, vec!['x']);
741            }
742            other => panic!("expected Bound, got {other:?}"),
743        }
744    }
745
746    #[test]
747    fn wildcard_does_not_match_modifier_bearing_chord() {
748        let mut t = KeymapTrie::new();
749        t.insert(
750            &[lit('f'), ChordPattern::CharLiteral],
751            fake_bound("find_char"),
752        );
753
754        // `f <C-x>` -- the wildcard is intended for "any TYPED
755        // char", not Ctrl-x.
756        let r = t.lookup(&[pressed('f'), KeyChord::ctrl('x')]);
757        assert!(matches!(r, LookupResult::Unbound), "got {r:?}");
758    }
759
760    #[test]
761    fn remove_returns_dropped_binding_and_makes_path_unbound() {
762        let mut t = KeymapTrie::new();
763        let bound = fake_bound("dd");
764        t.insert(&[lit('d'), lit('d')], Arc::clone(&bound));
765
766        let dropped = t.remove(&[lit('d'), lit('d')]).expect("had binding");
767        assert!(Arc::ptr_eq(&dropped, &bound));
768
769        // After removal, the full `dd` lookup hits a node with
770        // no binding AND no further descents -- Unbound. The
771        // `d` prefix is still Partial because the empty
772        // intermediate node remains (a future bind at the
773        // same prefix can repopulate it without rebuilding
774        // the tree).
775        let full = t.lookup(&[pressed('d'), pressed('d')]);
776        assert!(matches!(full, LookupResult::Unbound), "got {full:?}");
777        let prefix = t.lookup(&[pressed('d')]);
778        assert!(matches!(prefix, LookupResult::Partial), "got {prefix:?}");
779    }
780
781    #[test]
782    fn merge_over_overlays_other_bindings_on_conflict() {
783        let mut base = KeymapTrie::new();
784        let base_dd = fake_bound("base.dd");
785        base.insert(&[lit('d'), lit('d')], Arc::clone(&base_dd));
786
787        let mut over = KeymapTrie::new();
788        let over_dd = fake_bound("over.dd");
789        over.insert(&[lit('d'), lit('d')], Arc::clone(&over_dd));
790        let over_yy = fake_bound("over.yy");
791        over.insert(&[lit('y'), lit('y')], Arc::clone(&over_yy));
792
793        base.merge_over(&over);
794
795        // `dd` -> over wins.
796        let r = base.lookup(&[pressed('d'), pressed('d')]);
797        match r {
798            LookupResult::Bound { command, .. } => {
799                assert!(Arc::ptr_eq(&command, &over_dd));
800            }
801            other => panic!("expected Bound, got {other:?}"),
802        }
803
804        // `yy` -> only over had it; carried over.
805        let r = base.lookup(&[pressed('y'), pressed('y')]);
806        assert!(matches!(r, LookupResult::Bound { .. }));
807    }
808
809    #[test]
810    fn merge_over_preserves_non_conflicting_paths() {
811        let mut base = KeymapTrie::new();
812        base.insert(&[lit('d'), lit('d')], fake_bound("base.dd"));
813
814        let mut over = KeymapTrie::new();
815        over.insert(&[lit('y'), lit('y')], fake_bound("over.yy"));
816
817        base.merge_over(&over);
818
819        // Both paths bind.
820        assert!(matches!(
821            base.lookup(&[pressed('d'), pressed('d')]),
822            LookupResult::Bound { .. }
823        ));
824        assert!(matches!(
825            base.lookup(&[pressed('y'), pressed('y')]),
826            LookupResult::Bound { .. }
827        ));
828    }
829
830    #[test]
831    fn merge_over_combines_wildcard_subtrees() {
832        let mut base = KeymapTrie::new();
833        base.insert(
834            &[lit('f'), ChordPattern::CharLiteral],
835            fake_bound("base.find"),
836        );
837
838        let mut over = KeymapTrie::new();
839        // Override the wildcard binding with a layer-higher
840        // one. Same wildcard slot, different bound.
841        let over_find = fake_bound("over.find");
842        over.insert(
843            &[lit('f'), ChordPattern::CharLiteral],
844            Arc::clone(&over_find),
845        );
846
847        base.merge_over(&over);
848        let r = base.lookup(&[pressed('f'), pressed('z')]);
849        match r {
850            LookupResult::Bound { command, captured } => {
851                assert!(Arc::ptr_eq(&command, &over_find));
852                assert_eq!(captured, vec!['z']);
853            }
854            other => panic!("expected Bound, got {other:?}"),
855        }
856    }
857
858    #[test]
859    fn binding_count_walks_every_terminal() {
860        let mut t = KeymapTrie::new();
861        t.insert(&[lit('j')], fake_bound("j"));
862        t.insert(&[lit('k')], fake_bound("k"));
863        t.insert(&[lit('g'), lit('d')], fake_bound("gd"));
864        t.insert(&[lit('g'), lit('g')], fake_bound("gg"));
865        t.insert(&[lit('f'), ChordPattern::CharLiteral], fake_bound("find"));
866        assert_eq!(t.binding_count(), 5);
867    }
868
869    #[test]
870    fn ctrl_modified_chord_routes_through_children_not_wildcard() {
871        // `<C-w>j` -- the second-key trie node is reached via
872        // an exact `<C-w>` child, not via a wildcard at root.
873        let mut t = KeymapTrie::new();
874        t.insert(&[ctrl_lit('w'), lit('j')], fake_bound("window_down"));
875
876        let r = t.lookup(&[KeyChord::ctrl('w'), pressed('j')]);
877        assert!(matches!(r, LookupResult::Bound { .. }));
878    }
879
880    #[test]
881    fn empty_path_insert_is_noop() {
882        let mut t = KeymapTrie::new();
883        t.insert(&[], fake_bound("ignored"));
884        assert_eq!(t.binding_count(), 0);
885    }
886
887    #[test]
888    fn empty_input_on_populated_trie_returns_partial() {
889        let mut t = KeymapTrie::new();
890        t.insert(&[lit('j')], fake_bound("j"));
891        let r = t.lookup(&[]);
892        assert!(matches!(r, LookupResult::Partial), "got {r:?}");
893    }
894
895    /// `get` addresses REGISTRATIONS, not keystrokes. A `{char}` segment walks
896    /// the wildcard slot only, so `[f, {char}]` and `[f, x]` give different
897    /// answers, which is exactly the distinction `lookup`'s fallback erases.
898    #[test]
899    fn get_addresses_pattern_paths_exactly() {
900        let mut t = KeymapTrie::new();
901        let wild = [lit('f'), ChordPattern::CharLiteral];
902        let exact = [lit('f'), lit('x')];
903        let bound = fake_bound("get");
904        t.insert(&wild, Arc::clone(&bound));
905
906        assert!(t.get(&wild).is_some_and(|b| Arc::ptr_eq(b, &bound)));
907        assert!(
908            t.get(&exact).is_none(),
909            "the wildcard is not a fallback for get"
910        );
911        assert!(
912            t.get(&[lit('f')]).is_none(),
913            "a prefix is not a registration"
914        );
915    }
916
917    #[test]
918    fn empty_input_on_empty_trie_returns_unbound() {
919        let t = KeymapTrie::new();
920        let r = t.lookup(&[]);
921        assert!(matches!(r, LookupResult::Unbound), "got {r:?}");
922    }
923}