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}