Skip to main content

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}