Skip to main content

lattice_keymap/
keymap_entry.rs

1//! Static keymap catalog + `keymap_entry!` macro (DESIGN.md §5.2.3).
2//!
3//! K.3 (2026-06-07): moved from `lattice-mode::keymap_entry` into
4//! `lattice-keymap` so the trie, `KeymapLayer`, and `resolve_trace`
5//! can reference the entry type without a dep cycle back to
6//! `lattice-mode`.
7//!
8//! `lattice-mode::keymap_entry` is retained as a re-export shim.
9//! `lattice-host::keymap` and `lattice-ui-tui` continue to re-export
10//! the macro and entry type unchanged.
11//!
12//! ## Construction
13//!
14//! [`KeymapEntry`] keeps its `source` field private; the
15//! [`keymap_entry!`](crate::keymap_entry!) macro is the only intended construction path,
16//! and the macro calls the `#[doc(hidden)]` [`KeymapEntry::__new`]
17//! constructor to populate it. External crates that try to build a
18//! literal directly fail at the privacy boundary — preserving the
19//! forgery-prevention discipline from DESIGN.md §5.11.1 (`source` is
20//! captured at the row's own `file!()` + `line!()`, not supplied
21//! ad-hoc).
22//!
23//! Notation:
24//! - Plain chars: `j`, `dw`, `gg`.
25//! - Modifier-prefixed: `<C-d>`, `<C-v>`, `<C-r>`.
26//! - Special keys: `<Esc>`, `<CR>`, `<Tab>`, `<Up>`, `<Down>`, `<Left>`,
27//!   `<Right>`, `<Home>`, `<End>`, `<PageUp>`, `<PageDown>`, `<BS>`.
28//! - Multi-key sequences are concatenated: `gg`, `dw`, `zt`.
29
30use crate::BindingMode;
31
32/// One row in the catalog. Ordering of fields matches the rendering
33/// order in `:describe-key` so reading one field at a time still tells
34/// a coherent story.
35///
36/// The `source` field is private; the [`keymap_entry!`](crate::keymap_entry!) macro is the
37/// only intended construction path. The macro calls the
38/// `#[doc(hidden)]` [`KeymapEntry::__new`] constructor to populate
39/// it. External crates trying to build a literal directly hit the
40/// privacy boundary — preserving forgery-prevention per
41/// DESIGN.md §5.11.1: every binding's source is captured at the row's
42/// own `file!()` + `line!()`, not supplied ad-hoc.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct KeymapEntry {
45    /// The chord sequence in keymap notation (`"j"`, `"gg"`, `"<C-w>j"`,
46    /// `"<Esc>"` — see the module docs). Stored unparsed; parsing happens in
47    /// the host translation pass, and [`lookup`] compares this string
48    /// verbatim, so `"<C-w>"` and `"<c-w>"` are different rows here.
49    pub chord: &'static str,
50    /// The binding-modes this row is live in. A single-mode row
51    /// (`mode: Normal`) carries a one-element slice; a multi-mode row
52    /// (`mode: [Normal, Visual]`) carries one element per mode. The
53    /// host translation pass (`resolve_entries_into_bindings`) fans this
54    /// out into one `KeymapBinding` per mode, so the per-mode trie and
55    /// `:describe-key` see each mode independently. Always non-empty.
56    pub modes: &'static [BindingMode],
57    /// One-line human description, shown by `:describe-key`, `:keymap` and
58    /// which-key. Every entry has one (the macro requires it).
59    pub doc: &'static str,
60    /// Canonical name in the `CommandRegistry`. `None` for synthetic
61    /// actions (`PushDigit`, `SetPending`, `StartMacroRecord`, ...) that
62    /// don't bind a registered command.
63    pub command: Option<&'static str>,
64    /// SN.3c.2b: `:map`-style augment-and-continue. `true` = after this
65    /// binding's action runs, the dispatcher re-resolves the same chord
66    /// against the layers below the owning mode and runs the native
67    /// binding too (e.g. `active-snippet-mode`'s `<Esc>` clears the
68    /// session, then continues to the builtin `<Esc>` → exit insert).
69    /// `false` (default, set by the no-`fall_through` macro forms) =
70    /// the binding fully shadows its chord. Propagates to the
71    /// [`BoundCommand`](crate::BoundCommand) the registry stores.
72    pub fall_through: bool,
73    /// Where this binding was registered. For static entries built by
74    /// the [`keymap_entry!`](crate::keymap_entry!) macro this is the row's own `file:line`;
75    /// for runtime user binds this is the config-loader / dispatcher
76    /// source. Private — read via [`Self::source`]; construct via the
77    /// [`keymap_entry!`](crate::keymap_entry!) macro.
78    source: lattice_grammar::SourceLocation,
79}
80
81impl KeymapEntry {
82    /// Borrow the entry's source location. Replaces direct field
83    /// access now that `source` is private.
84    pub fn source(&self) -> &lattice_grammar::SourceLocation {
85        &self.source
86    }
87
88    /// Macro-internal constructor. `#[doc(hidden)]` and prefixed
89    /// `__` to signal "do not call directly"; the [`keymap_entry!`](crate::keymap_entry!)
90    /// macro is the only intended caller. Public visibility is
91    /// required so the macro expands cleanly in external crates
92    /// (mode crates that contribute their own keymaps). Forgery
93    /// prevention is by convention — calling `__new` directly with
94    /// a hand-rolled `SourceLocation` defeats the file/line capture
95    /// the macro provides, just as calling
96    /// `lattice_grammar::SourceLocation::builtin_file(...)` with a
97    /// fake path does. See DESIGN.md §5.11.1.
98    #[doc(hidden)]
99    pub fn __new(
100        chord: &'static str,
101        modes: &'static [BindingMode],
102        doc: &'static str,
103        command: Option<&'static str>,
104        fall_through: bool,
105        source: lattice_grammar::SourceLocation,
106    ) -> Self {
107        debug_assert!(
108            !modes.is_empty(),
109            "keymap_entry! requires at least one mode"
110        );
111        Self {
112            chord,
113            modes,
114            doc,
115            command,
116            fall_through,
117            source,
118        }
119    }
120
121    /// Human-readable mode list for `:describe-key` / `:keymap` and
122    /// diagnostics — e.g. `"Normal"` or `"Normal, Visual"`.
123    pub fn modes_label(&self) -> String {
124        self.modes
125            .iter()
126            .map(|m| m.label())
127            .collect::<Vec<_>>()
128            .join(", ")
129    }
130}
131
132/// Helper used by the [`keymap_entry!`](crate::keymap_entry!) macro to construct a
133/// `Builtin` source from `file!()` + `line!()`. `pub` + hidden so the
134/// macro can expand in external mode crates; direct callers defeat
135/// the file/line capture the macro provides.
136#[doc(hidden)]
137pub fn __builtin_source(file: &'static str, line: u32) -> lattice_grammar::SourceLocation {
138    lattice_grammar::SourceLocation::builtin_file(file, line)
139}
140
141impl lattice_grammar::Introspectable for KeymapEntry {
142    fn kind_label(&self) -> &'static str {
143        "key"
144    }
145
146    fn identifier(&self) -> String {
147        format!("{}  ({})", self.chord, self.modes_label())
148    }
149
150    fn doc(&self) -> &str {
151        self.doc
152    }
153
154    fn sources(&self) -> Vec<lattice_grammar::SourceEntry<'_>> {
155        vec![lattice_grammar::SourceEntry {
156            label: lattice_grammar::SourceLabel::BoundAt,
157            source: &self.source,
158        }]
159    }
160
161    fn extra_sections(&self) -> Vec<lattice_grammar::HelpSection> {
162        let mut lines = Vec::new();
163        if let Some(name) = self.command {
164            // Cross-reference: `[name](command:name)` markdown
165            // link follows to :describe-command.
166            lines.push(format!("Invokes: [{name}](command:{name})"));
167        }
168        if lines.is_empty() {
169            return Vec::new();
170        }
171        vec![lattice_grammar::HelpSection {
172            heading: "Action:".to_string(),
173            lines,
174            anchor: Some("action".to_string()),
175        }]
176    }
177}
178
179/// Construct a [`KeymapEntry`] with the row's source location captured
180/// at the macro invocation site. Forms:
181///
182/// - `keymap_entry! { mode: Normal, chord: "j", doc: "Move down", cmd: "motion:line-down" }`
183/// - `keymap_entry! { mode: Help, chord: "j", doc: "Scroll down" }`  (no cmd, defaults to None)
184/// - `keymap_entry! { mode: Normal, chord: "x", doc: "Custom", cmd: Some("plugin:foo") }`  (explicit Option)
185/// - `keymap_entry! { mode: [Normal, Visual], chord: "zn", doc: "Narrow", cmd: "operator:narrow" }`  (multi-mode)
186///
187/// The `mode:` slot accepts a single mode (`Normal`) or a bracketed list
188/// (`[Normal, Visual]`). A single mode sugars to a one-element slice, so
189/// there is ONE code path: every entry carries `modes: &[BindingMode]`,
190/// and the host translation pass fans it out into one binding per mode.
191/// Existing single-mode call sites are unchanged.
192///
193/// Expansion goes through [`KeymapEntry::__new`] so the `source` field
194/// stays private — callers cannot bypass per-row provenance by
195/// hand-rolling a literal. `file!()` and `line!()` expand at the
196/// macro invocation site, so each row in a static slice records its
197/// own distinct line.
198///
199/// K.3: usable from any crate that depends on `lattice-keymap` (directly)
200/// or transitively through `lattice-mode`, `lattice-host`, or `lattice-ui-tui`.
201/// The path qualifier `$crate::keymap_entry::KeymapEntry::__new` resolves
202/// inside `lattice-keymap`; callers use `lattice_keymap::keymap_entry! { … }`,
203/// `lattice_mode::keymap_entry! { … }`, or `lattice_host::keymap_entry!`.
204///
205/// # Examples
206///
207/// ```
208/// use lattice_keymap::{BindingMode, keymap_entry};
209///
210/// let e = keymap_entry! { mode: Normal, chord: "j", doc: "Move down", cmd: "motion:line-down" };
211/// assert_eq!(e.command, Some("motion:line-down"));
212/// assert_eq!(e.modes, [BindingMode::Normal].as_slice());
213/// assert!(!e.fall_through);
214///
215/// // Multi-mode, no command (a synthetic action), augment-and-continue.
216/// let e = keymap_entry! {
217///     mode: [Insert, Snippet], chord: "<Esc>", doc: "Leave", cmd: None, fall_through: true
218/// };
219/// assert_eq!(e.command, None);
220/// assert_eq!(e.modes_label(), "Insert, Active-snippet (minor mode)");
221/// assert!(e.fall_through);
222/// ```
223#[macro_export]
224macro_rules! keymap_entry {
225    // ----- Entry arms: match the `mode:` slot FRESH (single ident or a
226    // ----- bracketed list), normalize it to a parenthesized
227    // ----- `&[BindingMode]` slice token, and forward to `@build` with
228    // ----- the rest of the fields. Matching mode here — never after a
229    // ----- `:tt` forward — sidesteps the macro_rules gotcha where an
230    // ----- interpolated `:tt` won't re-match as `:ident` / `[..]`. The
231    // ----- single form becomes a one-element slice so there is ONE code
232    // ----- path (B-field): every entry carries a mode slice, fanned out
233    // ----- into one binding per mode by the host translation pass.
234    { mode: $m:ident, $($rest:tt)* } => {
235        $crate::keymap_entry!(@build (&[$crate::BindingMode::$m]) $($rest)*)
236    };
237    { mode: [ $($m:ident),+ $(,)? ], $($rest:tt)* } => {
238        $crate::keymap_entry!(@build (&[$($crate::BindingMode::$m),+]) $($rest)*)
239    };
240
241    // ----- `@build`: `$modes` is the parenthesized slice token built
242    // ----- above and is only ever EMITTED (re-matched as `:tt`, which is
243    // ----- always safe), never destructured. These arms normalize the
244    // ----- `cmd:` sugar exactly as before, then call `__new`.
245    // No-cmd form: defaults command to None.
246    (@build $modes:tt chord: $chord:expr, doc: $doc:expr $(,)?) => {
247        $crate::keymap_entry!(@build $modes chord: $chord, doc: $doc, cmd: None)
248    };
249    // String-literal sugar + fall_through (SN.3c.2b).
250    (@build $modes:tt chord: $chord:expr, doc: $doc:expr, cmd: $cmd:literal, fall_through: $ft:expr $(,)?) => {
251        $crate::keymap_entry!(@build $modes chord: $chord, doc: $doc, cmd: Some($cmd), fall_through: $ft)
252    };
253    // String-literal sugar: `cmd: "name"` -> `cmd: Some("name")`.
254    (@build $modes:tt chord: $chord:expr, doc: $doc:expr, cmd: $cmd:literal $(,)?) => {
255        $crate::keymap_entry!(@build $modes chord: $chord, doc: $doc, cmd: Some($cmd))
256    };
257    // Explicit form + fall_through (SN.3c.2b).
258    (@build $modes:tt chord: $chord:expr, doc: $doc:expr, cmd: $cmd:expr, fall_through: $ft:expr $(,)?) => {
259        $crate::keymap_entry::KeymapEntry::__new(
260            $chord,
261            $modes,
262            $doc,
263            $cmd,
264            $ft,
265            $crate::keymap_entry::__builtin_source(file!(), line!()),
266        )
267    };
268    // Explicit form: `cmd: None` or `cmd: Some(...)`. fall_through defaults false.
269    (@build $modes:tt chord: $chord:expr, doc: $doc:expr, cmd: $cmd:expr $(,)?) => {
270        $crate::keymap_entry::KeymapEntry::__new(
271            $chord,
272            $modes,
273            $doc,
274            $cmd,
275            false,
276            $crate::keymap_entry::__builtin_source(file!(), line!()),
277        )
278    };
279}
280
281/// Borrow the full vim default keymap. Lazily initialised on first
282/// call (the entries embed `SourceLocation`s which carry `PathBuf`s
283/// and therefore can't live in a `static`).
284///
285/// Order matters: chords that share a prefix (`g` vs `gg`) appear
286/// adjacent so a reader scanning the table sees the prefix
287/// relationship. The drift test in `input.rs` and `:keymap`'s render
288/// both walk this slice in declaration order.
289pub fn default_keymap() -> &'static [KeymapEntry] {
290    static CACHE: std::sync::OnceLock<Vec<KeymapEntry>> = std::sync::OnceLock::new();
291    CACHE.get_or_init(build_default_keymap)
292}
293
294fn build_default_keymap() -> Vec<KeymapEntry> {
295    vec![
296        // ---- Normal: motions ----
297        keymap_entry! { mode: Normal, chord: "h", doc: "Move cursor left", cmd: "motion:char-left" },
298        keymap_entry! { mode: Normal, chord: "<Left>", doc: "Move cursor left", cmd: "motion:char-left" },
299        keymap_entry! { mode: Normal, chord: "j", doc: "Move cursor down", cmd: "motion:line-down" },
300        keymap_entry! { mode: Normal, chord: "<Down>", doc: "Move cursor down", cmd: "motion:line-down" },
301        keymap_entry! { mode: Normal, chord: "k", doc: "Move cursor up", cmd: "motion:line-up" },
302        keymap_entry! { mode: Normal, chord: "<Up>", doc: "Move cursor up", cmd: "motion:line-up" },
303        keymap_entry! { mode: Normal, chord: "l", doc: "Move cursor right", cmd: "motion:char-right" },
304        keymap_entry! { mode: Normal, chord: "<Right>", doc: "Move cursor right", cmd: "motion:char-right" },
305        keymap_entry! { mode: Normal, chord: "0", doc: "Jump to start of line (column 0)", cmd: "motion:line-start" },
306        keymap_entry! { mode: Normal, chord: "<Home>", doc: "Jump to start of line", cmd: "motion:line-start" },
307        keymap_entry! { mode: Normal, chord: "$", doc: "Jump to end of line", cmd: "motion:line-end" },
308        keymap_entry! { mode: Normal, chord: "<End>", doc: "Jump to end of line", cmd: "motion:line-end" },
309        keymap_entry! { mode: Normal, chord: "^", doc: "Jump to first non-blank on line", cmd: "motion:first-non-blank" },
310        keymap_entry! { mode: Normal, chord: "w", doc: "Word forward (start of next word)", cmd: "motion:word-forward" },
311        keymap_entry! { mode: Normal, chord: "b", doc: "Word backward (start of previous word)", cmd: "motion:word-backward" },
312        keymap_entry! { mode: Normal, chord: "e", doc: "Word end (last byte of current/next word)", cmd: "motion:word-end" },
313        keymap_entry! { mode: Normal, chord: "W", doc: "WORD forward (whitespace-delimited)", cmd: "motion:big-word-forward" },
314        keymap_entry! { mode: Normal, chord: "B", doc: "WORD backward", cmd: "motion:big-word-backward" },
315        keymap_entry! { mode: Normal, chord: "E", doc: "WORD end", cmd: "motion:big-word-end" },
316        keymap_entry! { mode: Normal, chord: "}", doc: "Next paragraph boundary", cmd: "motion:paragraph-forward" },
317        keymap_entry! { mode: Normal, chord: "{", doc: "Previous paragraph boundary", cmd: "motion:paragraph-backward" },
318        keymap_entry! { mode: Normal, chord: ")", doc: "Next sentence", cmd: "motion:sentence-forward" },
319        keymap_entry! { mode: Normal, chord: "(", doc: "Previous sentence", cmd: "motion:sentence-backward" },
320        keymap_entry! { mode: Normal, chord: "G", doc: "Jump to last line", cmd: "motion:goto-last-line" },
321        keymap_entry! { mode: Normal, chord: "gg", doc: "Jump to first line", cmd: "motion:goto-first-line" },
322        // ---- Normal: viewport jumps ----
323        keymap_entry! { mode: Normal, chord: "H", doc: "Cursor to top of viewport" },
324        keymap_entry! { mode: Normal, chord: "M", doc: "Cursor to middle of viewport" },
325        keymap_entry! { mode: Normal, chord: "L", doc: "Cursor to bottom of viewport" },
326        // ---- Normal: scrolling ----
327        keymap_entry! { mode: Normal, chord: "<C-d>", doc: "Scroll half-page down (10 lines)", cmd: "motion:line-down" },
328        keymap_entry! { mode: Normal, chord: "<C-u>", doc: "Scroll half-page up (10 lines)", cmd: "motion:line-up" },
329        keymap_entry! { mode: Normal, chord: "<C-f>", doc: "Page down" },
330        keymap_entry! { mode: Normal, chord: "<C-b>", doc: "Page up" },
331        keymap_entry! { mode: Normal, chord: "<C-e>", doc: "Scroll viewport down one line" },
332        keymap_entry! { mode: Normal, chord: "<C-y>", doc: "Scroll viewport up one line" },
333        keymap_entry! { mode: Normal, chord: "<PageDown>", doc: "Page down (10 lines)", cmd: "motion:line-down" },
334        keymap_entry! { mode: Normal, chord: "<PageUp>", doc: "Page up (10 lines)", cmd: "motion:line-up" },
335        // ---- Normal: undo/redo, dot, jump-list ----
336        keymap_entry! { mode: Normal, chord: "u", doc: "Undo last change" },
337        keymap_entry! { mode: Normal, chord: "<C-r>", doc: "Redo (reverse undo)" },
338        keymap_entry! { mode: Normal, chord: ".", doc: "Repeat last change (dot-repeat)" },
339        keymap_entry! { mode: Normal, chord: "<C-o>", doc: "Jump-list back (previous AutoJump position)" },
340        keymap_entry! { mode: Normal, chord: "<C-i>", doc: "Jump-list forward" },
341        keymap_entry! { mode: Normal, chord: "<C-6>", doc: "Back through this pane's buffer history (restores the cursor you left)", cmd: "action:pane-history-back" },
342        keymap_entry! { mode: Normal, chord: "<C-7>", doc: "Forward through this pane's buffer history", cmd: "action:pane-history-forward" },
343        keymap_entry! { mode: Normal, chord: "<Tab>", doc: "Jump-list forward (terminal alias for Ctrl-I)" },
344        keymap_entry! { mode: Normal, chord: "<C-l>", doc: "Force redraw (clear terminal, reparse syntax, reset highlight cache)" },
345        // ---- Normal: pending-key prefixes ----
346        keymap_entry! { mode: Normal, chord: "g", doc: "Pending: second key resolves to gg/gU/gu/g~/gv/gJ/g;/g," },
347        keymap_entry! { mode: Normal, chord: "z", doc: "Pending: scroll/fold sub-commands" },
348        keymap_entry! { mode: Normal, chord: "d", doc: "Delete operator -- use with motion/text-object; doubled (`dd`) deletes current line", cmd: "operator:delete" },
349        keymap_entry! { mode: Normal, chord: "c", doc: "Change operator -- delete then enter Insert", cmd: "operator:change" },
350        keymap_entry! { mode: Normal, chord: "y", doc: "Yank operator -- copy without modifying", cmd: "operator:yank" },
351        keymap_entry! { mode: Normal, chord: ">", doc: "Indent-right operator", cmd: "operator:indent-right" },
352        keymap_entry! { mode: Normal, chord: "<", doc: "Indent-left operator", cmd: "operator:indent-left" },
353        // ---- Normal: standalone deletes/changes ----
354        keymap_entry! { mode: Normal, chord: "x", doc: "Delete one char to the right", cmd: "operator:delete" },
355        keymap_entry! { mode: Normal, chord: "r", doc: "Replace [count] char(s) under the cursor with the next typed char (waits for it); stays in Normal", cmd: "operator:replace-char" },
356        keymap_entry! { mode: Normal, chord: "D", doc: "Delete to end of line (== d$)", cmd: "operator:delete" },
357        keymap_entry! { mode: Normal, chord: "C", doc: "Change to end of line (== c$)", cmd: "operator:change" },
358        keymap_entry! { mode: Normal, chord: "S", doc: "Substitute current line (== cc)", cmd: "operator:change" },
359        keymap_entry! { mode: Normal, chord: "Y", doc: "Yank current line (== yy)", cmd: "operator:yank" },
360        keymap_entry! { mode: Normal, chord: "J", doc: "Join current line with next (insert space at boundary)" },
361        // ---- Normal: paste ----
362        keymap_entry! { mode: Normal, chord: "p", doc: "Paste after cursor / below current line" },
363        keymap_entry! { mode: Normal, chord: "P", doc: "Paste before cursor / above current line" },
364        // ---- Normal: case ----
365        keymap_entry! { mode: Normal, chord: "~", doc: "Toggle case at cursor and advance", cmd: "operator:toggle-case" },
366        // ---- Normal: mode entry ----
367        keymap_entry! { mode: Normal, chord: "i", doc: "Enter Insert mode at cursor" },
368        keymap_entry! { mode: Normal, chord: "a", doc: "Enter Insert mode after cursor" },
369        keymap_entry! { mode: Normal, chord: "o", doc: "Open new line below + Insert" },
370        keymap_entry! { mode: Normal, chord: "O", doc: "Open new line above + Insert" },
371        keymap_entry! { mode: Normal, chord: "v", doc: "Enter Visual (charwise)" },
372        keymap_entry! { mode: Normal, chord: "V", doc: "Enter Visual (linewise)" },
373        keymap_entry! { mode: Normal, chord: "<C-v>", doc: "Enter Visual (blockwise)" },
374        keymap_entry! { mode: Normal, chord: "<C-q>", doc: "Enter Visual (blockwise) -- alternate when terminal hijacks Ctrl-V" },
375        keymap_entry! { mode: Normal, chord: "R", doc: "Enter Replace mode" },
376        keymap_entry! { mode: Normal, chord: ":", doc: "Enter command-line" },
377        // ---- Normal: search ----
378        keymap_entry! { mode: Normal, chord: "/", doc: "Forward search" },
379        keymap_entry! { mode: Normal, chord: "?", doc: "Backward search" },
380        keymap_entry! { mode: Normal, chord: "n", doc: "Next search match (same direction)" },
381        keymap_entry! { mode: Normal, chord: "N", doc: "Previous search match (reverse direction)" },
382        keymap_entry! { mode: Normal, chord: "*", doc: "Search word under cursor forward" },
383        keymap_entry! { mode: Normal, chord: "#", doc: "Search word under cursor backward" },
384        keymap_entry! { mode: Normal, chord: "%", doc: "Jump to matching bracket" },
385        // ---- Normal: find-char prefixes ----
386        keymap_entry! { mode: Normal, chord: "f", doc: "Find char forward (waits for target char)", cmd: "motion:find-char-forward" },
387        keymap_entry! { mode: Normal, chord: "F", doc: "Find char backward", cmd: "motion:find-char-backward" },
388        keymap_entry! { mode: Normal, chord: "t", doc: "Till char forward (one before)", cmd: "motion:till-char-forward" },
389        keymap_entry! { mode: Normal, chord: "T", doc: "Till char backward (one after)", cmd: "motion:till-char-backward" },
390        keymap_entry! { mode: Normal, chord: ";", doc: "Repeat last find/till in same direction" },
391        keymap_entry! { mode: Normal, chord: ",", doc: "Repeat last find/till in reverse direction" },
392        // ---- Normal: marks ----
393        keymap_entry! { mode: Normal, chord: "m", doc: "Set named mark (next key is mark name)" },
394        keymap_entry! { mode: Normal, chord: "'", doc: "Jump to mark line (next key is mark name)" },
395        keymap_entry! { mode: Normal, chord: "`", doc: "Jump to mark exact position (next key is mark name)" },
396        // ---- Normal: registers, macros ----
397        keymap_entry! { mode: Normal, chord: "\"", doc: "Select register for next operator/paste (next key is register name)" },
398        keymap_entry! { mode: Normal, chord: "q", doc: "Start macro recording (next key is register; press q again to stop)" },
399        keymap_entry! { mode: Normal, chord: "@", doc: "Play macro from register (next key is register, or @ for last)" },
400        // ---- Normal: window management (DESIGN.md §5.9 -- B.1.b) ----
401        keymap_entry! { mode: Normal, chord: "<C-w>", doc: "Pending: window-management chord (split / close / navigate)" },
402        // ---- After-<C-w> sub-commands ----
403        keymap_entry! { mode: AfterCtrlW, chord: "<C-w>s", doc: "Split active pane horizontally (new pane below)" },
404        keymap_entry! { mode: AfterCtrlW, chord: "<C-w>v", doc: "Split active pane vertically (new pane right)" },
405        keymap_entry! { mode: AfterCtrlW, chord: "<C-w>c", doc: "Close active pane" },
406        keymap_entry! { mode: AfterCtrlW, chord: "<C-w>q", doc: "Close active pane (alias of <C-w>c)" },
407        keymap_entry! { mode: AfterCtrlW, chord: "<C-w>h", doc: "Navigate to pane on the left" },
408        keymap_entry! { mode: AfterCtrlW, chord: "<C-w>j", doc: "Navigate to pane below" },
409        keymap_entry! { mode: AfterCtrlW, chord: "<C-w>k", doc: "Navigate to pane above" },
410        keymap_entry! { mode: AfterCtrlW, chord: "<C-w>l", doc: "Navigate to pane on the right" },
411        keymap_entry! { mode: AfterCtrlW, chord: "<C-w>w", doc: "Cycle to next pane" },
412        keymap_entry! { mode: AfterCtrlW, chord: "<C-w>W", doc: "Cycle to previous pane" },
413        // ZP.2 (2026-09-09): tmux-style zoom. Non-destructive, unlike
414        // `<C-w>o` / `:only` — the split layout is restored verbatim.
415        keymap_entry! { mode: AfterCtrlW, chord: "<C-w>z", doc: "Toggle zoom on the active pane (layout preserved; tmux `prefix z`)" },
416        // Issue #28 (2026-05-22): split ratio adjustment.
417        keymap_entry! { mode: AfterCtrlW, chord: "<C-w>=", doc: "Equalize all split ratios (reset to 50/50)" },
418        keymap_entry! { mode: AfterCtrlW, chord: "<C-w>+", doc: "Grow active pane vertically" },
419        keymap_entry! { mode: AfterCtrlW, chord: "<C-w>-", doc: "Shrink active pane vertically" },
420        keymap_entry! { mode: AfterCtrlW, chord: "<C-w>>", doc: "Grow active pane horizontally" },
421        keymap_entry! { mode: AfterCtrlW, chord: "<C-w><", doc: "Shrink active pane horizontally" },
422        // ---- After-g sub-commands ----
423        keymap_entry! { mode: AfterG, chord: "gU", doc: "Uppercase operator -- prefix to motion/text-object; doubled = current line", cmd: "operator:upper" },
424        keymap_entry! { mode: AfterG, chord: "gu", doc: "Lowercase operator", cmd: "operator:lower" },
425        keymap_entry! { mode: AfterG, chord: "g~", doc: "Toggle-case operator", cmd: "operator:toggle-case" },
426        keymap_entry! { mode: AfterG, chord: "gv", doc: "Re-enter Visual with last selection" },
427        // Issue #29 (2026-05-22): tab navigation.
428        keymap_entry! { mode: AfterG, chord: "gt", doc: "Switch to the next tab (or {N}gt for absolute tab N)" },
429        keymap_entry! { mode: AfterG, chord: "gT", doc: "Switch to the previous tab" },
430        // Issue #29 slice 3: tab navigation aliases.
431        keymap_entry! { mode: Normal, chord: "<C-PageDown>", doc: "Switch to the next tab (alias of gt)" },
432        keymap_entry! { mode: Normal, chord: "<C-PageUp>", doc: "Switch to the previous tab (alias of gT)" },
433        keymap_entry! { mode: AfterG, chord: "gJ", doc: "Join lines without inserting a space" },
434        keymap_entry! { mode: AfterG, chord: "g;", doc: "Walk named-mark history backward" },
435        keymap_entry! { mode: AfterG, chord: "g,", doc: "Walk named-mark history forward" },
436        // gd / gD / gy / gI / gr / gx / K moved to LspMode::keymap() (MO.1).
437        keymap_entry! { mode: Normal, chord: "<C-t>", doc: "Tag stack: pop -- walk back through the LIFO chain of LSP go-to-definition drill-downs (independent of <C-o> jump list)" },
438        // ---- After-z sub-commands (scroll + folds) ----
439        keymap_entry! { mode: AfterZ, chord: "zz", doc: "Center cursor in viewport" },
440        keymap_entry! { mode: AfterZ, chord: "z.", doc: "Center cursor in viewport (alias of zz)" },
441        keymap_entry! { mode: AfterZ, chord: "zt", doc: "Cursor to top of viewport" },
442        keymap_entry! { mode: AfterZ, chord: "z<CR>", doc: "Cursor to top of viewport (alias of zt)" },
443        keymap_entry! { mode: AfterZ, chord: "zb", doc: "Cursor to bottom of viewport" },
444        keymap_entry! { mode: AfterZ, chord: "z-", doc: "Cursor to bottom of viewport (alias of zb)" },
445        keymap_entry! { mode: AfterZ, chord: "zf", doc: "Create fold from current Visual selection" },
446        keymap_entry! { mode: AfterZ, chord: "zo", doc: "Open fold at cursor" },
447        keymap_entry! { mode: AfterZ, chord: "zc", doc: "Close fold at cursor" },
448        keymap_entry! { mode: AfterZ, chord: "za", doc: "Toggle fold at cursor" },
449        keymap_entry! { mode: AfterZ, chord: "zR", doc: "Open all folds" },
450        keymap_entry! { mode: AfterZ, chord: "zM", doc: "Close all folds" },
451        keymap_entry! { mode: AfterZ, chord: "zd", doc: "Delete fold at cursor" },
452        keymap_entry! { mode: AfterZ, chord: "zj", doc: "Jump to next fold" },
453        keymap_entry! { mode: AfterZ, chord: "zk", doc: "Jump to previous fold" },
454        keymap_entry! { mode: AfterZ, chord: "zi", doc: "Toggle foldenable" },
455        keymap_entry! { mode: AfterZ, chord: "zl", doc: "Scroll view right [count] columns" },
456        keymap_entry! { mode: AfterZ, chord: "zh", doc: "Scroll view left [count] columns" },
457        keymap_entry! { mode: AfterZ, chord: "zL", doc: "Scroll view right half a screen" },
458        keymap_entry! { mode: AfterZ, chord: "zH", doc: "Scroll view left half a screen" },
459        keymap_entry! { mode: AfterZ, chord: "zs", doc: "Scroll cursor column to left edge" },
460        keymap_entry! { mode: AfterZ, chord: "ze", doc: "Scroll cursor column to right edge" },
461        // ---- Visual mode (motions extend, operators dispatch on Range::Selection) ----
462        keymap_entry! { mode: Visual, chord: "<Esc>", doc: "Exit to Normal" },
463        keymap_entry! { mode: Visual, chord: "v", doc: "Toggle: exit Visual" },
464        keymap_entry! { mode: Visual, chord: "V", doc: "Toggle: exit Visual" },
465        keymap_entry! { mode: Visual, chord: "h", doc: "Extend selection left", cmd: "motion:char-left" },
466        keymap_entry! { mode: Visual, chord: "j", doc: "Extend selection down", cmd: "motion:line-down" },
467        keymap_entry! { mode: Visual, chord: "k", doc: "Extend selection up", cmd: "motion:line-up" },
468        keymap_entry! { mode: Visual, chord: "l", doc: "Extend selection right", cmd: "motion:char-right" },
469        keymap_entry! { mode: Visual, chord: "0", doc: "Extend to start of line", cmd: "motion:line-start" },
470        keymap_entry! { mode: Visual, chord: "$", doc: "Extend to end of line", cmd: "motion:line-end" },
471        keymap_entry! { mode: Visual, chord: "^", doc: "Extend to first non-blank", cmd: "motion:first-non-blank" },
472        keymap_entry! { mode: Visual, chord: "w", doc: "Extend by word forward", cmd: "motion:word-forward" },
473        keymap_entry! { mode: Visual, chord: "b", doc: "Extend by word backward", cmd: "motion:word-backward" },
474        keymap_entry! { mode: Visual, chord: "e", doc: "Extend to word end", cmd: "motion:word-end" },
475        keymap_entry! { mode: Visual, chord: "G", doc: "Extend to last line", cmd: "motion:goto-last-line" },
476        keymap_entry! { mode: Visual, chord: "d", doc: "Delete selection", cmd: "operator:delete" },
477        keymap_entry! { mode: Visual, chord: "x", doc: "Delete selection (alias of d)", cmd: "operator:delete" },
478        keymap_entry! { mode: Visual, chord: "c", doc: "Change selection (delete + Insert)", cmd: "operator:change" },
479        keymap_entry! { mode: Visual, chord: "s", doc: "Change selection (alias of c)", cmd: "operator:change" },
480        keymap_entry! { mode: Visual, chord: "y", doc: "Yank selection", cmd: "operator:yank" },
481        keymap_entry! { mode: Visual, chord: "r", doc: "Replace every selected char with the next typed char (waits for it); returns to Normal", cmd: "operator:replace-char" },
482        // ---- Insert mode ----
483        keymap_entry! { mode: Insert, chord: "<Esc>", doc: "Exit to Normal" },
484        keymap_entry! { mode: Insert, chord: "<BS>", doc: "Delete char to the left" },
485        keymap_entry! { mode: Insert, chord: "<CR>", doc: "Insert newline" },
486        keymap_entry! { mode: Insert, chord: "<Tab>", doc: "Insert tab character" },
487        // ---- Insert-mode completion (Phase 4.2.g.1; CSM.K1
488        // retired the `<C-x><C-o>` alias -- `<C-Space>` is the
489        // sole popup trigger; per-source filter chords live
490        // inside `completion-popup-mode`, contributed via each
491        // source mode's `popup_filter_chord` field).
492        keymap_entry! { mode: Insert, chord: "<C-Space>", doc: "Manual completion trigger -- opens the popup with sources matching the prefix at the cursor" },
493        // Readline/vim line editing, general in every buffer.
494        keymap_entry! { mode: Insert, chord: "<C-a>", doc: "Move the caret to the start of the line (readline)" },
495        keymap_entry! { mode: Insert, chord: "<C-e>", doc: "Move the caret to the end of the line (readline)" },
496        keymap_entry! { mode: Insert, chord: "<C-b>", doc: "Move the caret one character left (readline)" },
497        keymap_entry! { mode: Insert, chord: "<C-f>", doc: "Move the caret one character right (readline)" },
498        keymap_entry! { mode: Insert, chord: "<C-w>", doc: "Delete the word before the caret (readline/vim)" },
499        keymap_entry! { mode: Insert, chord: "<C-u>", doc: "Delete from the line start to the caret (readline/vim)" },
500        keymap_entry! { mode: Insert, chord: "<C-k>", doc: "Delete from the caret to the line end (readline)" },
501        keymap_entry! { mode: Insert, chord: "<C-t>", doc: "Indent the current line by one shiftwidth (vim)" },
502        keymap_entry! { mode: Insert, chord: "<C-d>", doc: "Dedent the current line by one shiftwidth (vim)" },
503        keymap_entry! { mode: Insert, chord: "<C-x>", doc: "Pending: vim's expansion-prefix family. `<C-x><C-s>` (snippet-expand-at-cursor) is the only live chord today, contributed by `snippet-mode` (SN.3c.1), not Builtin." },
504        // Completion-popup minor mode -- bindings active only
505        // while `App.insert_completion.is_some()`. Override
506        // Insert-mode + Normal-mode meanings (notably <C-d>)
507        // for the popup's lifetime; closing the popup
508        // restores the original bindings.
509        keymap_entry! { mode: CompletionPopup, chord: "<C-n>", doc: "Completion popup: select next candidate" },
510        keymap_entry! { mode: CompletionPopup, chord: "<C-p>", doc: "Completion popup: select previous candidate" },
511        keymap_entry! { mode: CompletionPopup, chord: "<C-y>", doc: "Completion popup: accept selected candidate (vim convention)" },
512        keymap_entry! { mode: CompletionPopup, chord: "<Tab>", doc: "Completion popup: accept selected candidate" },
513        keymap_entry! { mode: CompletionPopup, chord: "<CR>", doc: "Completion popup: accept selected candidate" },
514        keymap_entry! { mode: CompletionPopup, chord: "<C-e>", doc: "Completion popup: cancel popup, stay in Insert (vim convention)" },
515        keymap_entry! { mode: CompletionPopup, chord: "<Esc>", doc: "Completion popup: cancel popup AND exit Insert (vim convention)" },
516        keymap_entry! { mode: CompletionPopup, chord: "<C-Space>", doc: "Completion popup: re-trigger / refresh (LSP isIncomplete path)" },
517        keymap_entry! { mode: CompletionPopup, chord: "<C-d>", doc: "Completion popup: toggle side documentation popup for the focused candidate" },
518        keymap_entry! { mode: CompletionPopup, chord: "<C-f>", doc: "Completion popup: page docs popup forward" },
519        keymap_entry! { mode: CompletionPopup, chord: "<C-b>", doc: "Completion popup: page docs popup backward" },
520        // ---- Snippets (Phase 4.2.g.4) ----
521        keymap_entry! { mode: AfterCtrlX, chord: "<C-x><C-s>", doc: "Direct snippet expansion: look up the word at the cursor in the snippet registry; expand without surfacing the popup" },
522        // Active-snippet minor mode -- bindings active only
523        // while `App.active_snippet.is_some()`. Override
524        // Insert-mode `<Tab>` / `<Esc>` for placeholder
525        // navigation; deactivates when the snippet exits.
526        keymap_entry! { mode: Snippet, chord: "<Tab>", doc: "Snippet: jump to next placeholder (or exit on $0)" },
527        keymap_entry! { mode: Snippet, chord: "<S-Tab>", doc: "Snippet: jump to previous placeholder" },
528        keymap_entry! { mode: Snippet, chord: "<Esc>", doc: "Snippet: exit the snippet (placeholders become plain text) and return to Normal" },
529        // ---- Replace mode ----
530        keymap_entry! { mode: Replace, chord: "<Esc>", doc: "Exit to Normal" },
531        keymap_entry! { mode: Replace, chord: "<BS>", doc: "Restore last overwritten byte" },
532        keymap_entry! { mode: Replace, chord: "<CR>", doc: "Insert newline" },
533        // ---- Command (`:`) ----
534        keymap_entry! { mode: Command, chord: "<Esc>", doc: "Cancel command line (or dismiss completion popup if open)" },
535        keymap_entry! { mode: Command, chord: "<CR>", doc: "Submit command line (or accept completion if popup is open)" },
536        keymap_entry! { mode: Command, chord: "<BS>", doc: "Delete previous char" },
537        keymap_entry! { mode: Command, chord: "<Up>", doc: "Walk command-history backward" },
538        keymap_entry! { mode: Command, chord: "<Down>", doc: "Walk command-history forward" },
539        keymap_entry! { mode: Command, chord: "<Tab>", doc: "Trigger completion / advance to next candidate" },
540        keymap_entry! { mode: Command, chord: "<C-h>", doc: "Describe command word / arg under cursor", cmd: "ex:describe-command" },
541        keymap_entry! { mode: Command, chord: "<C-u>", doc: "Clear the command line" },
542        keymap_entry! { mode: Command, chord: "<C-w>", doc: "Delete the trailing word" },
543        // ---- Search (`/` `?`) ----
544        keymap_entry! { mode: Search, chord: "<Esc>", doc: "Cancel search" },
545        keymap_entry! { mode: Search, chord: "<CR>", doc: "Submit search" },
546        keymap_entry! { mode: Search, chord: "<BS>", doc: "Delete previous char" },
547        // ---- Help buffer (DESIGN.md §5.11, §5.9) ----
548        //
549        // Help is a regular buffer routed through Normal-mode chord
550        // grammar. Only these buffer-local bindings differ -- they
551        // appear here. Motions, page motions, viewport jumps, marks,
552        // `<C-o>` / `<C-i>`, etc. all inherit from the Normal-mode
553        // entries above; describe-key in Help mode reports them
554        // through the `Normal` rows.
555        //
556        // `<Esc>` is NOT here: it is owned by `help-mode`'s own keymap
557        // (`action:help-dismiss`), the LM.4 file-tree pattern, so its
558        // descriptor comes from the mode layer rather than this host catalog
559        // (the drift guard resolves catalog chords through `translate`, which
560        // no longer intercepts help's Esc).
561        keymap_entry! { mode: Help, chord: "q", doc: "Dismiss help" },
562        keymap_entry! { mode: Help, chord: "<CR>", doc: "Follow link under cursor" },
563    ]
564}
565
566/// Every row of the built-in [`default_keymap`] whose [`KeymapEntry::chord`]
567/// string equals `chord` exactly — across all modes, in table order. The
568/// match is on the notation string, case-sensitively, with no chord parsing
569/// or normalisation. Used by `:describe-key`.
570///
571/// This consults only the static built-in catalog, never the live
572/// [`KeymapRegistry`](crate::KeymapRegistry): mode, user and plugin bindings
573/// are not visible here (use
574/// [`KeymapHandle::resolve_trace`](crate::KeymapHandle::resolve_trace) for
575/// the live answer).
576///
577/// # Examples
578///
579/// ```
580/// use lattice_keymap::{BindingMode, lookup};
581///
582/// let hits = lookup("gg");
583/// assert!(hits.iter().any(|e| e.modes.contains(&BindingMode::Normal)));
584/// assert!(lookup("not-a-chord").is_empty());
585/// ```
586pub fn lookup(chord: &str) -> Vec<&'static KeymapEntry> {
587    default_keymap()
588        .iter()
589        .filter(|e| e.chord == chord)
590        .collect()
591}
592
593/// Every entry of the built-in catalog in mode-grouped declaration order —
594/// identical to [`default_keymap`]. Used by `:keymap`.
595pub fn entries() -> &'static [KeymapEntry] {
596    default_keymap()
597}
598
599#[cfg(test)]
600mod tests {
601    #![allow(clippy::unwrap_used, clippy::panic)]
602    use super::*;
603    use std::collections::HashSet;
604
605    #[test]
606    fn lookup_returns_every_mode_a_chord_appears_in() {
607        // `j` is bound in Normal (line down) and Visual (extend
608        // down). Help inherits Normal's `j` via active-buffer
609        // routing, so it doesn't appear as a separate descriptor.
610        let hits = lookup("j");
611        assert_eq!(hits.len(), 2);
612        let modes: HashSet<_> = hits.iter().flat_map(|e| e.modes.iter().copied()).collect();
613        assert!(modes.contains(&BindingMode::Normal));
614        assert!(modes.contains(&BindingMode::Visual));
615    }
616
617    #[test]
618    fn lookup_unknown_chord_is_empty() {
619        assert!(lookup("nope-not-a-chord").is_empty());
620    }
621
622    #[test]
623    fn macro_single_mode_sugars_to_one_element_slice() {
624        // B-field backwards compat: `mode: X` still works and produces a
625        // one-element `modes` slice (no call-site churn).
626        let e = keymap_entry! { mode: Normal, chord: "zz", doc: "center", cmd: None };
627        assert_eq!(e.modes, [BindingMode::Normal].as_slice());
628        assert_eq!(e.modes_label(), "Normal");
629    }
630
631    #[test]
632    fn macro_multi_mode_builds_a_slice_in_order() {
633        // `mode: [..]` is the new multi-mode form — one entry, several
634        // modes, fanned out into one binding per mode by the host.
635        let e = keymap_entry! {
636            mode: [Normal, Visual],
637            chord: "zn",
638            doc: "narrow",
639            cmd: "operator:narrow"
640        };
641        assert_eq!(
642            e.modes,
643            [BindingMode::Normal, BindingMode::Visual].as_slice()
644        );
645        assert_eq!(e.command, Some("operator:narrow"));
646        assert_eq!(e.modes_label(), "Normal, Visual");
647    }
648
649    #[test]
650    fn macro_multi_mode_carries_fall_through() {
651        // The multi-mode form composes with every existing cmd / sugar /
652        // fall_through arm.
653        let e = keymap_entry! {
654            mode: [Insert, Select],
655            chord: "<Esc>",
656            doc: "leave",
657            cmd: "action:snippet-leave",
658            fall_through: true
659        };
660        assert_eq!(
661            e.modes,
662            [BindingMode::Insert, BindingMode::Select].as_slice()
663        );
664        assert!(e.fall_through);
665    }
666
667    #[test]
668    fn motion_chords_link_to_registered_command_names() {
669        // Every entry whose `command` is Some must point at a name some
670        // registry actually registers. Drift-test against the builtin
671        // populator. Includes ex-commands because Command-mode keymap rows
672        // reference `ex:describe-command` for `<C-h>`.
673        //
674        // **`action:` names are checked in `lattice-host`, not here.** Host
675        // actions (`action:pane-history-back`, …) are registered by
676        // `lattice_host::actions::populate`, and this crate sits BELOW
677        // `lattice-host` — it cannot see that registry without inverting the
678        // dependency. So the complete check lives at
679        // `lattice_host::keymap_normal::tests::every_default_keymap_command_is_registered`,
680        // which populates the grammar builtins AND the host actions and
681        // covers every entry this one skips.
682        //
683        // This split is not cosmetic: PBH added the first `action:` entries
684        // to the default keymap, and the unsplit version of this test failed
685        // for months on `<C-6>` without the binding being broken.
686        let mut registry = lattice_grammar::CommandRegistry::new();
687        let _ = lattice_grammar::builtins::populate(&mut registry);
688        let _ = lattice_grammar::ex_commands::populate(&mut registry);
689        for entry in default_keymap() {
690            let Some(name) = entry.command else { continue };
691            if name.starts_with("action:") {
692                continue;
693            }
694            assert!(
695                registry.id_by_name(name).is_some(),
696                "binding `{}` ({}) claims `{}` but registry has no such command",
697                entry.chord,
698                entry.modes_label(),
699                name
700            );
701        }
702    }
703
704    #[test]
705    fn no_duplicate_chord_mode_pairs() {
706        // Two entries with the same (chord, mode) would both match
707        // the same lookup -- a bug.
708        let mut seen: HashSet<(&str, BindingMode)> = HashSet::new();
709        for entry in default_keymap() {
710            for mode in entry.modes {
711                assert!(
712                    seen.insert((entry.chord, *mode)),
713                    "duplicate keymap entry: chord={} mode={:?}",
714                    entry.chord,
715                    mode
716                );
717            }
718        }
719    }
720
721    #[test]
722    fn every_entry_has_a_builtin_source_with_a_line() {
723        // Per DESIGN.md §5.11.1 -- every binding MUST carry provenance
724        // so :describe-key can render a source link. The keymap_entry!
725        // macro is the only construction path; verify it captured
726        // the row's location.
727        for entry in default_keymap() {
728            assert_eq!(
729                entry.source().layer,
730                lattice_grammar::SourceLayer::Builtin,
731                "entry `{}` ({}) source layer should be Builtin",
732                entry.chord,
733                entry.modes_label()
734            );
735            match &entry.source().kind {
736                lattice_grammar::SourceKind::File { path, line } => {
737                    assert!(
738                        path.to_string_lossy().contains("keymap_entry.rs"),
739                        "expected source path to contain `keymap_entry.rs`, got `{}` (entry `{}` {})",
740                        path.display(),
741                        entry.chord,
742                        entry.modes_label(),
743                    );
744                    assert!(
745                        line.is_some(),
746                        "entry `{}` ({}) has no captured line",
747                        entry.chord,
748                        entry.modes_label()
749                    );
750                }
751                other => panic!("expected File source kind, got {other:?}"),
752            }
753        }
754    }
755
756    #[test]
757    fn adjacent_entries_capture_distinct_lines() {
758        // The macro injects file!() + line!() per row; rows on
759        // different lines must record distinct line numbers,
760        // otherwise the per-row capture has regressed.
761        let entries = default_keymap();
762        for window in entries.windows(2) {
763            let (a, b) = (&window[0], &window[1]);
764            let line_a = match &a.source.kind {
765                lattice_grammar::SourceKind::File { line, .. } => *line,
766                _ => None,
767            };
768            let line_b = match &b.source.kind {
769                lattice_grammar::SourceKind::File { line, .. } => *line,
770                _ => None,
771            };
772            // We don't require strict ordering (the table has comment
773            // gaps) but adjacent entries on different source lines
774            // must record different captured lines.
775            if let (Some(a_line), Some(b_line)) = (line_a, line_b) {
776                assert!(
777                    a_line != b_line,
778                    "adjacent entries `{}` ({}) and `{}` ({}) both captured line {a_line}",
779                    a.chord,
780                    a.modes_label(),
781                    b.chord,
782                    b.modes_label(),
783                );
784            }
785        }
786    }
787
788    #[test]
789    fn introspectable_impl_emits_bound_at_source() {
790        use lattice_grammar::{Introspectable, SourceLabel};
791        let entries = default_keymap();
792        let entry = entries.first().expect("at least one entry");
793        let sources = entry.sources();
794        assert_eq!(sources.len(), 1);
795        assert_eq!(sources[0].label, SourceLabel::BoundAt);
796    }
797
798    #[test]
799    fn introspectable_render_includes_source_link_and_invokes_section() {
800        // End-to-end: render an entry with a command through the
801        // generic renderer; verify the output has both the
802        // `(file:...)` source link and the `(command:...)`
803        // cross-reference (markdown link form).
804        let entries = default_keymap();
805        let entry = entries
806            .iter()
807            .find(|e| e.command.is_some())
808            .expect("at least one entry has a command");
809        let lines = lattice_grammar::render_introspection_lines(entry);
810        let body = lines.join("\n");
811        assert!(
812            body.contains("Bound at:"),
813            "body missing source label: {body}"
814        );
815        assert!(
816            body.contains("(file:") && body.contains("keymap_entry.rs"),
817            "body missing source link: {body}"
818        );
819        assert!(
820            body.contains("(command:"),
821            "body missing command cross-reference: {body}"
822        );
823        assert!(
824            body.contains("(built-in)"),
825            "body missing source layer label: {body}"
826        );
827    }
828
829    #[test]
830    fn binding_mode_label_is_non_empty() {
831        for mode in [
832            BindingMode::Normal,
833            BindingMode::Insert,
834            BindingMode::Visual,
835            BindingMode::Replace,
836            BindingMode::Command,
837            BindingMode::Search,
838            BindingMode::OperatorPending,
839            BindingMode::AfterG,
840            BindingMode::AfterZ,
841            BindingMode::AfterMark,
842            BindingMode::AfterJumpMarkLine,
843            BindingMode::AfterJumpMarkExact,
844            BindingMode::AfterRegister,
845            BindingMode::AfterMacroStart,
846            BindingMode::AfterMacroPlay,
847            BindingMode::AfterFindChar,
848            BindingMode::AfterTextObject,
849            BindingMode::Help,
850        ] {
851            assert!(!mode.label().is_empty());
852        }
853    }
854}