Skip to main content

lattice_keymap/
resolution.rs

1//! `KeymapResolution` — trace result for `resolve_trace` and
2//! `resolve_trace_all_modes`.
3//!
4//! K.3 (2026-06-07): lives in `lattice-keymap` so the `:describe-key`
5//! handler in `lattice-host` and unit tests can use the type without
6//! depending on the full host stack.
7
8use std::sync::Arc;
9
10use crate::{BindingMode, BoundCommand, KeymapLayer};
11
12/// One layer's hit in a [`KeymapResolution`]. Every registered layer
13/// that has a terminal binding for the queried chord sequence
14/// contributes one `LayerHit`, in priority order ascending (Builtin
15/// first, Buffer last). The `active` flag is set by the caller against
16/// the active buffer's `ActiveModes` set to show which hit would
17/// actually fire.
18#[derive(Debug, Clone)]
19pub struct LayerHit {
20    /// The layer this binding lives in.
21    pub layer: KeymapLayer,
22    /// The bound command at this layer.
23    pub command: Arc<BoundCommand>,
24    /// Whether this layer is active for the buffer in question.
25    /// Always `true` for `Builtin`, `User`, and `Buffer` (always-on
26    /// layers); set by the `resolve_trace` caller for `MajorMode`
27    /// and `MinorMode` layers based on the buffer's active-modes list.
28    pub active: bool,
29}
30
31/// DK.4: one binding registered BELOW a queried prefix. Returned by
32/// `KeymapHandle::continuations`, which `:describe-key` renders when the
33/// chord it was asked about is a prefix rather than a binding.
34///
35/// "`<C-c><C-x>` is not bound in any mode" is technically true and
36/// useless; the answer the user came for is the subtree — what may
37/// follow, what each continuation runs, and which of them can fire in
38/// the buffer they asked from.
39#[derive(Debug, Clone)]
40pub struct Continuation {
41    /// The binding mode this continuation is registered in.
42    pub mode: BindingMode,
43    /// The chords that follow the queried prefix, already rendered
44    /// (`<C-b>`, `p`, `{char}` for a wildcard descent).
45    pub suffix: String,
46    /// The layer the continuation lives in.
47    pub layer: KeymapLayer,
48    /// The bound command at the end of the suffix.
49    pub command: Arc<BoundCommand>,
50    /// Whether this layer is active on the buffer that was described.
51    /// Inactive continuations are still listed — that is the point of
52    /// "describe-key answers for every key, not just the active ones" —
53    /// but they are marked, so the reader can tell "this exists" from
54    /// "this fires here".
55    pub active: bool,
56}
57
58/// Full trace of all layer hits for a chord sequence in one
59/// `BindingMode`. Returned by `KeymapHandle::resolve_trace`.
60///
61/// `hits` is ordered by layer priority ascending (Builtin first,
62/// Buffer last). The winner is the last active hit (highest-priority
63/// active layer wins). An empty `hits` vec means the chord is
64/// completely unregistered across all layers.
65#[derive(Debug, Clone)]
66pub struct KeymapResolution {
67    /// The binding mode queried.
68    pub mode: BindingMode,
69    /// All layer hits in priority order (ascending). Empty when no
70    /// layer has a terminal binding for the queried chord sequence.
71    pub hits: Vec<LayerHit>,
72}
73
74impl KeymapResolution {
75    /// The winning hit: the last active hit in priority order
76    /// (highest-priority active layer). `None` when no active layer
77    /// has a binding for the queried chord.
78    ///
79    /// Caveat: this ranks by the static [`KeymapLayer`] order, where
80    /// mode layers sit below `User` / `Buffer` and same-kind mode layers
81    /// sort by name. Dispatch
82    /// ([`KeymapHandle::lookup_with_context`](crate::KeymapHandle::lookup_with_context))
83    /// instead overlays active mode layers *above* `User` / `Buffer`, in
84    /// activation order. When an active mode layer and a `User` / `Buffer`
85    /// layer both bind the chord, or two active modes do, this can name a
86    /// different hit than the one that fires.
87    pub fn winner(&self) -> Option<&LayerHit> {
88        self.hits.iter().rev().find(|h| h.active)
89    }
90
91    /// `true` if at least one active layer has a binding.
92    pub fn is_bound(&self) -> bool {
93        self.winner().is_some()
94    }
95}
96
97/// Parse a `:describe-key` argument that may carry a mode prefix.
98///
99/// Supports the six primary-mode prefix shorthands:
100///
101/// | prefix | mode |
102/// |--------|------|
103/// | `n_`   | `Normal` |
104/// | `i_`   | `Insert` |
105/// | `v_`   | `Visual` |
106/// | `r_`   | `Replace` |
107/// | `c_`   | `Command` |
108/// | `s_`   | `Search` |
109///
110/// Returns `(mode, chord_str)`:
111/// - `mode` is `Some(BindingMode)` when a prefix was recognised;
112///   `chord_str` is the remainder after stripping the `x_` prefix.
113/// - When no prefix matches, `mode` is `None` and `chord_str` is
114///   the original input unchanged.
115///
116/// # Examples
117///
118/// ```
119/// use lattice_keymap::{BindingMode, parse_describe_key_arg};
120///
121/// let (mode, chord) = parse_describe_key_arg("n_j");
122/// assert_eq!(mode, Some(BindingMode::Normal));
123/// assert_eq!(chord, "j");
124///
125/// let (mode, chord) = parse_describe_key_arg("<C-w>j");
126/// assert_eq!(mode, None);
127/// assert_eq!(chord, "<C-w>j");
128///
129/// let (mode, chord) = parse_describe_key_arg("i_<C-n>");
130/// assert_eq!(mode, Some(BindingMode::Insert));
131/// assert_eq!(chord, "<C-n>");
132/// ```
133pub fn parse_describe_key_arg(s: &str) -> (Option<BindingMode>, &str) {
134    const PREFIXES: &[(&str, BindingMode)] = &[
135        ("n_", BindingMode::Normal),
136        ("i_", BindingMode::Insert),
137        ("v_", BindingMode::Visual),
138        ("r_", BindingMode::Replace),
139        ("c_", BindingMode::Command),
140        ("s_", BindingMode::Search),
141    ];
142    for (prefix, mode) in PREFIXES {
143        if let Some(rest) = s.strip_prefix(prefix) {
144            return (Some(*mode), rest);
145        }
146    }
147    (None, s)
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use crate::KeymapLayer;
154    use lattice_grammar::{CommandInvocation, SourceLocation};
155    use lattice_protocol::ids::CommandId;
156    use std::sync::Arc;
157
158    fn fake_bound(layer: KeymapLayer) -> Arc<BoundCommand> {
159        Arc::new(BoundCommand::from_invocation(
160            CommandInvocation::of(CommandId::new(0)),
161            SourceLocation::synthetic("test"),
162            layer,
163        ))
164    }
165
166    // ---- parse_describe_key_arg ----
167
168    #[test]
169    fn parse_normal_prefix() {
170        let (mode, chord) = parse_describe_key_arg("n_j");
171        assert_eq!(mode, Some(BindingMode::Normal));
172        assert_eq!(chord, "j");
173    }
174
175    #[test]
176    fn parse_insert_prefix() {
177        let (mode, chord) = parse_describe_key_arg("i_<C-n>");
178        assert_eq!(mode, Some(BindingMode::Insert));
179        assert_eq!(chord, "<C-n>");
180    }
181
182    #[test]
183    fn parse_visual_prefix() {
184        let (mode, chord) = parse_describe_key_arg("v_d");
185        assert_eq!(mode, Some(BindingMode::Visual));
186        assert_eq!(chord, "d");
187    }
188
189    #[test]
190    fn parse_replace_prefix() {
191        let (mode, chord) = parse_describe_key_arg("r_x");
192        assert_eq!(mode, Some(BindingMode::Replace));
193        assert_eq!(chord, "x");
194    }
195
196    #[test]
197    fn parse_command_prefix() {
198        let (mode, chord) = parse_describe_key_arg("c_<Tab>");
199        assert_eq!(mode, Some(BindingMode::Command));
200        assert_eq!(chord, "<Tab>");
201    }
202
203    #[test]
204    fn parse_search_prefix() {
205        let (mode, chord) = parse_describe_key_arg("s_<C-r>");
206        assert_eq!(mode, Some(BindingMode::Search));
207        assert_eq!(chord, "<C-r>");
208    }
209
210    #[test]
211    fn parse_no_prefix_returns_none_and_full_string() {
212        let (mode, chord) = parse_describe_key_arg("<C-w>j");
213        assert_eq!(mode, None);
214        assert_eq!(chord, "<C-w>j");
215    }
216
217    #[test]
218    fn parse_empty_string() {
219        let (mode, chord) = parse_describe_key_arg("");
220        assert_eq!(mode, None);
221        assert_eq!(chord, "");
222    }
223
224    #[test]
225    fn parse_prefix_only_no_chord() {
226        // "n_" with nothing after the prefix — chord_str is "".
227        let (mode, chord) = parse_describe_key_arg("n_");
228        assert_eq!(mode, Some(BindingMode::Normal));
229        assert_eq!(chord, "");
230    }
231
232    #[test]
233    fn parse_does_not_treat_x_alone_as_prefix() {
234        // "n" alone (no underscore) is just a chord, not a prefix.
235        let (mode, chord) = parse_describe_key_arg("n");
236        assert_eq!(mode, None);
237        assert_eq!(chord, "n");
238    }
239
240    // ---- KeymapResolution ----
241
242    #[test]
243    fn winner_returns_last_active_hit() {
244        let r = KeymapResolution {
245            mode: BindingMode::Normal,
246            hits: vec![
247                LayerHit {
248                    layer: KeymapLayer::Builtin,
249                    command: fake_bound(KeymapLayer::Builtin),
250                    active: true,
251                },
252                LayerHit {
253                    layer: KeymapLayer::User,
254                    command: fake_bound(KeymapLayer::User),
255                    active: true,
256                },
257            ],
258        };
259        // Last active hit (highest priority) is User.
260        let winner = r.winner().expect("should have winner");
261        assert_eq!(winner.layer, KeymapLayer::User);
262    }
263
264    #[test]
265    fn winner_skips_inactive_hits() {
266        let minor = crate::ModeId::new("diff-mode");
267        let r = KeymapResolution {
268            mode: BindingMode::Normal,
269            hits: vec![
270                LayerHit {
271                    layer: KeymapLayer::Builtin,
272                    command: fake_bound(KeymapLayer::Builtin),
273                    active: true,
274                },
275                LayerHit {
276                    layer: KeymapLayer::MinorMode(minor),
277                    command: fake_bound(KeymapLayer::MinorMode(minor)),
278                    active: false, // not active on this buffer
279                },
280            ],
281        };
282        // Minor-mode is registered but not active — Builtin wins.
283        let winner = r.winner().expect("should have winner");
284        assert_eq!(winner.layer, KeymapLayer::Builtin);
285    }
286
287    #[test]
288    fn winner_none_when_no_active_hits() {
289        let minor = crate::ModeId::new("some-mode");
290        let r = KeymapResolution {
291            mode: BindingMode::Normal,
292            hits: vec![LayerHit {
293                layer: KeymapLayer::MinorMode(minor),
294                command: fake_bound(KeymapLayer::MinorMode(minor)),
295                active: false,
296            }],
297        };
298        assert!(r.winner().is_none());
299        assert!(!r.is_bound());
300    }
301
302    #[test]
303    fn is_bound_true_when_active_hit_present() {
304        let r = KeymapResolution {
305            mode: BindingMode::Normal,
306            hits: vec![LayerHit {
307                layer: KeymapLayer::Builtin,
308                command: fake_bound(KeymapLayer::Builtin),
309                active: true,
310            }],
311        };
312        assert!(r.is_bound());
313    }
314
315    #[test]
316    fn empty_hits_means_not_bound() {
317        let r = KeymapResolution {
318            mode: BindingMode::Normal,
319            hits: vec![],
320        };
321        assert!(r.winner().is_none());
322        assert!(!r.is_bound());
323    }
324}