lattice_keymap/contribution.rs
1//! `Keymap` and `KeymapBinding` — a mode's declarative keymap contribution.
2//!
3//! K.3 (2026-06-07): moved from `lattice-mode::contributions` into
4//! `lattice-keymap` so `KeymapLayer`, the trie, and future
5//! `resolve_trace` can reference these types without a dep cycle.
6//!
7//! `lattice-mode::contributions` is retained as a re-export shim for
8//! `Keymap`, `KeymapBinding`, `Subscription`, and `DecorationProvider`.
9
10use lattice_grammar::{CommandInvocation, SourceLocation};
11use lattice_protocol::ChordPattern;
12
13use crate::BindingMode;
14use crate::KeymapEntry;
15
16/// One mode-contributed keymap binding.
17///
18/// Declarative: the host calls `lattice_mode::Mode::keymap` once at
19/// registration time and translates each binding into a
20/// `BoundCommand` inserted at `KeymapLayer::MinorMode(mode.id())`.
21/// Re-translation only happens on dynamic
22/// `ModeRegistry::register` after boot.
23///
24/// `source` is captured at the binding's own `file!()` +
25/// `line!()` (via [`SourceLocation::builtin_file`]) so
26/// `:describe-key` can name the contributing crate without
27/// the host having to track provenance separately.
28#[derive(Debug, Clone, PartialEq)]
29pub struct KeymapBinding {
30 /// Binding-mode the chord resolves in (Normal, Insert, …).
31 pub mode: BindingMode,
32 /// Registration path -- one [`ChordPattern`] per chord
33 /// in the sequence (`gd` -> two `Literal` chords, `'a` ->
34 /// one `Literal` + one `CharLiteral` for the mark name).
35 pub chords: Vec<ChordPattern>,
36 /// Typed invocation the dispatcher fires on match. Same
37 /// shape host-registered bindings carry, so the matcher
38 /// engine treats mode-contributed and host-registered
39 /// bindings identically once translated.
40 pub command: CommandInvocation,
41 /// Where this binding was registered. Surfaces in
42 /// `:describe-key` and the upcoming `:keymap` listing.
43 pub source: SourceLocation,
44 /// Human-readable one-line doc surfaced by `:describe-key`
45 /// and the `:keymap` listing. K.2.4.A.0.2: populated when
46 /// the binding originates from a `keymap_entry!`-driven
47 /// entry (every entry carries a doc) and translated by the
48 /// host pass into a `KeymapBinding`. `None` when the
49 /// binding came via `bind_chord` (the terse chain form,
50 /// optimized for ergonomics) or via `KeymapBinding::new`
51 /// directly. Plugin and runtime `:bind` callers can attach
52 /// docs via [`Self::with_doc`].
53 pub doc: Option<&'static str>,
54 /// SN.3c.2b: `:map`-style augment-and-continue, carried from the
55 /// owning [`KeymapEntry::fall_through`] through to the
56 /// [`crate::BoundCommand`] the registry stores. `false` by default;
57 /// see [`crate::BoundCommand::fall_through`] for the semantics.
58 pub fall_through: bool,
59}
60
61impl KeymapBinding {
62 /// Construct one mode-contributed binding. Modes use the
63 /// `lattice_grammar::SourceLocation::builtin_file(file!(),
64 /// line!())` idiom for `source` so provenance points at
65 /// the binding declaration's own `file:line`. `doc`
66 /// defaults to `None`; attach a doc string via
67 /// [`Self::with_doc`].
68 pub fn new(
69 mode: BindingMode,
70 chords: Vec<ChordPattern>,
71 command: CommandInvocation,
72 source: SourceLocation,
73 ) -> Self {
74 Self {
75 mode,
76 chords,
77 command,
78 source,
79 doc: None,
80 fall_through: false,
81 }
82 }
83
84 /// Attach a human-readable one-line doc. Returned via
85 /// `:describe-key` and `:keymap`. Builder shape so call
86 /// sites can chain `KeymapBinding::new(...).with_doc("...")`.
87 pub fn with_doc(mut self, doc: &'static str) -> Self {
88 self.doc = Some(doc);
89 self
90 }
91
92 /// SN.3c.2b: set the augment-and-continue flag (see
93 /// [`crate::BoundCommand::fall_through`]). Builder shape so the
94 /// table-form translation can chain it off the entry.
95 pub fn with_fall_through(mut self, fall_through: bool) -> Self {
96 self.fall_through = fall_through;
97 self
98 }
99}
100
101/// A mode's full keymap contribution.
102///
103/// `Keymap::default()` is the empty contribution -- modes that
104/// don't ship bindings rely on the `lattice_mode::Mode::keymap` trait
105/// default.
106///
107/// Two declaration paths share the same contribution shape:
108///
109/// 1. **Chain form** — `Keymap::new().bind_chord(...)` /
110/// `.bind(...)`. Terse; ergonomic for 1-5 bindings; populates
111/// [`Keymap::bindings`] with fully-typed [`KeymapBinding`]s.
112/// Source-location auto-captured via `#[track_caller]`. No
113/// docstring per binding (use `.bind(KeymapBinding::new(...)
114/// .with_doc(...))` if needed).
115/// 2. **Table form** — `Keymap::from_entries(&MY_TABLE)` /
116/// `.extend_with_entries(&...)`. Static-catalog-style;
117/// ergonomic for 5-20+ bindings; references a
118/// `&'static [KeymapEntry]` built with the
119/// [`keymap_entry!`](crate::keymap_entry!) macro. Each entry carries a docstring; the host
120/// translation pass (K.2.4.A.0.3) resolves the entry's
121/// canonical command-name string against the
122/// `CommandRegistry` at registration time, building one
123/// [`KeymapBinding`] per resolvable entry. Mode authors
124/// declare entries in a `static` slice next to the impl;
125/// macro-captured `file!()` + `line!()` give per-row
126/// provenance.
127///
128/// The two paths compose:
129///
130/// ```
131/// use lattice_grammar::{CommandId, CommandInvocation};
132/// use lattice_keymap::{BindingMode, Keymap, KeymapEntry, keymap_entry};
133/// use std::sync::LazyLock;
134///
135/// // A static table (`KeymapEntry` embeds a `SourceLocation`, so the
136/// // slice is built lazily rather than as a `const`).
137/// static MY_KEYMAP: LazyLock<Vec<KeymapEntry>> = LazyLock::new(|| vec![
138/// keymap_entry! { mode: Normal, chord: "]e", doc: "Next excerpt", cmd: "my:excerpt-next" },
139/// keymap_entry! { mode: [Normal, Visual], chord: "q", doc: "Close", cmd: "my:close" },
140/// ]);
141///
142/// let refresh = CommandInvocation::of(CommandId::new(7));
143/// let km = Keymap::from_entries(MY_KEYMAP.as_slice())
144/// .bind_chord(BindingMode::Normal, "<C-r>", refresh.clone());
145///
146/// assert_eq!(km.entries.len(), 2); // resolved against the CommandRegistry later
147/// assert_eq!(km.bindings.len(), 1); // already typed
148/// assert_eq!(km.bindings[0].command, refresh);
149/// assert_eq!(km.bindings[0].doc, None); // the chain form carries no doc
150/// ```
151///
152/// Layer placement is implicit at translation time: every
153/// binding / entry contributed by `Mode X` lands at
154/// `KeymapLayer::MinorMode(x.id())` per K.1.b convention.
155/// Per-binding layer is *not* exposed here -- letting a mode
156/// inject into another layer would break the layer-priority
157/// contract (a "minor mode" silently shadowing a builtin would
158/// be invisible to `:describe-key`).
159#[derive(Debug, Clone, Default, PartialEq)]
160pub struct Keymap {
161 /// Declarative list of bindings this mode contributes.
162 /// Populated by the chain form (`bind` / `bind_chord`) and
163 /// by the host translation pass when it resolves entries.
164 pub bindings: Vec<KeymapBinding>,
165 /// Static-catalog-style entries this mode contributes.
166 /// Populated by [`Self::from_entries`] /
167 /// [`Self::extend_with_entries`]. The host translation
168 /// pass (K.2.4.A.0.3) walks both `bindings` and `entries`;
169 /// entries get name→`CommandId` resolved via the
170 /// `CommandRegistry` and the resulting [`KeymapBinding`]s
171 /// (carrying the entry's `doc`) flow into the trie
172 /// alongside the explicit `bindings`.
173 pub entries: Vec<&'static KeymapEntry>,
174}
175
176impl Keymap {
177 /// Empty keymap. Equivalent to `Keymap::default()`; kept
178 /// for symmetry with builder-style construction.
179 pub fn new() -> Self {
180 Self::default()
181 }
182
183 /// Build a keymap from a static slice of `keymap_entry!`-
184 /// constructed entries. The host translation pass resolves
185 /// each entry's canonical command-name string against the
186 /// `CommandRegistry` at registration time; unresolvable
187 /// names log a `tracing::warn!` and skip the binding
188 /// (matches the existing catalog-drift convention).
189 ///
190 /// Returns a keymap with [`Self::entries`] populated and
191 /// [`Self::bindings`] empty. Compose with the chain form
192 /// (`.bind_chord(...)`) to add typed bindings on top.
193 pub fn from_entries(entries: &'static [KeymapEntry]) -> Self {
194 Self {
195 bindings: Vec::new(),
196 entries: entries.iter().collect(),
197 }
198 }
199
200 /// Append a static slice of `keymap_entry!`-constructed
201 /// entries to an existing keymap. Returns `self` so call
202 /// sites can chain
203 /// `Keymap::new().bind_chord(...).extend_with_entries(&TBL)`
204 /// or
205 /// `Keymap::from_entries(&BASE).extend_with_entries(&MORE)`.
206 pub fn extend_with_entries(mut self, entries: &'static [KeymapEntry]) -> Self {
207 self.entries.extend(entries.iter());
208 self
209 }
210
211 /// Append one binding. Returns `self` so call sites can
212 /// chain `Keymap::new().bind(...).bind(...)`.
213 pub fn bind(mut self, binding: KeymapBinding) -> Self {
214 self.bindings.push(binding);
215 self
216 }
217
218 /// Append one binding parsed from a chord-sequence string.
219 ///
220 /// The recommended idiom for mode-contributed keymaps:
221 ///
222 /// ```
223 /// use lattice_grammar::{CommandId, CommandInvocation};
224 /// use lattice_keymap::{BindingMode, ChordPattern, Keymap};
225 /// use lattice_protocol::KeyChord;
226 ///
227 /// let next = CommandInvocation::of(CommandId::new(1));
228 /// let prev = CommandInvocation::of(CommandId::new(2));
229 /// let km = Keymap::new()
230 /// .bind_chord(BindingMode::Normal, "]e", next)
231 /// .bind_chord(BindingMode::Normal, "<C-w>j", prev);
232 ///
233 /// assert_eq!(
234 /// km.bindings[1].chords,
235 /// vec![
236 /// ChordPattern::Literal(KeyChord::ctrl('w')),
237 /// ChordPattern::Literal(KeyChord::char('j')),
238 /// ],
239 /// );
240 /// // Provenance is this call site, via `#[track_caller]`.
241 /// assert!(matches!(
242 /// &km.bindings[0].source.kind,
243 /// lattice_grammar::SourceKind::File { path, .. } if path == std::path::Path::new(file!()),
244 /// ));
245 /// ```
246 ///
247 /// `#[track_caller]` propagates the binding row's own
248 /// `file:line` into the resulting [`SourceLocation`]; no
249 /// `SourceLocation::builtin_file(file!(), line!())`
250 /// boilerplate per row. `:describe-key` shows the chord's
251 /// declaration site directly.
252 ///
253 /// The chord string is parsed via
254 /// [`lattice_protocol::parse_chord_sequence`] -- accepts
255 /// the same notation the host's `keymap_entry!` macro
256 /// catalog uses (`"j"`, `"gd"`, `"]e"`, `"<C-w>j"`,
257 /// `"<Esc>"`, `"<C-S-x>"`, …). Wildcards (`'a`, `"a`,
258 /// `fX`) are *not* expressible here; the rare mode that
259 /// needs `ChordPattern::CharLiteral` calls [`Keymap::bind`]
260 /// directly with an explicit `chords` vector.
261 ///
262 /// # Panics
263 ///
264 /// On a chord-string parse error. Mode bindings are declared
265 /// at compile-time-static call sites with constant chord
266 /// strings; a malformed string is a bug in the mode impl,
267 /// not a runtime condition. The panic message names the
268 /// chord string + the caller location so the fix is
269 /// obvious. Same shape as host-side catalog drift: the
270 /// editor refuses to boot rather than silently dropping
271 /// the binding.
272 #[track_caller]
273 pub fn bind_chord(self, mode: BindingMode, chord: &str, command: CommandInvocation) -> Self {
274 let chords = lattice_protocol::parse_chord_sequence(chord)
275 .unwrap_or_else(|e| panic!("bind_chord: chord {chord:?} failed to parse: {e}"))
276 .into_iter()
277 .map(ChordPattern::Literal)
278 .collect();
279 let loc = std::panic::Location::caller();
280 let source = SourceLocation::builtin_file(loc.file(), loc.line());
281 self.bind(KeymapBinding::new(mode, chords, command, source))
282 }
283
284 /// Bind one chord across SEVERAL modes — the declarative multi-mode
285 /// peer of [`Self::bind_chord`]. Pushes one [`KeymapBinding`] per
286 /// mode (the registry trie is per-mode), so `:describe-key` sees the
287 /// chord in each named mode. `modes` must be non-empty.
288 ///
289 /// The imperative peer is [`crate::KeymapHandle::bind_modes`]; the
290 /// `keymap_entry!` `mode: [..]` catalog form is the static-table peer.
291 /// Same parse-or-panic discipline + caller-location capture as
292 /// [`Self::bind_chord`].
293 #[track_caller]
294 pub fn bind_chord_modes(
295 mut self,
296 modes: &[BindingMode],
297 chord: &str,
298 command: CommandInvocation,
299 ) -> Self {
300 let chords: Vec<ChordPattern> = lattice_protocol::parse_chord_sequence(chord)
301 .unwrap_or_else(|e| panic!("bind_chord_modes: chord {chord:?} failed to parse: {e}"))
302 .into_iter()
303 .map(ChordPattern::Literal)
304 .collect();
305 let loc = std::panic::Location::caller();
306 let source = SourceLocation::builtin_file(loc.file(), loc.line());
307 for &mode in modes {
308 self.bindings.push(KeymapBinding::new(
309 mode,
310 chords.clone(),
311 command.clone(),
312 source.clone(),
313 ));
314 }
315 self
316 }
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322 use lattice_grammar::SourceKind;
323 use lattice_protocol::{CommandId, KeyChord};
324
325 fn synthetic_invocation() -> CommandInvocation {
326 CommandInvocation::of(CommandId::new(1))
327 }
328
329 #[test]
330 fn default_keymap_has_no_bindings() {
331 let km = Keymap::default();
332 assert!(km.bindings.is_empty());
333 }
334
335 #[test]
336 fn new_equals_default() {
337 assert_eq!(Keymap::new(), Keymap::default());
338 }
339
340 #[test]
341 fn bind_appends_and_preserves_order() {
342 let a = KeymapBinding::new(
343 BindingMode::Normal,
344 vec![ChordPattern::Literal(KeyChord::char('a'))],
345 synthetic_invocation(),
346 SourceLocation::builtin_file(file!(), line!()),
347 );
348 let b = KeymapBinding::new(
349 BindingMode::Normal,
350 vec![ChordPattern::Literal(KeyChord::char('b'))],
351 synthetic_invocation(),
352 SourceLocation::builtin_file(file!(), line!()),
353 );
354 let km = Keymap::new().bind(a.clone()).bind(b.clone());
355 assert_eq!(km.bindings, vec![a, b]);
356 }
357
358 #[test]
359 fn bind_chord_modes_pushes_one_binding_per_mode() {
360 let km = Keymap::new().bind_chord_modes(
361 &[BindingMode::Normal, BindingMode::Visual],
362 "zn",
363 synthetic_invocation(),
364 );
365 assert_eq!(km.bindings.len(), 2, "one binding per named mode");
366 assert_eq!(km.bindings[0].mode, BindingMode::Normal);
367 assert_eq!(km.bindings[1].mode, BindingMode::Visual);
368 // Same parsed chord sequence + command in both.
369 assert_eq!(km.bindings[0].chords, km.bindings[1].chords);
370 assert_eq!(km.bindings[0].command, km.bindings[1].command);
371 }
372
373 #[test]
374 fn equality_is_structural() {
375 // PartialEq derives through chords, CommandInvocation,
376 // and SourceLocation -- two structurally-identical
377 // bindings compare equal. Capture `line` once so both
378 // bindings carry the same source-location for the test.
379 let line = line!();
380 let make = || {
381 KeymapBinding::new(
382 BindingMode::Visual,
383 vec![ChordPattern::Literal(KeyChord::char('v'))],
384 synthetic_invocation(),
385 SourceLocation::builtin_file(file!(), line),
386 )
387 };
388 assert_eq!(make(), make());
389 }
390
391 #[test]
392 fn source_location_captures_declaration_line() {
393 // `file!()` + `line!()` evaluated at the binding's own
394 // declaration site -- this is the contract `:describe-key`
395 // depends on. Capture two SourceLocations at known
396 // lines and assert they hold those exact lines.
397 let line_a = line!();
398 let loc_a = SourceLocation::builtin_file(file!(), line_a);
399 let line_b = line!();
400 let loc_b = SourceLocation::builtin_file(file!(), line_b);
401 assert_ne!(loc_a, loc_b);
402 match &loc_a.kind {
403 SourceKind::File { line, .. } => assert_eq!(*line, Some(line_a)),
404 other => panic!("expected SourceKind::File, got {other:?}"),
405 }
406 match &loc_b.kind {
407 SourceKind::File { line, .. } => assert_eq!(*line, Some(line_b)),
408 other => panic!("expected SourceKind::File, got {other:?}"),
409 }
410 }
411
412 #[test]
413 fn bind_chord_parses_chord_string_into_literal_pattern() {
414 // `]e` parses to two Literal chords (the multibuffer
415 // excerpt-next idiom). Ergonomic substitute for
416 // building `vec![ChordPattern::Literal(...), ...]` by
417 // hand.
418 let km = Keymap::new().bind_chord(BindingMode::Normal, "]e", synthetic_invocation());
419 assert_eq!(km.bindings.len(), 1);
420 assert_eq!(km.bindings[0].mode, BindingMode::Normal);
421 assert_eq!(
422 km.bindings[0].chords,
423 vec![
424 ChordPattern::Literal(KeyChord::char(']')),
425 ChordPattern::Literal(KeyChord::char('e')),
426 ],
427 );
428 }
429
430 #[test]
431 fn bind_chord_parses_modifier_notation() {
432 // `<C-w>j` -- modifier-bearing first chord plus a bare
433 // second chord. Same shape `keymap_entry!` accepts.
434 let km = Keymap::new().bind_chord(BindingMode::Normal, "<C-w>j", synthetic_invocation());
435 assert_eq!(
436 km.bindings[0].chords,
437 vec![
438 ChordPattern::Literal(KeyChord::ctrl('w')),
439 ChordPattern::Literal(KeyChord::char('j')),
440 ],
441 );
442 }
443
444 #[test]
445 fn bind_chord_parses_emacs_style_prefix_sequence() {
446 // `<C-x>pp` -- modifier-bearing prefix followed by two
447 // bare chord descents. The shape emacs's `C-x p p`
448 // (project-switch-project) takes once mapped into
449 // Lattice's keymap. The trie indexes three nodes:
450 // ctrl('x') -> char('p') -> char('p') (terminal).
451 let km = Keymap::new().bind_chord(BindingMode::Normal, "<C-x>pp", synthetic_invocation());
452 assert_eq!(
453 km.bindings[0].chords,
454 vec![
455 ChordPattern::Literal(KeyChord::ctrl('x')),
456 ChordPattern::Literal(KeyChord::char('p')),
457 ChordPattern::Literal(KeyChord::char('p')),
458 ],
459 );
460 }
461
462 #[test]
463 fn bind_chord_captures_source_at_call_site() {
464 // `#[track_caller]` -- the resulting binding's source
465 // points at the line `bind_chord` was called on, not
466 // at the inside of `bind_chord` itself. That's what
467 // makes the API ergonomic for mode tables.
468 let expected_line = line!() + 1;
469 let km = Keymap::new().bind_chord(BindingMode::Normal, "j", synthetic_invocation());
470 match &km.bindings[0].source.kind {
471 SourceKind::File { line, path } => {
472 assert_eq!(*line, Some(expected_line));
473 assert!(
474 path.to_string_lossy().ends_with("contribution.rs"),
475 "source path = {}",
476 path.display(),
477 );
478 }
479 other => panic!("expected SourceKind::File, got {other:?}"),
480 }
481 }
482
483 #[test]
484 #[should_panic(expected = "bind_chord")]
485 fn bind_chord_panics_on_invalid_chord_string() {
486 // Malformed chord string at a binding declaration is a
487 // mode-impl bug -- surface at boot, not silently drop.
488 let _ = Keymap::new().bind_chord(BindingMode::Normal, "<Foo>", synthetic_invocation());
489 }
490
491 // ---- K.2.4.A.0.2: table-form contribution (`from_entries`) ----
492
493 #[test]
494 fn default_keymap_has_no_entries() {
495 // Sibling of `default_keymap_has_no_bindings` for the
496 // new `entries` field. Modes that don't ship table-form
497 // entries leave it empty; the host translation pass
498 // walks it without finding work.
499 let km = Keymap::default();
500 assert!(km.entries.is_empty());
501 }
502
503 #[test]
504 fn from_entries_collects_slice() {
505 // Use the built-in vim default keymap (the static
506 // catalog moved to lattice-mode in K.2.4.A.0.1) as a
507 // realistic fixture — confirms the type plumbing
508 // accepts the same shape modes will return from
509 // `Mode::keymap()`.
510 let catalog = crate::keymap_entry::default_keymap();
511 let km = Keymap::from_entries(catalog);
512 assert!(
513 km.bindings.is_empty(),
514 "from_entries leaves the bindings list empty"
515 );
516 assert_eq!(km.entries.len(), catalog.len());
517 }
518
519 #[test]
520 fn extend_with_entries_appends_in_order() {
521 // Split the static catalog in half and feed it through
522 // the chain form. Resulting entries should be the
523 // catalog's concatenation, with order preserved across
524 // both halves.
525 let catalog = crate::keymap_entry::default_keymap();
526 let mid = catalog.len() / 2;
527 let first = &catalog[..mid];
528 let second = &catalog[mid..];
529 let km = Keymap::from_entries(first).extend_with_entries(second);
530 assert_eq!(km.entries.len(), catalog.len());
531 // Pointer-equality on the borrowed entries: the first
532 // collected entry IS the catalog's first entry; the
533 // entry at `mid` IS the second slice's first entry.
534 assert!(std::ptr::eq(km.entries[0], &first[0]));
535 assert!(std::ptr::eq(km.entries[mid], &second[0]));
536 }
537
538 // ---- K.2.4.A.0.2: KeymapBinding::with_doc ----
539
540 #[test]
541 fn with_doc_sets_doc() {
542 let binding = KeymapBinding::new(
543 BindingMode::Normal,
544 vec![ChordPattern::Literal(KeyChord::char('q'))],
545 synthetic_invocation(),
546 SourceLocation::builtin_file(file!(), line!()),
547 );
548 assert_eq!(binding.doc, None, "KeymapBinding::new defaults doc to None");
549 let binding = binding.with_doc("Quit");
550 assert_eq!(binding.doc, Some("Quit"));
551 }
552}