Skip to main content

keymap_config/
lib.rs

1//! # keymap-config
2//!
3//! Builds a [`Keymap`] from a TOML `[keys]` table, resolving action names with a
4//! caller-supplied function and reporting problems that should not silently
5//! pass.
6//!
7//! ```
8//! #[derive(Clone, Debug, PartialEq)]
9//! enum Action { Quit, Save }
10//!
11//! let toml = r#"
12//! [keys]
13//! "ctrl+q" = "quit"
14//! "ctrl+s" = "save"
15//! "#;
16//!
17//! let out = keymap_config::from_str(toml, |name| match name {
18//!     "quit" => Some(Action::Quit),
19//!     "save" => Some(Action::Save),
20//!     _ => None,
21//! })
22//! .unwrap();
23//!
24//! assert!(out.warnings.is_empty());
25//! ```
26//!
27//! ## Named layers
28//!
29//! Bindings live in *layers*. The bare top-level `[keys]` table is the
30//! [`GLOBAL_LAYER`] layer; additional `[layers.<name>]` tables each build a layer
31//! of that name, holding chord→action entries directly:
32//!
33//! ```toml
34//! [keys]               # the implicit "global" layer
35//! "ctrl+q" = "quit"
36//!
37//! [layers.panel]       # a caller-named layer
38//! "ctrl+s" = "split"
39//! ```
40//!
41//! [`from_str`] returns these as [`BuildOutput::layers`], a name→[`Keymap`] map
42//! always containing `"global"`. Which layers are active, and in what order, is
43//! **not** this crate's concern: the caller picks them per event and resolves
44//! with [`keymap_core::resolve_layered`] (first layer to bind a chord wins, misses
45//! fall through). The same chord in two layers is therefore an *override*, not a
46//! conflict — this crate only ever reports conflicts *within* a single layer.
47//! Layer names are opaque labels; the library attaches meaning to none of them.
48//!
49//! ## What is an error versus a warning
50//!
51//! Structural problems — malformed TOML, or a key string that does not parse —
52//! are [`BuildError`]: there is no usable map to return. Survivable problems —
53//! two keys resolving to the same chord, or an action name the resolver does not
54//! recognize — are collected into [`BuildOutput::warnings`] so the rest of the
55//! bindings still work and the user can be told what to fix.
56//!
57//! ## Legacy-terminal survivability (opt-in, separate from warnings)
58//!
59//! "This binding won't survive a legacy terminal" (e.g. a `cmd+…` chord a legacy
60//! terminal cannot deliver, or `ctrl+i` which is indistinguishable from `tab`) is
61//! deliberately *not* a [`Warning`]: it depends on the deployment terminal, not
62//! the config's correctness, and folding it into [`BuildOutput::warnings`] would
63//! make a perfectly good config report warnings. It is exposed instead as the
64//! opt-in [`keymap_core::legacy_lints`], which you call on a built keymap when
65//! you want it: `keymap_core::legacy_lints(out.global())` (or on any one layer).
66//! Callers gating on `warnings.is_empty()` are unaffected.
67//!
68//! ## Multi-key sequences
69//!
70//! Alongside the single-chord `[keys]` table, an `[[sequences]]` array of tables
71//! binds *sequences* of chords (leader trees, `ctrl+x ctrl+s`) into a
72//! [`SequenceKeymap`](keymap_seq::SequenceKeymap), returned as
73//! [`BuildOutput::sequences`]:
74//!
75//! ```toml
76//! [keys]
77//! "ctrl+q" = "quit"
78//!
79//! [[sequences]]
80//! keys = ["ctrl+x", "ctrl+s"]
81//! action = "save"
82//! ```
83//!
84//! Each element of `keys` reuses the single-chord grammar, so no new key syntax
85//! is introduced. Two sequence bindings that share a prefix cannot coexist
86//! without a timeout (see `keymap-seq`); such a pair is reported as
87//! [`Warning::PrefixShadow`] and the later one is dropped. A single-key binding
88//! that shadows a sequence (e.g. `"j"` in `[keys]` versus a sequence starting
89//! with `j`) is reported as the advisory [`Warning::SequenceShadow`] — both
90//! bindings are kept (they live in separate maps), so it only flags that the
91//! caller composing the two maps must resolve the overlap with a timeout.
92//!
93//! ## Runnable examples
94//!
95//! Under [`examples/`](https://github.com/S-Nakamur-a/keymap-rs/tree/main/crates/keymap-config/examples):
96//! `cargo run -p keymap-config --example load_config` walks the TOML-to-keymap
97//! pipeline end to end, and `--example rebind` shows the runtime-rebind flow
98//! on top of `keymap_term::decode`.
99
100use std::collections::{BTreeMap, HashMap};
101
102use keymap_core::{KeyInput, Keymap, ParseKeyInputError};
103use keymap_seq::{SeqBindError, SequenceKeymap};
104use serde::Deserialize;
105
106/// The name of the layer built from the top-level `[keys]` table.
107///
108/// It is the one name this crate assigns: the bare `[keys]` table (and an
109/// explicit `[layers.global]`, if present) build the layer under this key, which
110/// is **always present** in [`BuildOutput::layers`] even when empty. Every other
111/// layer name is opaque to the library — it is just the label the config author
112/// chose; the library attaches no meaning to it and never decides when a layer is
113/// active. That selection stays caller-side (see [`keymap_core::resolve_layered`]).
114pub const GLOBAL_LAYER: &str = "global";
115
116/// The result of a successful build: the named layers plus any non-fatal warnings.
117#[derive(Debug, Clone)]
118#[non_exhaustive]
119pub struct BuildOutput<A> {
120    /// The assembled layers, keyed by name. The top-level `[keys]` table builds
121    /// the [`GLOBAL_LAYER`] layer (always present, even when empty); each
122    /// `[layers.<name>]` table builds the layer of that name. Within any one
123    /// layer, conflicting chords resolve to the last binding. The same chord
124    /// bound in two *different* layers is not a conflict — it is the override the
125    /// caller composes with [`keymap_core::resolve_layered`], so this crate never
126    /// reports across layer boundaries.
127    ///
128    /// Always contains the `"global"` key, so `layers.len()` counts the global
129    /// layer even when the config bound nothing in it.
130    pub layers: BTreeMap<String, Keymap<A>>,
131    /// The assembled sequence map, built from the top-level `[[sequences]]`
132    /// tables. Sequences are **not** layered (they belong to the global config);
133    /// empty when the config has no sequences.
134    pub sequences: SequenceKeymap<A>,
135    /// Problems that did not prevent building (conflicts, unknown actions,
136    /// prefix shadows), in the order they were first seen.
137    pub warnings: Vec<Warning>,
138}
139
140impl<A> BuildOutput<A> {
141    /// The [`GLOBAL_LAYER`] layer (built from the top-level `[keys]` table).
142    ///
143    /// This is the convenience accessor for the common case of a single,
144    /// unlayered keymap: `out.global()` is exactly `&out.layers[GLOBAL_LAYER]`.
145    /// A config with no `[keys]` table yields an empty global layer, not an
146    /// absent one.
147    ///
148    /// # Panics
149    ///
150    /// Never in practice: [`from_str`] always inserts the global layer (empty if
151    /// the config had no `[keys]` table), so the lookup cannot miss.
152    #[must_use]
153    pub fn global(&self) -> &Keymap<A> {
154        self.layers
155            .get(GLOBAL_LAYER)
156            .expect("the global layer is always inserted")
157    }
158}
159
160/// A non-fatal problem found while building a [`Keymap`].
161#[derive(Debug, Clone, PartialEq, Eq)]
162#[non_exhaustive]
163pub enum Warning {
164    /// Two or more keys resolved to the same chord *within one layer*; the last
165    /// one wins. A named layer's conflict uses this same variant and carries no
166    /// layer name, so two layers each conflicting on the same chord produce
167    /// warnings indistinguishable on their own (the chord identifies the clash,
168    /// not the layer). The same chord across *different* layers is an override,
169    /// not a conflict, and is never reported.
170    Conflict {
171        /// The shared chord, in canonical form (e.g. `"ctrl+a"`).
172        chord: String,
173        /// The competing action names, in file order.
174        contenders: Vec<String>,
175        /// The action name that won (the last binding for the chord).
176        winner: String,
177    },
178    /// An action name in the config was not recognized by the resolver; the
179    /// binding was skipped.
180    UnknownAction {
181        /// The key string as written in the config. For a sequence, the
182        /// canonical chords joined by spaces.
183        key: String,
184        /// The unrecognized action name.
185        action: String,
186    },
187    /// Two sequence bindings share a prefix: the shorter (`prefix`) is a proper
188    /// prefix of the longer (`shadowed`), so they cannot coexist without a
189    /// timeout to tell them apart. The later-in-file binding was dropped to keep
190    /// the sequence table prefix-free.
191    PrefixShadow {
192        /// The shorter sequence, one canonical chord per element.
193        prefix: Vec<String>,
194        /// The action bound to the shorter sequence.
195        prefix_action: String,
196        /// The longer sequence that shares the prefix, one chord per element.
197        shadowed: Vec<String>,
198        /// The action bound to the longer sequence.
199        shadowed_action: String,
200    },
201    /// A `[[sequences]]` entry had an empty `keys` array; it was skipped.
202    EmptySequence {
203        /// The action name the empty sequence would have been bound to.
204        action: String,
205    },
206    /// A single-chord `[keys]` binding collides with the first key of a
207    /// `[[sequences]]` entry (e.g. `"j"` in `[keys]` and a sequence `["j", "k"]`).
208    /// Pressing that chord is then ambiguous — fire the single action now, or wait
209    /// to see if the sequence continues? — and can only be disambiguated by a
210    /// caller-side timeout (the vim `jj` case; see `keymap-seq`).
211    ///
212    /// Unlike [`PrefixShadow`](Warning::PrefixShadow), **nothing is dropped**: the
213    /// chord stays in its layer (the global one — this is checked only against the
214    /// global layer) and the sequence stays in [`BuildOutput::sequences`] (they are
215    /// separate maps). This is purely an
216    /// advisory that the caller composing the two maps must resolve the overlap.
217    SequenceShadow {
218        /// The single chord, in canonical form (e.g. `"j"`).
219        chord: String,
220        /// The action bound to the single chord.
221        chord_action: String,
222        /// One sequence the chord shadows — the lexicographically-first when it
223        /// shadows several — one canonical chord per element (its first equals
224        /// `chord`).
225        sequence: Vec<String>,
226        /// The action bound to that sequence.
227        sequence_action: String,
228    },
229}
230
231/// A fatal problem that prevents building a [`Keymap`].
232#[derive(Debug)]
233#[non_exhaustive]
234pub enum BuildError {
235    /// The input was not valid TOML.
236    Toml(toml::de::Error),
237    /// A key string in the `[keys]` table could not be parsed.
238    KeyParse {
239        /// The offending key string as written.
240        key: String,
241        /// The underlying parse error.
242        source: ParseKeyInputError,
243    },
244}
245
246impl core::fmt::Display for BuildError {
247    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
248        match self {
249            BuildError::Toml(_) => f.write_str("invalid TOML"),
250            BuildError::KeyParse { key, .. } => write!(f, "invalid key string {key:?}"),
251        }
252    }
253}
254
255impl std::error::Error for BuildError {
256    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
257        match self {
258            BuildError::Toml(e) => Some(e),
259            BuildError::KeyParse { source, .. } => Some(source),
260        }
261    }
262}
263
264impl From<toml::de::Error> for BuildError {
265    fn from(e: toml::de::Error) -> Self {
266        BuildError::Toml(e)
267    }
268}
269
270// `deny_unknown_fields` makes a misspelled or unsupported key a `BuildError::Toml`
271// rather than a silent no-op — chosen pre-1.0 because adding it *later* would
272// reject configs that used to parse. It also keeps the door open for additive
273// fields (e.g. a future per-`[[sequences]]` `layer = "..."` tag) without their
274// typos failing silently.
275#[derive(Deserialize)]
276#[serde(deny_unknown_fields)]
277struct RawConfig {
278    #[serde(default)]
279    keys: BTreeMap<String, String>,
280    #[serde(default)]
281    layers: BTreeMap<String, BTreeMap<String, String>>,
282    #[serde(default)]
283    sequences: Vec<RawSequence>,
284}
285
286#[derive(Deserialize)]
287#[serde(deny_unknown_fields)]
288struct RawSequence {
289    keys: Vec<String>,
290    action: String,
291}
292
293/// The built sequence map paired with its sequence→action-name dictionary (the
294/// names let a cross-shadow be reported against the single-key table).
295type SequenceBuild<A> = (SequenceKeymap<A>, HashMap<Vec<KeyInput>, String>);
296
297/// Builds a [`Keymap`] from a TOML string.
298///
299/// The `[keys]` table maps key strings (see `keymap_core::KeyInput`'s `FromStr`)
300/// to action names; `resolve` turns each action name into the caller's action
301/// type `A`, returning `None` for names it does not recognize.
302///
303/// # Errors
304///
305/// Returns [`BuildError`] if the input is not valid TOML, or if any key string
306/// fails to parse. Unknown action names and chord conflicts are not errors —
307/// they are returned in [`BuildOutput::warnings`].
308pub fn from_str<A, F>(toml_str: &str, mut resolve: F) -> Result<BuildOutput<A>, BuildError>
309where
310    F: FnMut(&str) -> Option<A>,
311{
312    let RawConfig {
313        keys,
314        mut layers,
315        sequences: raw_sequences,
316    } = toml::from_str(toml_str)?;
317
318    let mut warnings = Vec::new();
319    let mut built: BTreeMap<String, Keymap<A>> = BTreeMap::new();
320
321    // The global layer is the top-level `[keys]` table plus an explicit
322    // `[layers.global]` if the author also wrote one. The two are concatenated —
323    // `[keys]` first, then `[layers.global]` — and fed through the same per-chord
324    // pipeline, so a chord present in both is reported as an ordinary in-layer
325    // `Conflict` (and, being last, the `[layers.global]` entry wins) rather than
326    // silently dropped. The global layer is always inserted, even when empty.
327    let explicit_global = layers.remove(GLOBAL_LAYER).unwrap_or_default();
328    let global_entries = keys.into_iter().chain(explicit_global);
329    let (global_keymap, global_names) = build_layer(global_entries, &mut resolve, &mut warnings)?;
330    built.insert(GLOBAL_LAYER.to_string(), global_keymap);
331
332    // Every other named layer, in `BTreeMap` name order so warnings are
333    // deterministic. Each layer's conflicts are detected within itself; layer
334    // boundaries are never crossed (the caller owns the active chain).
335    for (name, raw_keys) in layers {
336        // Only the global layer feeds the cross-shadow check below, so a
337        // non-global layer's name dictionary is computed but not needed here.
338        let (keymap, _names) = build_layer(raw_keys, &mut resolve, &mut warnings)?;
339        built.insert(name, keymap);
340    }
341
342    // Sequences are global-only, so the cross-shadow check (single chord versus
343    // the first key of a sequence) runs against the global layer alone.
344    let (sequences, seq_names) = build_sequences(raw_sequences, &mut resolve, &mut warnings)?;
345    detect_cross_shadows(&global_names, &seq_names, &mut warnings);
346
347    Ok(BuildOutput {
348        layers: built,
349        sequences,
350        warnings,
351    })
352}
353
354/// Builds one layer's [`Keymap`] from its `(raw key string, action name)`
355/// entries, appending survivable problems to `warnings`.
356///
357/// Entries are grouped by the chord they normalize to (so different spellings of
358/// the same chord — `ctrl+a` and `control+a` — collide), visited in first-seen
359/// order. Within a group the last resolvable binding wins and a [`Warning::Conflict`]
360/// is reported; an unresolvable action name is a [`Warning::UnknownAction`] and
361/// that single entry is skipped.
362///
363/// Returns the built map together with the winning action *name* per chord, kept
364/// so a cross-shadow warning can name the single-key side (the keymap stores `A`,
365/// not names). Callers that don't need the names (any layer but global) discard them.
366fn build_layer<A, I, F>(
367    entries: I,
368    resolve: &mut F,
369    warnings: &mut Vec<Warning>,
370) -> Result<(Keymap<A>, HashMap<KeyInput, String>), BuildError>
371where
372    I: IntoIterator<Item = (String, String)>,
373    F: FnMut(&str) -> Option<A>,
374{
375    // Parse every key first (parse failures are fatal), grouping entries by the
376    // chord they normalize to, in first-seen order.
377    let mut order: Vec<KeyInput> = Vec::new();
378    let mut groups: HashMap<KeyInput, Vec<(String, String)>> = HashMap::new();
379    for (raw_key, action_name) in entries {
380        let chord = raw_key
381            .parse::<KeyInput>()
382            .map_err(|source| BuildError::KeyParse {
383                key: raw_key.clone(),
384                source,
385            })?;
386        let entry = groups.entry(chord).or_default();
387        if entry.is_empty() {
388            order.push(chord);
389        }
390        entry.push((raw_key, action_name));
391    }
392
393    let mut keymap = Keymap::new();
394    let mut names: HashMap<KeyInput, String> = HashMap::new();
395    for chord in order {
396        let Some(entries) = groups.remove(&chord) else {
397            continue;
398        };
399
400        let mut resolved: Vec<(String, A)> = Vec::new();
401        for (raw_key, action_name) in entries {
402            match resolve(&action_name) {
403                Some(action) => resolved.push((action_name, action)),
404                None => warnings.push(Warning::UnknownAction {
405                    key: raw_key,
406                    action: action_name,
407                }),
408            }
409        }
410
411        if resolved.len() > 1 {
412            let contenders: Vec<String> = resolved.iter().map(|(name, _)| name.clone()).collect();
413            if let Some(winner) = contenders.last().cloned() {
414                warnings.push(Warning::Conflict {
415                    chord: chord.to_string(),
416                    contenders,
417                    winner,
418                });
419            }
420        }
421
422        // Last binding wins.
423        if let Some((name, action)) = resolved.pop() {
424            keymap.bind(chord, action);
425            names.insert(chord, name);
426        }
427    }
428
429    Ok((keymap, names))
430}
431
432/// Serializes a [`Keymap`] and [`SequenceKeymap`] back into a TOML config string
433/// — the inverse of [`from_str`].
434///
435/// `name_of` is the mirror of [`from_str`]'s `resolve`: it turns each bound
436/// action `A` back into the name written in the config, returning `None` for an
437/// action that has no config name. A binding whose action returns `None` is
438/// **omitted** from the output (the write-side dual of how [`from_str`] reports
439/// an unresolvable name as [`Warning::UnknownAction`]); pass a total `name_of`
440/// for a faithful export.
441///
442/// The result is **deterministic**: the `[keys]` table is emitted in canonical
443/// chord order and the `[[sequences]]` array in joined-chord order, so the same
444/// maps always produce byte-identical output (suitable for writing to disk and
445/// diffing). I/O is the caller's — like [`from_str`], this crate only deals in
446/// `&str`/`String`.
447///
448/// The guarantee is a **semantic round-trip**, not byte identity:
449/// `from_str(&to_toml(km, seq, name_of), resolve)` rebuilds keymaps equivalent to
450/// `km`/`seq` when `name_of` and `resolve` are inverses. The reverse direction is
451/// lossy by design — normalization (`shift+a` → `a`) is non-invertible, and
452/// comments, ordering, and original spelling are not preserved.
453///
454/// Action names and chords are emitted by constructing a [`toml::Value`] (never
455/// by string concatenation), so the `toml` serializer handles all escaping; a
456/// name containing quotes or TOML metacharacters cannot break out of its string
457/// to inject extra bindings on the round-trip.
458///
459/// # Trust boundary
460///
461/// `to_toml` applies **no** policy: it emits whatever is bound, reserved/escape
462/// keys included. A caller that writes this output somewhere another process
463/// (notably a PTY-internal process) can also write **must** enforce reserved-key
464/// rejection at its own load/bind boundary — otherwise an attacker who can write
465/// that file can rebind the escape key. See `docs/STATUS.md`.
466///
467/// # Panics
468///
469/// Never in practice: it serializes a TOML value built only from string keys and
470/// values, which the `toml` serializer cannot fail on.
471pub fn to_toml<A, F>(keymap: &Keymap<A>, sequences: &SequenceKeymap<A>, mut name_of: F) -> String
472where
473    F: FnMut(&A) -> Option<&str>,
474{
475    let mut root = toml::Table::new();
476
477    let keys = keymap_to_table(keymap, &mut name_of);
478    if !keys.is_empty() {
479        root.insert("keys".to_string(), toml::Value::Table(keys));
480    }
481    insert_sequences(&mut root, sequences, &mut name_of);
482
483    // Serializing a table of string-only values is infallible.
484    toml::to_string(&root).expect("string-only TOML value always serializes")
485}
486
487/// Serializes a named-layer set and the global [`SequenceKeymap`] back into a
488/// TOML config string — the inverse of [`from_str`] for a multi-layer config.
489///
490/// The [`GLOBAL_LAYER`] layer is emitted as the top-level `[keys]` table and every
491/// other layer as `[layers.<name>]`, with `[[sequences]]` carrying the (global)
492/// sequence map. Like [`to_toml`] the output is **deterministic** (`BTreeMap`/sorted
493/// layer, chord, and sequence order) and applies **no** policy — the same trust-boundary
494/// caveat applies (see [`to_toml`]). Pass [`BuildOutput::layers`] straight in to
495/// round-trip a parsed config; an empty layer emits nothing, and a global-only set
496/// produces byte-identical output to [`to_toml`] on that one layer.
497///
498/// # Panics
499///
500/// Never in practice, for the same reason as [`to_toml`].
501pub fn to_toml_layered<A, F>(
502    layers: &BTreeMap<String, Keymap<A>>,
503    sequences: &SequenceKeymap<A>,
504    mut name_of: F,
505) -> String
506where
507    F: FnMut(&A) -> Option<&str>,
508{
509    let mut root = toml::Table::new();
510    let mut named = toml::Table::new();
511
512    for (name, keymap) in layers {
513        let table = keymap_to_table(keymap, &mut name_of);
514        if table.is_empty() {
515            continue;
516        }
517        if name == GLOBAL_LAYER {
518            root.insert("keys".to_string(), toml::Value::Table(table));
519        } else {
520            named.insert(name.clone(), toml::Value::Table(table));
521        }
522    }
523
524    insert_sequences(&mut root, sequences, &mut name_of);
525    if !named.is_empty() {
526        root.insert("layers".to_string(), toml::Value::Table(named));
527    }
528
529    // Serializing a table of string-only values is infallible.
530    toml::to_string(&root).expect("string-only TOML value always serializes")
531}
532
533/// Renders one keymap as a `chord -> action name` [`toml::Table`]. A `toml` table
534/// is a sorted map, so chord order is canonical with no extra sorting; bindings
535/// whose action has no config name (`name_of` returns `None`) are omitted.
536fn keymap_to_table<A, F>(keymap: &Keymap<A>, name_of: &mut F) -> toml::Table
537where
538    F: FnMut(&A) -> Option<&str>,
539{
540    let mut table = toml::Table::new();
541    for (chord, action) in keymap.iter() {
542        if let Some(name) = name_of(action) {
543            table.insert(chord.to_string(), toml::Value::String(name.to_string()));
544        }
545    }
546    table
547}
548
549/// Inserts a `[[sequences]]` array into `root` when the map has any named
550/// sequences, sorted by the joined canonical chords (the same ordering key
551/// cross-shadow detection uses) for deterministic output.
552fn insert_sequences<A, F>(root: &mut toml::Table, sequences: &SequenceKeymap<A>, name_of: &mut F)
553where
554    F: FnMut(&A) -> Option<&str>,
555{
556    let mut seqs: Vec<(Vec<String>, String)> = sequences
557        .bindings()
558        .filter_map(|(path, action)| {
559            name_of(action).map(|name| (render_sequence(&path), name.to_string()))
560        })
561        .collect();
562    seqs.sort_by_key(|(chords, _)| chords.join(" "));
563
564    if seqs.is_empty() {
565        return;
566    }
567    let array = seqs
568        .into_iter()
569        .map(|(chords, name)| {
570            let mut entry = toml::Table::new();
571            entry.insert(
572                "keys".to_string(),
573                toml::Value::Array(chords.into_iter().map(toml::Value::String).collect()),
574            );
575            entry.insert("action".to_string(), toml::Value::String(name));
576            toml::Value::Table(entry)
577        })
578        .collect();
579    root.insert("sequences".to_string(), toml::Value::Array(array));
580}
581
582/// Flags single-chord bindings that collide with the first key of a sequence.
583///
584/// The two maps are built independently and composed by the caller, so a chord
585/// `j` and a sequence starting with `j` are not a build-time conflict in either
586/// table alone — but together they make the press of `j` ambiguous. One advisory
587/// [`Warning::SequenceShadow`] is emitted per offending chord (naming the
588/// lexicographically-first sequence it shadows), chords visited in canonical-string
589/// order for a deterministic warning sequence.
590fn detect_cross_shadows(
591    single_names: &HashMap<KeyInput, String>,
592    seq_names: &HashMap<Vec<KeyInput>, String>,
593    warnings: &mut Vec<Warning>,
594) {
595    let mut singles: Vec<(&KeyInput, &String)> = single_names.iter().collect();
596    singles.sort_by_key(|(chord, _)| chord.to_string());
597
598    for (chord, chord_action) in singles {
599        let mut shadowed: Vec<(&Vec<KeyInput>, &String)> = seq_names
600            .iter()
601            .filter(|(seq, _)| seq.first() == Some(chord))
602            .collect();
603        shadowed.sort_by_key(|(seq, _)| render_sequence(seq).join(" "));
604
605        if let Some((sequence, sequence_action)) = shadowed.first() {
606            warnings.push(Warning::SequenceShadow {
607                chord: chord.to_string(),
608                chord_action: chord_action.clone(),
609                sequence: render_sequence(sequence),
610                sequence_action: (*sequence_action).clone(),
611            });
612        }
613    }
614}
615
616/// Builds the [`SequenceKeymap`] from the `[[sequences]]` tables, appending any
617/// survivable problems to `warnings`.
618///
619/// Detection of prefix conflicts lives entirely in `keymap-seq`: this function
620/// just calls [`SequenceKeymap::bind`] in file order and translates its result
621/// into warnings, keeping only a sequence→action-name dictionary (names are this
622/// crate's vocabulary, not `keymap-seq`'s) so it can name both sides of a clash.
623///
624/// Two write policies show through here, intentionally asymmetric:
625/// - An exact re-binding of the same sequence is **last-wins** (overwrites) and
626///   reported as a [`Warning::Conflict`], matching the single-chord `[keys]`
627///   table.
628/// - A *prefix* clash keeps the **earlier** binding and drops the later one
629///   (reported as [`Warning::PrefixShadow`]); the established prefix path wins.
630///
631/// Returns the built map together with the sequence→action-name dictionary it
632/// accrues, so the caller can detect cross-shadows against the single-key table
633/// without re-deriving names.
634fn build_sequences<A, F>(
635    raw_sequences: Vec<RawSequence>,
636    resolve: &mut F,
637    warnings: &mut Vec<Warning>,
638) -> Result<SequenceBuild<A>, BuildError>
639where
640    F: FnMut(&str) -> Option<A>,
641{
642    let mut sequences = SequenceKeymap::new();
643    // Action *names* keyed by the sequence they bind, so a clash can name both
644    // sides. This is naming data, not conflict detection — the trie owns that.
645    let mut names: HashMap<Vec<KeyInput>, String> = HashMap::new();
646
647    for raw_seq in raw_sequences {
648        let mut keys = Vec::with_capacity(raw_seq.keys.len());
649        for raw_key in &raw_seq.keys {
650            let chord = raw_key
651                .parse::<KeyInput>()
652                .map_err(|source| BuildError::KeyParse {
653                    key: raw_key.clone(),
654                    source,
655                })?;
656            keys.push(chord);
657        }
658
659        let Some(action) = resolve(&raw_seq.action) else {
660            warnings.push(Warning::UnknownAction {
661                key: render_sequence(&keys).join(" "),
662                action: raw_seq.action,
663            });
664            continue;
665        };
666
667        match sequences.bind(keys.iter().copied(), action) {
668            Ok(None) => {
669                names.insert(keys, raw_seq.action);
670            }
671            Ok(Some(_)) => {
672                // Exact re-binding: last wins, reported like a repeated chord.
673                let previous = names.insert(keys.clone(), raw_seq.action.clone());
674                warnings.push(Warning::Conflict {
675                    chord: render_sequence(&keys).join(" "),
676                    contenders: vec![previous.unwrap_or_default(), raw_seq.action.clone()],
677                    winner: raw_seq.action,
678                });
679            }
680            Err(SeqBindError::Empty) => {
681                warnings.push(Warning::EmptySequence {
682                    action: raw_seq.action,
683                });
684            }
685            Err(SeqBindError::PrefixShadow { sequence, conflict }) => {
686                let conflict_action = names.get(&conflict).cloned().unwrap_or_default();
687                // The shorter sequence is the prefix that wins; the longer is shadowed.
688                let (prefix, prefix_action, shadowed, shadowed_action) =
689                    if sequence.len() <= conflict.len() {
690                        (sequence, raw_seq.action, conflict, conflict_action)
691                    } else {
692                        (conflict, conflict_action, sequence, raw_seq.action)
693                    };
694                warnings.push(Warning::PrefixShadow {
695                    prefix: render_sequence(&prefix),
696                    prefix_action,
697                    shadowed: render_sequence(&shadowed),
698                    shadowed_action,
699                });
700            }
701            // `SeqBindError` is non-exhaustive: a future rejection reason we do
702            // not yet model drops the binding rather than aborting the build.
703            Err(_) => {}
704        }
705    }
706
707    Ok((sequences, names))
708}
709
710/// Renders a sequence as one canonical chord string per key.
711fn render_sequence(keys: &[KeyInput]) -> Vec<String> {
712    keys.iter().map(ToString::to_string).collect()
713}
714
715#[cfg(test)]
716mod tests {
717    use super::*;
718    use keymap_core::{Key, Modifiers};
719
720    #[derive(Debug, Clone, PartialEq)]
721    enum Action {
722        Quit,
723        Save,
724        Split,
725        Top,
726    }
727
728    fn resolver(name: &str) -> Option<Action> {
729        match name {
730            "quit" => Some(Action::Quit),
731            "save" => Some(Action::Save),
732            "split" => Some(Action::Split),
733            "top" => Some(Action::Top),
734            _ => None,
735        }
736    }
737
738    use keymap_seq::Match;
739
740    fn seq(keys: &[(char, Modifiers)]) -> Vec<KeyInput> {
741        keys.iter()
742            .map(|&(c, m)| KeyInput::new(Key::Char(c), m))
743            .collect()
744    }
745
746    #[test]
747    fn builds_bindings_and_resolves_actions() {
748        let toml = "[keys]\n\"ctrl+q\" = \"quit\"\n\"ctrl+s\" = \"save\"\n";
749        let out = from_str(toml, resolver).unwrap();
750        assert!(out.warnings.is_empty());
751        let q = KeyInput::new(Key::Char('q'), Modifiers::CTRL);
752        assert_eq!(out.global().get(&q), Some(&Action::Quit));
753    }
754
755    #[test]
756    fn bare_keys_build_the_global_layer_which_is_always_present() {
757        // Even an empty config has a (empty) global layer, so `global()` never
758        // panics and callers can rely on `layers["global"]` existing.
759        let empty: BuildOutput<Action> = from_str("", resolver).unwrap();
760        assert_eq!(empty.layers.keys().collect::<Vec<_>>(), vec![GLOBAL_LAYER]);
761        assert!(empty.global().is_empty());
762
763        let out = from_str("[keys]\n\"ctrl+q\" = \"quit\"\n", resolver).unwrap();
764        assert_eq!(out.layers.keys().collect::<Vec<_>>(), vec![GLOBAL_LAYER]);
765    }
766
767    #[test]
768    fn named_layers_are_parsed_under_their_names() {
769        let toml = "\
770[keys]\n\"ctrl+q\" = \"quit\"\n\
771[layers.panel]\n\"ctrl+s\" = \"split\"\n";
772        let out = from_str(toml, resolver).unwrap();
773        assert!(out.warnings.is_empty());
774        // global and panel both present, named exactly.
775        assert_eq!(
776            out.layers.keys().map(String::as_str).collect::<Vec<_>>(),
777            vec!["global", "panel"]
778        );
779        let q = KeyInput::new(Key::Char('q'), Modifiers::CTRL);
780        let s = KeyInput::new(Key::Char('s'), Modifiers::CTRL);
781        assert_eq!(out.global().get(&q), Some(&Action::Quit));
782        assert_eq!(out.layers["panel"].get(&s), Some(&Action::Split));
783        // A chord lives only in the layer that bound it.
784        assert_eq!(out.global().get(&s), None);
785        assert_eq!(out.layers["panel"].get(&q), None);
786    }
787
788    #[test]
789    fn a_layer_with_no_keys_section_still_gets_an_empty_global() {
790        let toml = "[layers.panel]\n\"ctrl+s\" = \"split\"\n";
791        let out = from_str(toml, resolver).unwrap();
792        assert!(out.global().is_empty());
793        assert!(!out.layers["panel"].is_empty());
794    }
795
796    #[test]
797    fn same_chord_in_two_layers_is_an_override_not_a_conflict() {
798        // The crux of the layered design: `ctrl+s` bound in both global and panel
799        // is exactly the override `resolve_layered` exists for. The library never
800        // knows which layer is active, so it must not report across the boundary.
801        let toml = "\
802[keys]\n\"ctrl+s\" = \"save\"\n\
803[layers.panel]\n\"ctrl+s\" = \"split\"\n";
804        let out = from_str(toml, resolver).unwrap();
805        assert!(
806            out.warnings.is_empty(),
807            "cross-layer override must not warn: {:?}",
808            out.warnings
809        );
810        let s = KeyInput::new(Key::Char('s'), Modifiers::CTRL);
811        assert_eq!(out.global().get(&s), Some(&Action::Save));
812        assert_eq!(out.layers["panel"].get(&s), Some(&Action::Split));
813    }
814
815    #[test]
816    fn explicit_global_layer_merges_into_the_top_level_keys() {
817        let toml = "\
818[keys]\n\"ctrl+q\" = \"quit\"\n\
819[layers.global]\n\"ctrl+s\" = \"save\"\n";
820        let out = from_str(toml, resolver).unwrap();
821        assert!(out.warnings.is_empty());
822        // No separate "global" layer is created beyond the merged one.
823        assert_eq!(out.layers.keys().collect::<Vec<_>>(), vec![GLOBAL_LAYER]);
824        let q = KeyInput::new(Key::Char('q'), Modifiers::CTRL);
825        let s = KeyInput::new(Key::Char('s'), Modifiers::CTRL);
826        assert_eq!(out.global().get(&q), Some(&Action::Quit));
827        assert_eq!(out.global().get(&s), Some(&Action::Save));
828    }
829
830    #[test]
831    fn keys_and_explicit_global_colliding_on_a_chord_conflict_last_wins() {
832        // The same chord in `[keys]` and `[layers.global]` is a within-layer
833        // conflict (they are the same layer): reported, and the `[layers.global]`
834        // entry — fed after `[keys]` — wins.
835        let toml = "\
836[keys]\n\"ctrl+q\" = \"quit\"\n\
837[layers.global]\n\"ctrl+q\" = \"save\"\n";
838        let out = from_str(toml, resolver).unwrap();
839        assert_eq!(
840            out.warnings,
841            vec![Warning::Conflict {
842                chord: "ctrl+q".to_string(),
843                contenders: vec!["quit".to_string(), "save".to_string()],
844                winner: "save".to_string(),
845            }]
846        );
847        let q = KeyInput::new(Key::Char('q'), Modifiers::CTRL);
848        assert_eq!(out.global().get(&q), Some(&Action::Save));
849    }
850
851    #[test]
852    fn conflict_within_a_named_layer_is_reported() {
853        // `ctrl+a` and `control+a` are the same chord spelled two ways; within one
854        // layer that is a conflict, exactly as it is in the global layer.
855        let toml = "\
856[layers.panel]\n\"ctrl+a\" = \"quit\"\n\"control+a\" = \"save\"\n";
857        let out = from_str(toml, resolver).unwrap();
858        assert_eq!(
859            out.warnings,
860            vec![Warning::Conflict {
861                chord: "ctrl+a".to_string(),
862                contenders: vec!["save".to_string(), "quit".to_string()],
863                winner: "quit".to_string(),
864            }]
865        );
866    }
867
868    #[test]
869    fn cross_shadow_is_checked_against_global_only() {
870        // A chord `j` lives in `panel`, while the (global) sequence `j k` lives in
871        // the global config. Because sequences are global and cross-shadow is a
872        // global-layer-only check, the panel chord is *not* flagged.
873        let toml = "\
874[layers.panel]\n\"j\" = \"top\"\n\
875[[sequences]]\nkeys = [\"j\", \"k\"]\naction = \"save\"\n";
876        let out = from_str(toml, resolver).unwrap();
877        assert!(
878            out.warnings.is_empty(),
879            "a non-global chord must not cross-shadow a global sequence: {:?}",
880            out.warnings
881        );
882    }
883
884    #[test]
885    fn unknown_action_in_a_named_layer_is_a_warning_carrying_no_layer_name() {
886        // A named layer goes through the same pipeline as global, so an unknown
887        // action there is a `UnknownAction` warning (binding skipped, not fatal).
888        // By design the warning names the chord, not the layer.
889        let toml = "[layers.panel]\n\"ctrl+z\" = \"undo\"\n";
890        let out = from_str(toml, resolver).unwrap();
891        assert_eq!(
892            out.warnings,
893            vec![Warning::UnknownAction {
894                key: "ctrl+z".to_string(),
895                action: "undo".to_string(),
896            }]
897        );
898        assert!(out.layers["panel"].is_empty());
899    }
900
901    #[test]
902    fn malformed_key_in_a_named_layer_is_a_fatal_error() {
903        // Parse failures are fatal in any layer, not just `[keys]`.
904        let toml = "[layers.panel]\n\"ctrl+nope\" = \"quit\"\n";
905        let err = from_str(toml, resolver).unwrap_err();
906        assert!(matches!(err, BuildError::KeyParse { .. }));
907    }
908
909    #[test]
910    fn sequences_do_not_create_extra_layers() {
911        // A global-only config with sequences must still yield exactly the global
912        // layer — sequences live beside the layers, not as one.
913        let toml = "\
914[keys]\n\"ctrl+q\" = \"quit\"\n\
915[[sequences]]\nkeys = [\"ctrl+x\", \"ctrl+s\"]\naction = \"save\"\n";
916        let out = from_str(toml, resolver).unwrap();
917        assert!(out.warnings.is_empty());
918        assert_eq!(out.layers.keys().collect::<Vec<_>>(), vec![GLOBAL_LAYER]);
919        assert!(!out.sequences.is_empty());
920    }
921
922    #[test]
923    fn unknown_actions_across_layers_warn_global_first_then_name_order() {
924        // Warning order is deterministic: global first, then named layers in
925        // `BTreeMap` (lexicographic) name order.
926        let toml = "\
927[keys]\n\"a\" = \"nope_global\"\n\
928[layers.zeta]\n\"b\" = \"nope_zeta\"\n\
929[layers.alpha]\n\"c\" = \"nope_alpha\"\n";
930        let out = from_str(toml, resolver).unwrap();
931        let unknown_actions: Vec<&str> = out
932            .warnings
933            .iter()
934            .filter_map(|w| match w {
935                Warning::UnknownAction { action, .. } => Some(action.as_str()),
936                _ => None,
937            })
938            .collect();
939        assert_eq!(
940            unknown_actions,
941            vec!["nope_global", "nope_alpha", "nope_zeta"]
942        );
943    }
944
945    #[test]
946    fn unknown_top_level_field_is_a_fatal_error() {
947        // `deny_unknown_fields`: a misspelled table is rejected, not ignored.
948        let err = from_str("[kesy]\n\"ctrl+q\" = \"quit\"\n", resolver).unwrap_err();
949        assert!(matches!(err, BuildError::Toml(_)));
950    }
951
952    #[test]
953    fn unknown_sequence_field_is_a_fatal_error() {
954        // Guards the future `layer = "..."` extension: an unsupported sequence
955        // field fails loudly today rather than being silently dropped.
956        let toml = "[[sequences]]\nkeys = [\"g\"]\naction = \"top\"\nlayer = \"panel\"\n";
957        let err = from_str(toml, resolver).unwrap_err();
958        assert!(matches!(err, BuildError::Toml(_)));
959    }
960
961    #[test]
962    fn unknown_action_is_a_warning_not_a_failure() {
963        let toml = "[keys]\n\"ctrl+q\" = \"quit\"\n\"ctrl+z\" = \"undo\"\n";
964        let out = from_str(toml, resolver).unwrap();
965        // The good binding still works.
966        let q = KeyInput::new(Key::Char('q'), Modifiers::CTRL);
967        assert_eq!(out.global().get(&q), Some(&Action::Quit));
968        assert_eq!(
969            out.warnings,
970            vec![Warning::UnknownAction {
971                key: "ctrl+z".to_string(),
972                action: "undo".to_string(),
973            }]
974        );
975    }
976
977    #[test]
978    fn distinct_spellings_of_same_chord_conflict() {
979        // Different spellings of the same chord: "ctrl+a" and the alias
980        // "control+a". (Note "ctrl+A" would be a *different* chord, since the
981        // glyph case is significant by design.)
982        let toml = "[keys]\n\"ctrl+a\" = \"quit\"\n\"control+a\" = \"save\"\n";
983        let out = from_str(toml, resolver).unwrap();
984        let a = KeyInput::new(Key::Char('a'), Modifiers::CTRL);
985        // One winner is bound for the shared chord.
986        assert!(out.global().get(&a).is_some());
987        let conflicts: Vec<_> = out
988            .warnings
989            .iter()
990            .filter(|w| matches!(w, Warning::Conflict { .. }))
991            .collect();
992        assert_eq!(conflicts.len(), 1);
993    }
994
995    #[test]
996    fn legacy_lints_are_opt_in_and_separate_from_warnings() {
997        // `cmd+s` is a perfectly valid binding — the config is clean — but it
998        // won't reach a legacy terminal. That is NOT a build warning; it surfaces
999        // only when the caller opts in by calling `legacy_lints`.
1000        let toml = "[keys]\n\"cmd+s\" = \"save\"\n";
1001        let out = from_str(toml, resolver).unwrap();
1002        assert!(out.warnings.is_empty());
1003        assert_eq!(
1004            keymap_core::legacy_lints(out.global()),
1005            vec![keymap_core::LegacyLint::Unrepresentable {
1006                chord: "super+s".to_string(),
1007            }]
1008        );
1009    }
1010
1011    #[test]
1012    fn malformed_key_is_a_fatal_error() {
1013        let toml = "[keys]\n\"ctrl+nope\" = \"quit\"\n";
1014        let err = from_str(toml, resolver).unwrap_err();
1015        assert!(matches!(err, BuildError::KeyParse { .. }));
1016    }
1017
1018    #[test]
1019    fn malformed_toml_is_a_fatal_error() {
1020        let err = from_str("this is not toml", resolver).unwrap_err();
1021        assert!(matches!(err, BuildError::Toml(_)));
1022    }
1023
1024    #[test]
1025    fn empty_config_builds_empty_map() {
1026        let out: BuildOutput<Action> = from_str("", resolver).unwrap();
1027        assert!(out.global().is_empty());
1028        assert!(out.sequences.is_empty());
1029        assert!(out.warnings.is_empty());
1030    }
1031
1032    #[test]
1033    fn builds_sequence_bindings() {
1034        let toml = "\
1035[keys]\n\"ctrl+q\" = \"quit\"\n\
1036[[sequences]]\nkeys = [\"ctrl+x\", \"ctrl+s\"]\naction = \"save\"\n\
1037[[sequences]]\nkeys = [\"ctrl+x\", \"ctrl+c\"]\naction = \"quit\"\n";
1038        let out = from_str(toml, resolver).unwrap();
1039        assert!(out.warnings.is_empty());
1040        // Single chord still lands in the single-key map.
1041        let q = KeyInput::new(Key::Char('q'), Modifiers::CTRL);
1042        assert_eq!(out.global().get(&q), Some(&Action::Quit));
1043        // The sequence resolves; a partial buffer is a prefix.
1044        let save = seq(&[('x', Modifiers::CTRL), ('s', Modifiers::CTRL)]);
1045        assert_eq!(out.sequences.lookup(&save), Match::Exact(&Action::Save));
1046        assert_eq!(out.sequences.lookup(&save[..1]), Match::Prefix);
1047    }
1048
1049    #[test]
1050    fn sequence_unknown_action_is_a_warning() {
1051        let toml = "[[sequences]]\nkeys = [\"ctrl+x\", \"ctrl+z\"]\naction = \"undo\"\n";
1052        let out = from_str(toml, resolver).unwrap();
1053        assert!(out.sequences.is_empty());
1054        assert_eq!(
1055            out.warnings,
1056            vec![Warning::UnknownAction {
1057                key: "ctrl+x ctrl+z".to_string(),
1058                action: "undo".to_string(),
1059            }]
1060        );
1061    }
1062
1063    #[test]
1064    fn sequence_prefix_shadow_is_a_warning_and_drops_the_later() {
1065        // `g` (top) then `g g` (split): the shorter shadows the longer.
1066        let toml = "\
1067[[sequences]]\nkeys = [\"g\"]\naction = \"top\"\n\
1068[[sequences]]\nkeys = [\"g\", \"g\"]\naction = \"split\"\n";
1069        let out = from_str(toml, resolver).unwrap();
1070        // The earlier (shorter) binding survives; the later is dropped.
1071        assert_eq!(
1072            out.sequences.lookup(&seq(&[('g', Modifiers::NONE)])),
1073            Match::Exact(&Action::Top)
1074        );
1075        assert_eq!(
1076            out.warnings,
1077            vec![Warning::PrefixShadow {
1078                prefix: vec!["g".to_string()],
1079                prefix_action: "top".to_string(),
1080                shadowed: vec!["g".to_string(), "g".to_string()],
1081                shadowed_action: "split".to_string(),
1082            }]
1083        );
1084    }
1085
1086    #[test]
1087    fn sequence_prefix_shadow_reverse_order_drops_the_later_short_one() {
1088        // Longer bound first, then the shorter prefix arrives: the shorter is the
1089        // later binding and is dropped, but it is still labelled the `prefix`.
1090        let toml = "\
1091[[sequences]]\nkeys = [\"g\", \"g\"]\naction = \"split\"\n\
1092[[sequences]]\nkeys = [\"g\"]\naction = \"top\"\n";
1093        let out = from_str(toml, resolver).unwrap();
1094        // The earlier (longer) binding survives.
1095        assert_eq!(
1096            out.sequences
1097                .lookup(&seq(&[('g', Modifiers::NONE), ('g', Modifiers::NONE)])),
1098            Match::Exact(&Action::Split)
1099        );
1100        assert_eq!(
1101            out.warnings,
1102            vec![Warning::PrefixShadow {
1103                prefix: vec!["g".to_string()],
1104                prefix_action: "top".to_string(),
1105                shadowed: vec!["g".to_string(), "g".to_string()],
1106                shadowed_action: "split".to_string(),
1107            }]
1108        );
1109    }
1110
1111    #[test]
1112    fn same_sequence_three_times_reports_pairwise_conflicts() {
1113        let toml = "\
1114[[sequences]]\nkeys = [\"ctrl+x\"]\naction = \"save\"\n\
1115[[sequences]]\nkeys = [\"ctrl+x\"]\naction = \"split\"\n\
1116[[sequences]]\nkeys = [\"ctrl+x\"]\naction = \"quit\"\n";
1117        let out = from_str(toml, resolver).unwrap();
1118        // Last one wins.
1119        assert_eq!(
1120            out.sequences.lookup(&seq(&[('x', Modifiers::CTRL)])),
1121            Match::Exact(&Action::Quit)
1122        );
1123        // Two pairwise conflicts, each naming the running winner vs the newcomer.
1124        assert_eq!(
1125            out.warnings,
1126            vec![
1127                Warning::Conflict {
1128                    chord: "ctrl+x".to_string(),
1129                    contenders: vec!["save".to_string(), "split".to_string()],
1130                    winner: "split".to_string(),
1131                },
1132                Warning::Conflict {
1133                    chord: "ctrl+x".to_string(),
1134                    contenders: vec!["split".to_string(), "quit".to_string()],
1135                    winner: "quit".to_string(),
1136                },
1137            ]
1138        );
1139    }
1140
1141    #[test]
1142    fn empty_sequence_is_a_warning() {
1143        let toml = "[[sequences]]\nkeys = []\naction = \"save\"\n";
1144        let out = from_str(toml, resolver).unwrap();
1145        assert!(out.sequences.is_empty());
1146        assert_eq!(
1147            out.warnings,
1148            vec![Warning::EmptySequence {
1149                action: "save".to_string(),
1150            }]
1151        );
1152    }
1153
1154    #[test]
1155    fn duplicate_sequence_conflicts_and_last_wins() {
1156        let toml = "\
1157[[sequences]]\nkeys = [\"ctrl+x\", \"ctrl+s\"]\naction = \"save\"\n\
1158[[sequences]]\nkeys = [\"ctrl+x\", \"ctrl+s\"]\naction = \"split\"\n";
1159        let out = from_str(toml, resolver).unwrap();
1160        let s = seq(&[('x', Modifiers::CTRL), ('s', Modifiers::CTRL)]);
1161        assert_eq!(out.sequences.lookup(&s), Match::Exact(&Action::Split));
1162        assert_eq!(
1163            out.warnings,
1164            vec![Warning::Conflict {
1165                chord: "ctrl+x ctrl+s".to_string(),
1166                contenders: vec!["save".to_string(), "split".to_string()],
1167                winner: "split".to_string(),
1168            }]
1169        );
1170    }
1171
1172    #[test]
1173    fn malformed_sequence_key_is_a_fatal_error() {
1174        let toml = "[[sequences]]\nkeys = [\"ctrl+x\", \"ctrl+nope\"]\naction = \"save\"\n";
1175        let err = from_str(toml, resolver).unwrap_err();
1176        assert!(matches!(err, BuildError::KeyParse { .. }));
1177    }
1178
1179    #[test]
1180    fn single_chord_shadowing_a_sequence_is_an_advisory_warning() {
1181        // `j` (down) and a sequence `j j` (the vim `jj` case): pressing `j` is
1182        // ambiguous without a timeout. Both bindings are kept — only flagged.
1183        let toml = "\
1184[keys]\n\"j\" = \"top\"\n\
1185[[sequences]]\nkeys = [\"j\", \"k\"]\naction = \"save\"\n";
1186        let out = from_str(toml, resolver).unwrap();
1187        // Nothing dropped: the chord resolves and the sequence resolves.
1188        assert_eq!(
1189            out.global()
1190                .get(&KeyInput::new(Key::Char('j'), Modifiers::NONE)),
1191            Some(&Action::Top)
1192        );
1193        assert_eq!(
1194            out.sequences
1195                .lookup(&seq(&[('j', Modifiers::NONE), ('k', Modifiers::NONE)])),
1196            Match::Exact(&Action::Save)
1197        );
1198        assert_eq!(
1199            out.warnings,
1200            vec![Warning::SequenceShadow {
1201                chord: "j".to_string(),
1202                chord_action: "top".to_string(),
1203                sequence: vec!["j".to_string(), "k".to_string()],
1204                sequence_action: "save".to_string(),
1205            }]
1206        );
1207    }
1208
1209    #[test]
1210    fn length_one_sequence_equal_to_a_single_chord_shadows() {
1211        // A degenerate overlap: the single chord and a length-1 sequence are the
1212        // exact same key, bound in both maps.
1213        let toml = "\
1214[keys]\n\"q\" = \"quit\"\n\
1215[[sequences]]\nkeys = [\"q\"]\naction = \"save\"\n";
1216        let out = from_str(toml, resolver).unwrap();
1217        assert_eq!(
1218            out.warnings,
1219            vec![Warning::SequenceShadow {
1220                chord: "q".to_string(),
1221                chord_action: "quit".to_string(),
1222                sequence: vec!["q".to_string()],
1223                sequence_action: "save".to_string(),
1224            }]
1225        );
1226    }
1227
1228    #[test]
1229    fn disjoint_first_keys_do_not_shadow() {
1230        // `j` single, `g g` sequence: different first keys, no overlap.
1231        let toml = "\
1232[keys]\n\"j\" = \"top\"\n\
1233[[sequences]]\nkeys = [\"g\", \"g\"]\naction = \"save\"\n";
1234        let out = from_str(toml, resolver).unwrap();
1235        assert!(out.warnings.is_empty());
1236    }
1237
1238    #[test]
1239    fn one_chord_shadowing_several_sequences_reports_the_lex_first() {
1240        // `j` starts both `j k` and `j a`; one advisory names the lex-first (`j a`).
1241        let toml = "\
1242[keys]\n\"j\" = \"top\"\n\
1243[[sequences]]\nkeys = [\"j\", \"k\"]\naction = \"save\"\n\
1244[[sequences]]\nkeys = [\"j\", \"a\"]\naction = \"quit\"\n";
1245        let out = from_str(toml, resolver).unwrap();
1246        assert_eq!(
1247            out.warnings,
1248            vec![Warning::SequenceShadow {
1249                chord: "j".to_string(),
1250                chord_action: "top".to_string(),
1251                sequence: vec!["j".to_string(), "a".to_string()],
1252                sequence_action: "quit".to_string(),
1253            }]
1254        );
1255    }
1256
1257    #[test]
1258    fn multiple_shadowing_chords_emit_in_canonical_chord_order() {
1259        // `z` is declared before `j`, but warnings come out chord-sorted (`j`
1260        // before `z`) — pins the sort, which the HashMap source order would not
1261        // otherwise guarantee.
1262        let toml = "\
1263[keys]\n\"z\" = \"top\"\n\"j\" = \"quit\"\n\
1264[[sequences]]\nkeys = [\"z\", \"x\"]\naction = \"save\"\n\
1265[[sequences]]\nkeys = [\"j\", \"k\"]\naction = \"split\"\n";
1266        let out = from_str(toml, resolver).unwrap();
1267        assert_eq!(
1268            out.warnings,
1269            vec![
1270                Warning::SequenceShadow {
1271                    chord: "j".to_string(),
1272                    chord_action: "quit".to_string(),
1273                    sequence: vec!["j".to_string(), "k".to_string()],
1274                    sequence_action: "split".to_string(),
1275                },
1276                Warning::SequenceShadow {
1277                    chord: "z".to_string(),
1278                    chord_action: "top".to_string(),
1279                    sequence: vec!["z".to_string(), "x".to_string()],
1280                    sequence_action: "save".to_string(),
1281                },
1282            ]
1283        );
1284    }
1285
1286    #[test]
1287    fn unknown_single_chord_does_not_shadow() {
1288        // `j` resolves to nothing, so it never enters the keymap — it cannot
1289        // shadow. Only the UnknownAction warning fires, no SequenceShadow.
1290        let toml = "\
1291[keys]\n\"j\" = \"nonexistent\"\n\
1292[[sequences]]\nkeys = [\"j\", \"k\"]\naction = \"save\"\n";
1293        let out = from_str(toml, resolver).unwrap();
1294        assert_eq!(
1295            out.warnings,
1296            vec![Warning::UnknownAction {
1297                key: "j".to_string(),
1298                action: "nonexistent".to_string(),
1299            }]
1300        );
1301    }
1302
1303    #[test]
1304    fn cross_shadow_coexists_with_conflict_and_comes_last() {
1305        // A single-key Conflict and a SequenceShadow in one config: detection
1306        // appends after the key/sequence passes, so the shadow is emitted last.
1307        let toml = "\
1308[keys]\n\"ctrl+a\" = \"quit\"\n\"control+a\" = \"save\"\n\"j\" = \"top\"\n\
1309[[sequences]]\nkeys = [\"j\", \"k\"]\naction = \"split\"\n";
1310        let out = from_str(toml, resolver).unwrap();
1311        assert_eq!(
1312            out.warnings,
1313            vec![
1314                // The `[keys]` table is a BTreeMap, so "control+a" (save) sorts
1315                // before "ctrl+a" (quit); save is the earlier contender, quit wins.
1316                Warning::Conflict {
1317                    chord: "ctrl+a".to_string(),
1318                    contenders: vec!["save".to_string(), "quit".to_string()],
1319                    winner: "quit".to_string(),
1320                },
1321                Warning::SequenceShadow {
1322                    chord: "j".to_string(),
1323                    chord_action: "top".to_string(),
1324                    sequence: vec!["j".to_string(), "k".to_string()],
1325                    sequence_action: "split".to_string(),
1326                },
1327            ]
1328        );
1329    }
1330
1331    #[test]
1332    fn chord_matching_a_non_first_sequence_key_does_not_shadow() {
1333        // `j` is only the *second* key of `g j`; the predicate is `first() ==`,
1334        // not "contains", so this must not warn.
1335        let toml = "\
1336[keys]\n\"j\" = \"top\"\n\
1337[[sequences]]\nkeys = [\"g\", \"j\"]\naction = \"save\"\n";
1338        let out = from_str(toml, resolver).unwrap();
1339        assert!(out.warnings.is_empty());
1340    }
1341
1342    // --- to_toml round-trip ---
1343    //
1344    // These use `String` as the action type, so `name_of`/`resolve` are exact
1345    // inverses (`|a| Some(a)` / `|n| Some(n.to_owned())`) and any round-trip
1346    // failure is the serializer's, not the resolver's.
1347
1348    fn km_pairs(km: &Keymap<String>) -> Vec<(KeyInput, String)> {
1349        let mut v: Vec<_> = km.iter().map(|(k, a)| (*k, a.clone())).collect();
1350        v.sort_by_key(|(k, _)| k.to_string());
1351        v
1352    }
1353
1354    fn seq_pairs(s: &SequenceKeymap<String>) -> Vec<(Vec<KeyInput>, String)> {
1355        let mut v: Vec<_> = s.bindings().map(|(p, a)| (p, a.clone())).collect();
1356        v.sort_by_key(|(p, _)| render_sequence(p).join(" "));
1357        v
1358    }
1359
1360    /// `to_toml` then `from_str` reproduces the same bindings.
1361    fn assert_round_trips(km: &Keymap<String>, seq: &SequenceKeymap<String>) {
1362        let toml = to_toml(km, seq, |a: &String| Some(a.as_str()));
1363        let out = from_str(&toml, |name: &str| Some(name.to_owned())).unwrap();
1364        assert!(
1365            out.warnings.is_empty(),
1366            "round-trip warned: {:?}",
1367            out.warnings
1368        );
1369        assert_eq!(km_pairs(km), km_pairs(out.global()));
1370        assert_eq!(seq_pairs(seq), seq_pairs(&out.sequences));
1371    }
1372
1373    fn norm(key: Key, mods: Modifiers) -> KeyInput {
1374        KeyInput::normalized(key, mods)
1375    }
1376
1377    #[test]
1378    fn to_toml_round_trips_keys_and_sequences() {
1379        let mut km = Keymap::new();
1380        km.bind(norm(Key::Char('q'), Modifiers::CTRL), "quit".to_owned());
1381        km.bind(norm(Key::Char('s'), Modifiers::CTRL), "save".to_owned());
1382        // Normalization fixed point: bare shift+letter folds to the letter, so it
1383        // emits as "a" and round-trips to the same normalized chord.
1384        km.bind(norm(Key::Char('a'), Modifiers::SHIFT), "all".to_owned());
1385        // Multi-modifier keeps shift: "ctrl+shift+a" survives as itself.
1386        km.bind(
1387            norm(Key::Char('a'), Modifiers::CTRL | Modifiers::SHIFT),
1388            "alt_all".to_owned(),
1389        );
1390
1391        let mut seq = SequenceKeymap::new();
1392        seq.bind(
1393            [
1394                norm(Key::Char('x'), Modifiers::CTRL),
1395                norm(Key::Char('s'), Modifiers::CTRL),
1396            ],
1397            "seq_save".to_owned(),
1398        )
1399        .unwrap();
1400        seq.bind(
1401            [
1402                norm(Key::Char('g'), Modifiers::NONE),
1403                norm(Key::Char('g'), Modifiers::NONE),
1404            ],
1405            "top".to_owned(),
1406        )
1407        .unwrap();
1408
1409        assert_round_trips(&km, &seq);
1410    }
1411
1412    #[test]
1413    fn to_toml_is_deterministic_and_canonically_ordered() {
1414        let mut km = Keymap::new();
1415        km.bind(norm(Key::Char('z'), Modifiers::CTRL), "z".to_owned());
1416        km.bind(norm(Key::Char('a'), Modifiers::CTRL), "a".to_owned());
1417        let seq = SequenceKeymap::new();
1418
1419        let first = to_toml(&km, &seq, |a: &String| Some(a.as_str()));
1420        let second = to_toml(&km, &seq, |a: &String| Some(a.as_str()));
1421        assert_eq!(first, second, "output must be deterministic");
1422        // Canonical chord order: "ctrl+a" before "ctrl+z" regardless of bind order.
1423        let a_at = first.find("ctrl+a").unwrap();
1424        let z_at = first.find("ctrl+z").unwrap();
1425        assert!(
1426            a_at < z_at,
1427            "keys must be emitted in canonical order:\n{first}"
1428        );
1429    }
1430
1431    #[test]
1432    fn to_toml_omits_unnameable_bindings() {
1433        let mut km = Keymap::new();
1434        km.bind(norm(Key::Char('q'), Modifiers::CTRL), "quit".to_owned());
1435        km.bind(norm(Key::Char('x'), Modifiers::CTRL), "secret".to_owned());
1436        let seq = SequenceKeymap::new();
1437
1438        // `name_of` declines to name "secret", so that binding is dropped.
1439        let toml = to_toml(&km, &seq, |a: &String| {
1440            (a != "secret").then_some(a.as_str())
1441        });
1442        let out = from_str(&toml, |name: &str| Some(name.to_owned())).unwrap();
1443        assert_eq!(
1444            out.global().get(&norm(Key::Char('q'), Modifiers::CTRL)),
1445            Some(&"quit".to_owned())
1446        );
1447        assert_eq!(
1448            out.global().get(&norm(Key::Char('x'), Modifiers::CTRL)),
1449            None
1450        );
1451    }
1452
1453    #[test]
1454    fn to_toml_empty_maps_emit_empty_string() {
1455        let km: Keymap<String> = Keymap::new();
1456        let seq: SequenceKeymap<String> = SequenceKeymap::new();
1457        assert_eq!(to_toml(&km, &seq, |a: &String| Some(a.as_str())), "");
1458    }
1459
1460    #[test]
1461    fn to_toml_round_trips_adversarial_names_and_chords() {
1462        // Action names with TOML metacharacters must not break out of their
1463        // string and inject bindings; emitting via `toml::Value` escapes them.
1464        let mut km = Keymap::new();
1465        km.bind(
1466            norm(Key::Char('a'), Modifiers::NONE),
1467            "quit\"; [injected]\nx = \"oops".to_owned(),
1468        );
1469        // Chords whose glyph collides with grammar/TOML punctuation.
1470        km.bind(norm(Key::Char('+'), Modifiers::NONE), "plus".to_owned());
1471        km.bind(norm(Key::Char(' '), Modifiers::NONE), "space".to_owned());
1472        km.bind(norm(Key::Char('"'), Modifiers::NONE), "quote".to_owned());
1473        km.bind(norm(Key::Char('あ'), Modifiers::NONE), "hira".to_owned());
1474        km.bind(norm(Key::Char('F'), Modifiers::NONE), "cap_f".to_owned());
1475
1476        // Sequence starts with an unbound key (`z`) so it does not cross-shadow
1477        // the single-key ` `/`+` bindings; ` ` and `+` still appear as non-first
1478        // elements to exercise array-element round-tripping of odd glyphs.
1479        let mut seq = SequenceKeymap::new();
1480        seq.bind(
1481            [
1482                norm(Key::Char('z'), Modifiers::NONE),
1483                norm(Key::Char(' '), Modifiers::NONE),
1484                norm(Key::Char('+'), Modifiers::NONE),
1485            ],
1486            "z_space_plus".to_owned(),
1487        )
1488        .unwrap();
1489
1490        assert_round_trips(&km, &seq);
1491    }
1492
1493    #[test]
1494    fn shadow_matching_is_on_the_parsed_chord_not_the_label() {
1495        // Positive: `ctrl+x` single shadows `[ctrl+x, ctrl+s]`.
1496        let toml = "\
1497[keys]\n\"ctrl+x\" = \"top\"\n\
1498[[sequences]]\nkeys = [\"ctrl+x\", \"ctrl+s\"]\naction = \"save\"\n";
1499        let out = from_str(toml, resolver).unwrap();
1500        assert_eq!(
1501            out.warnings,
1502            vec![Warning::SequenceShadow {
1503                chord: "ctrl+x".to_string(),
1504                chord_action: "top".to_string(),
1505                sequence: vec!["ctrl+x".to_string(), "ctrl+s".to_string()],
1506                sequence_action: "save".to_string(),
1507            }]
1508        );
1509
1510        // Negative: a different modifier set is a different KeyInput, no shadow.
1511        let toml = "\
1512[keys]\n\"ctrl+x\" = \"top\"\n\
1513[[sequences]]\nkeys = [\"ctrl+shift+x\", \"ctrl+s\"]\naction = \"save\"\n";
1514        let out = from_str(toml, resolver).unwrap();
1515        assert!(out.warnings.is_empty());
1516    }
1517
1518    #[test]
1519    fn to_toml_layered_round_trips_named_layers() {
1520        let mut global = Keymap::new();
1521        global.bind(norm(Key::Char('q'), Modifiers::CTRL), "quit".to_owned());
1522        let mut panel = Keymap::new();
1523        panel.bind(norm(Key::Char('s'), Modifiers::CTRL), "split".to_owned());
1524        // The same chord in two layers is preserved independently on round-trip.
1525        panel.bind(
1526            norm(Key::Char('q'), Modifiers::CTRL),
1527            "panel_quit".to_owned(),
1528        );
1529
1530        let mut layers = BTreeMap::new();
1531        layers.insert(GLOBAL_LAYER.to_string(), global);
1532        layers.insert("panel".to_string(), panel);
1533
1534        let mut seq = SequenceKeymap::new();
1535        seq.bind(
1536            [
1537                norm(Key::Char('x'), Modifiers::CTRL),
1538                norm(Key::Char('s'), Modifiers::CTRL),
1539            ],
1540            "seq_save".to_owned(),
1541        )
1542        .unwrap();
1543
1544        let toml = to_toml_layered(&layers, &seq, |a: &String| Some(a.as_str()));
1545        let out = from_str(&toml, |name: &str| Some(name.to_owned())).unwrap();
1546        assert!(
1547            out.warnings.is_empty(),
1548            "round-trip warned: {:?}",
1549            out.warnings
1550        );
1551        assert_eq!(km_pairs(&layers["global"]), km_pairs(out.global()));
1552        assert_eq!(km_pairs(&layers["panel"]), km_pairs(&out.layers["panel"]));
1553        assert_eq!(seq_pairs(&seq), seq_pairs(&out.sequences));
1554    }
1555
1556    #[test]
1557    fn to_toml_layered_matches_to_toml_for_a_global_only_set() {
1558        // A set with only the global layer must emit byte-identical output to
1559        // `to_toml` on that one keymap, so the two emitters cannot drift.
1560        let mut global = Keymap::new();
1561        global.bind(norm(Key::Char('q'), Modifiers::CTRL), "quit".to_owned());
1562        global.bind(norm(Key::Char('s'), Modifiers::CTRL), "save".to_owned());
1563        let mut seq = SequenceKeymap::new();
1564        seq.bind(
1565            [
1566                norm(Key::Char('g'), Modifiers::NONE),
1567                norm(Key::Char('g'), Modifiers::NONE),
1568            ],
1569            "top".to_owned(),
1570        )
1571        .unwrap();
1572
1573        let mut layers = BTreeMap::new();
1574        layers.insert(GLOBAL_LAYER.to_string(), global.clone());
1575
1576        let plain = to_toml(&global, &seq, |a: &String| Some(a.as_str()));
1577        let layered = to_toml_layered(&layers, &seq, |a: &String| Some(a.as_str()));
1578        assert_eq!(plain, layered);
1579        // And an empty global-only set emits the empty string, like `to_toml`.
1580        let empty: BTreeMap<String, Keymap<String>> = BTreeMap::new();
1581        assert_eq!(
1582            to_toml_layered(&empty, &SequenceKeymap::new(), |a: &String| Some(
1583                a.as_str()
1584            )),
1585            ""
1586        );
1587    }
1588
1589    #[test]
1590    fn to_toml_layered_drops_empty_layers() {
1591        // An empty layer emits no table, so it does not survive a round-trip.
1592        // This is the deliberate asymmetry: `from_str` keeps a declared-but-empty
1593        // layer in the map, but `to_toml_layered` writes only layers with bindings.
1594        let mut global = Keymap::new();
1595        global.bind(norm(Key::Char('q'), Modifiers::CTRL), "quit".to_owned());
1596        let mut layers = BTreeMap::new();
1597        layers.insert(GLOBAL_LAYER.to_string(), global);
1598        layers.insert("panel".to_string(), Keymap::<String>::new());
1599
1600        let toml = to_toml_layered(&layers, &SequenceKeymap::new(), |a: &String| {
1601            Some(a.as_str())
1602        });
1603        assert!(
1604            !toml.contains("panel"),
1605            "empty layer must not be emitted:\n{toml}"
1606        );
1607        let out = from_str(&toml, |name: &str| Some(name.to_owned())).unwrap();
1608        assert_eq!(out.layers.keys().collect::<Vec<_>>(), vec![GLOBAL_LAYER]);
1609    }
1610}