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}