use std::collections::{BTreeMap, HashMap};
use keymap_core::{KeyInput, Keymap, ParseKeyInputError};
use keymap_seq::{SeqBindError, SequenceKeymap};
use serde::Deserialize;
pub const GLOBAL_LAYER: &str = "global";
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct BuildOutput<A> {
pub layers: BTreeMap<String, Keymap<A>>,
pub sequences: SequenceKeymap<A>,
pub warnings: Vec<Warning>,
pub unbinds: BTreeMap<String, Vec<KeyInput>>,
}
impl<A> BuildOutput<A> {
#[must_use]
pub fn global(&self) -> &Keymap<A> {
self.layers
.get(GLOBAL_LAYER)
.expect("the global layer is always inserted")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Warning {
Conflict {
chord: String,
contenders: Vec<String>,
winner: String,
},
UnknownAction {
key: String,
action: String,
},
PrefixShadow {
prefix: Vec<String>,
prefix_action: String,
shadowed: Vec<String>,
shadowed_action: String,
},
EmptySequence {
action: String,
},
SequenceShadow {
chord: String,
chord_action: String,
sequence: Vec<String>,
sequence_action: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum WarningKind {
Conflict,
UnknownAction,
PrefixShadow,
EmptySequence,
SequenceShadow,
}
impl Warning {
#[must_use]
pub fn kind(&self) -> WarningKind {
match self {
Warning::Conflict { .. } => WarningKind::Conflict,
Warning::UnknownAction { .. } => WarningKind::UnknownAction,
Warning::PrefixShadow { .. } => WarningKind::PrefixShadow,
Warning::EmptySequence { .. } => WarningKind::EmptySequence,
Warning::SequenceShadow { .. } => WarningKind::SequenceShadow,
}
}
}
impl core::fmt::Display for Warning {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Warning::Conflict { chord, winner, .. } => {
write!(f, "conflict on {chord:?}: {winner:?} wins")
}
Warning::UnknownAction { key, action } => {
write!(f, "unknown action {action:?} for key {key:?}")
}
Warning::PrefixShadow {
prefix,
shadowed,
prefix_action,
..
} => {
write!(
f,
"prefix shadow: {:?} ({prefix_action:?}) shadows {:?}; later binding dropped",
prefix.join(" "),
shadowed.join(" ")
)
}
Warning::EmptySequence { action } => {
write!(f, "empty sequence for action {action:?}")
}
Warning::SequenceShadow {
chord,
sequence,
chord_action,
..
} => {
write!(
f,
"sequence shadow: chord {chord:?} ({chord_action:?}) shadows sequence {:?}",
sequence.join(" ")
)
}
}
}
}
#[derive(Debug)]
#[non_exhaustive]
pub enum BuildError {
Toml(toml::de::Error),
KeyParse {
key: String,
source: ParseKeyInputError,
},
InvalidTombstone {
key: String,
},
}
impl core::fmt::Display for BuildError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
BuildError::Toml(_) => f.write_str("invalid TOML"),
BuildError::KeyParse { key, .. } => write!(f, "invalid key string {key:?}"),
BuildError::InvalidTombstone { key } => {
write!(
f,
"invalid tombstone for {key:?}: use `= false` to unbind, not `= true`"
)
}
}
}
}
impl std::error::Error for BuildError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
BuildError::Toml(e) => Some(e),
BuildError::KeyParse { source, .. } => Some(source),
BuildError::InvalidTombstone { .. } => None,
}
}
}
impl From<toml::de::Error> for BuildError {
fn from(e: toml::de::Error) -> Self {
BuildError::Toml(e)
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawConfig {
#[serde(default)]
keys: BTreeMap<String, RawValue>,
#[serde(default)]
layers: BTreeMap<String, BTreeMap<String, RawValue>>,
#[serde(default)]
sequences: Vec<RawSequence>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum RawValue {
Action(String),
Bool(bool),
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawSequence {
keys: Vec<String>,
action: String,
}
type SequenceBuild<A> = (SequenceKeymap<A>, HashMap<Vec<KeyInput>, String>);
type LayerBuild<A> = (Keymap<A>, HashMap<KeyInput, String>, Vec<KeyInput>);
pub fn from_str<A, F>(toml_str: &str, mut resolve: F) -> Result<BuildOutput<A>, BuildError>
where
F: FnMut(&str) -> Option<A>,
{
let RawConfig {
keys,
mut layers,
sequences: raw_sequences,
} = toml::from_str(toml_str)?;
let mut warnings = Vec::new();
let mut built: BTreeMap<String, Keymap<A>> = BTreeMap::new();
let mut unbinds: BTreeMap<String, Vec<KeyInput>> = BTreeMap::new();
let explicit_global = layers.remove(GLOBAL_LAYER).unwrap_or_default();
let global_entries = keys.into_iter().chain(explicit_global);
let (global_keymap, global_names, global_unbinds) =
build_layer(global_entries, &mut resolve, &mut warnings)?;
built.insert(GLOBAL_LAYER.to_string(), global_keymap);
if !global_unbinds.is_empty() {
unbinds.insert(GLOBAL_LAYER.to_string(), global_unbinds);
}
for (name, raw_keys) in layers {
let (keymap, _names, layer_unbinds) = build_layer(raw_keys, &mut resolve, &mut warnings)?;
built.insert(name.clone(), keymap);
if !layer_unbinds.is_empty() {
unbinds.insert(name, layer_unbinds);
}
}
let (sequences, seq_names) = build_sequences(raw_sequences, &mut resolve, &mut warnings)?;
detect_cross_shadows(&global_names, &seq_names, &mut warnings);
Ok(BuildOutput {
layers: built,
sequences,
warnings,
unbinds,
})
}
fn build_layer<A, I, F>(
entries: I,
resolve: &mut F,
warnings: &mut Vec<Warning>,
) -> Result<LayerBuild<A>, BuildError>
where
I: IntoIterator<Item = (String, RawValue)>,
F: FnMut(&str) -> Option<A>,
{
let mut order: Vec<KeyInput> = Vec::new();
let mut groups: HashMap<KeyInput, Vec<(String, String)>> = HashMap::new();
let mut tombstone_order: Vec<KeyInput> = Vec::new();
let mut tombstone_set: std::collections::HashSet<KeyInput> = std::collections::HashSet::new();
for (raw_key, raw_value) in entries {
let chord = raw_key
.parse::<KeyInput>()
.map_err(|source| BuildError::KeyParse {
key: raw_key.clone(),
source,
})?;
match raw_value {
RawValue::Bool(false) => {
if tombstone_set.insert(chord) {
tombstone_order.push(chord);
}
}
RawValue::Bool(true) => {
return Err(BuildError::InvalidTombstone { key: raw_key });
}
RawValue::Action(action_name) => {
let entry = groups.entry(chord).or_default();
if entry.is_empty() {
order.push(chord);
}
entry.push((raw_key, action_name));
}
}
}
let mut keymap = Keymap::new();
let mut names: HashMap<KeyInput, String> = HashMap::new();
for chord in order {
let Some(entries) = groups.remove(&chord) else {
continue;
};
let mut resolved: Vec<(String, A)> = Vec::new();
for (raw_key, action_name) in entries {
match resolve(&action_name) {
Some(action) => resolved.push((action_name, action)),
None => warnings.push(Warning::UnknownAction {
key: raw_key,
action: action_name,
}),
}
}
if resolved.len() > 1 {
let contenders: Vec<String> = resolved.iter().map(|(name, _)| name.clone()).collect();
if let Some(winner) = contenders.last().cloned() {
warnings.push(Warning::Conflict {
chord: chord.to_string(),
contenders,
winner,
});
}
}
if let Some((name, action)) = resolved.pop() {
keymap.bind(chord, action);
names.insert(chord, name);
}
}
Ok((keymap, names, tombstone_order))
}
pub fn to_toml<A, F>(keymap: &Keymap<A>, sequences: &SequenceKeymap<A>, mut name_of: F) -> String
where
F: FnMut(&A) -> Option<&str>,
{
let mut root = toml::Table::new();
let keys = keymap_to_table(keymap, &mut name_of);
if !keys.is_empty() {
root.insert("keys".to_string(), toml::Value::Table(keys));
}
insert_sequences(&mut root, sequences, &mut name_of);
toml::to_string(&root).expect("string-only TOML value always serializes")
}
pub fn to_toml_layered<A, F>(
layers: &BTreeMap<String, Keymap<A>>,
sequences: &SequenceKeymap<A>,
mut name_of: F,
) -> String
where
F: FnMut(&A) -> Option<&str>,
{
to_toml_layered_impl(layers, sequences, &BTreeMap::new(), &mut name_of)
}
pub fn to_toml_layered_with_unbinds<A, F>(
layers: &BTreeMap<String, Keymap<A>>,
sequences: &SequenceKeymap<A>,
unbinds: &BTreeMap<String, Vec<KeyInput>>,
mut name_of: F,
) -> String
where
F: FnMut(&A) -> Option<&str>,
{
to_toml_layered_impl(layers, sequences, unbinds, &mut name_of)
}
fn to_toml_layered_impl<A, F>(
layers: &BTreeMap<String, Keymap<A>>,
sequences: &SequenceKeymap<A>,
unbinds: &BTreeMap<String, Vec<KeyInput>>,
name_of: &mut F,
) -> String
where
F: FnMut(&A) -> Option<&str>,
{
let mut root = toml::Table::new();
let mut named = toml::Table::new();
let mut all_layer_names: std::collections::BTreeSet<&str> =
layers.keys().map(String::as_str).collect();
for name in unbinds.keys() {
all_layer_names.insert(name.as_str());
}
for name in all_layer_names {
let mut table = if let Some(keymap) = layers.get(name) {
keymap_to_table(keymap, name_of)
} else {
toml::Table::new()
};
if let Some(layer_unbinds) = unbinds.get(name) {
for chord in layer_unbinds {
table.insert(chord.to_string(), toml::Value::Boolean(false));
}
}
if table.is_empty() {
continue;
}
if name == GLOBAL_LAYER {
root.insert("keys".to_string(), toml::Value::Table(table));
} else {
named.insert(name.to_string(), toml::Value::Table(table));
}
}
insert_sequences(&mut root, sequences, name_of);
if !named.is_empty() {
root.insert("layers".to_string(), toml::Value::Table(named));
}
toml::to_string(&root).expect("string-only TOML value always serializes")
}
#[derive(Debug)]
#[non_exhaustive]
pub struct Merged<A> {
pub output: BuildOutput<A>,
pub notes: Vec<MergeNote>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum MergeNote {
Overrode {
layer: String,
chord: String,
},
Unbound {
layer: String,
chord: String,
},
UnbindMiss {
layer: String,
chord: String,
},
DroppedSequence {
sequence: Vec<String>,
},
}
#[must_use]
pub fn merge<A: Clone>(mut base: BuildOutput<A>, overlay: BuildOutput<A>) -> Merged<A> {
let mut notes: Vec<MergeNote> = Vec::new();
base.warnings.extend(overlay.warnings);
for (layer_name, chords) in &overlay.unbinds {
for chord in chords {
let removed = base
.layers
.get_mut(layer_name)
.and_then(|layer| layer.unbind(chord));
if removed.is_some() {
notes.push(MergeNote::Unbound {
layer: layer_name.clone(),
chord: chord.to_string(),
});
} else {
notes.push(MergeNote::UnbindMiss {
layer: layer_name.clone(),
chord: chord.to_string(),
});
}
}
}
for (layer_name, overlay_keymap) in overlay.layers {
let base_layer = base.layers.entry(layer_name.clone()).or_default();
let keys: Vec<KeyInput> = overlay_keymap.iter().map(|(k, _)| *k).collect();
let mut owned_overlay = overlay_keymap;
for chord in keys {
if base_layer.contains(&chord) {
notes.push(MergeNote::Overrode {
layer: layer_name.clone(),
chord: chord.to_string(),
});
}
if let Some(action) = owned_overlay.unbind(&chord) {
base_layer.bind(chord, action);
}
}
}
let overlay_seqs: Vec<(Vec<KeyInput>, A)> = overlay
.sequences
.bindings()
.map(|(path, action)| (path.clone(), action.clone()))
.collect();
for (path, action) in overlay_seqs {
use keymap_seq::SeqBindError;
while let Err(SeqBindError::PrefixShadow { sequence, conflict }) =
base.sequences.bind(path.iter().copied(), action.clone())
{
let victim = if sequence == path { conflict } else { sequence };
notes.push(MergeNote::DroppedSequence {
sequence: render_sequence(&victim),
});
let remaining: Vec<(Vec<KeyInput>, A)> = base
.sequences
.bindings()
.filter(|(p, _)| *p != victim.as_slice())
.map(|(p, a)| (p.clone(), a.clone()))
.collect();
base.sequences = SequenceKeymap::new();
for (p, a) in remaining {
let _ = base.sequences.bind(p.iter().copied(), a);
}
}
}
Merged {
output: base,
notes,
}
}
fn keymap_to_table<A, F>(keymap: &Keymap<A>, name_of: &mut F) -> toml::Table
where
F: FnMut(&A) -> Option<&str>,
{
let mut table = toml::Table::new();
for (chord, action) in keymap.iter() {
if let Some(name) = name_of(action) {
table.insert(chord.to_string(), toml::Value::String(name.to_string()));
}
}
table
}
fn insert_sequences<A, F>(root: &mut toml::Table, sequences: &SequenceKeymap<A>, name_of: &mut F)
where
F: FnMut(&A) -> Option<&str>,
{
let mut seqs: Vec<(Vec<String>, String)> = sequences
.bindings()
.filter_map(|(path, action)| {
name_of(action).map(|name| (render_sequence(&path), name.to_string()))
})
.collect();
seqs.sort_by_key(|(chords, _)| chords.join(" "));
if seqs.is_empty() {
return;
}
let array = seqs
.into_iter()
.map(|(chords, name)| {
let mut entry = toml::Table::new();
entry.insert(
"keys".to_string(),
toml::Value::Array(chords.into_iter().map(toml::Value::String).collect()),
);
entry.insert("action".to_string(), toml::Value::String(name));
toml::Value::Table(entry)
})
.collect();
root.insert("sequences".to_string(), toml::Value::Array(array));
}
fn detect_cross_shadows(
single_names: &HashMap<KeyInput, String>,
seq_names: &HashMap<Vec<KeyInput>, String>,
warnings: &mut Vec<Warning>,
) {
let mut singles: Vec<(&KeyInput, &String)> = single_names.iter().collect();
singles.sort_by_key(|(chord, _)| chord.to_string());
for (chord, chord_action) in singles {
let mut shadowed: Vec<(&Vec<KeyInput>, &String)> = seq_names
.iter()
.filter(|(seq, _)| seq.first() == Some(chord))
.collect();
shadowed.sort_by_key(|(seq, _)| render_sequence(seq).join(" "));
if let Some((sequence, sequence_action)) = shadowed.first() {
warnings.push(Warning::SequenceShadow {
chord: chord.to_string(),
chord_action: chord_action.clone(),
sequence: render_sequence(sequence),
sequence_action: (*sequence_action).clone(),
});
}
}
}
fn build_sequences<A, F>(
raw_sequences: Vec<RawSequence>,
resolve: &mut F,
warnings: &mut Vec<Warning>,
) -> Result<SequenceBuild<A>, BuildError>
where
F: FnMut(&str) -> Option<A>,
{
let mut sequences = SequenceKeymap::new();
let mut names: HashMap<Vec<KeyInput>, String> = HashMap::new();
for raw_seq in raw_sequences {
let mut keys = Vec::with_capacity(raw_seq.keys.len());
for raw_key in &raw_seq.keys {
let chord = raw_key
.parse::<KeyInput>()
.map_err(|source| BuildError::KeyParse {
key: raw_key.clone(),
source,
})?;
keys.push(chord);
}
let Some(action) = resolve(&raw_seq.action) else {
warnings.push(Warning::UnknownAction {
key: render_sequence(&keys).join(" "),
action: raw_seq.action,
});
continue;
};
match sequences.bind(keys.iter().copied(), action) {
Ok(None) => {
names.insert(keys, raw_seq.action);
}
Ok(Some(_)) => {
let previous = names.insert(keys.clone(), raw_seq.action.clone());
warnings.push(Warning::Conflict {
chord: render_sequence(&keys).join(" "),
contenders: vec![previous.unwrap_or_default(), raw_seq.action.clone()],
winner: raw_seq.action,
});
}
Err(SeqBindError::Empty) => {
warnings.push(Warning::EmptySequence {
action: raw_seq.action,
});
}
Err(SeqBindError::PrefixShadow { sequence, conflict }) => {
let conflict_action = names.get(&conflict).cloned().unwrap_or_default();
let (prefix, prefix_action, shadowed, shadowed_action) =
if sequence.len() <= conflict.len() {
(sequence, raw_seq.action, conflict, conflict_action)
} else {
(conflict, conflict_action, sequence, raw_seq.action)
};
warnings.push(Warning::PrefixShadow {
prefix: render_sequence(&prefix),
prefix_action,
shadowed: render_sequence(&shadowed),
shadowed_action,
});
}
Err(_) => {}
}
}
Ok((sequences, names))
}
fn render_sequence(keys: &[KeyInput]) -> Vec<String> {
keys.iter().map(ToString::to_string).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use keymap_core::{Key, Modifiers};
#[derive(Debug, Clone, PartialEq)]
enum Action {
Quit,
Save,
Split,
Top,
}
fn resolver(name: &str) -> Option<Action> {
match name {
"quit" => Some(Action::Quit),
"save" => Some(Action::Save),
"split" => Some(Action::Split),
"top" => Some(Action::Top),
_ => None,
}
}
use keymap_seq::Match;
fn seq(keys: &[(char, Modifiers)]) -> Vec<KeyInput> {
keys.iter()
.map(|&(c, m)| KeyInput::new(Key::Char(c), m))
.collect()
}
#[test]
fn builds_bindings_and_resolves_actions() {
let toml = "[keys]\n\"ctrl+q\" = \"quit\"\n\"ctrl+s\" = \"save\"\n";
let out = from_str(toml, resolver).unwrap();
assert!(out.warnings.is_empty());
let q = KeyInput::new(Key::Char('q'), Modifiers::CTRL);
assert_eq!(out.global().get(&q), Some(&Action::Quit));
}
#[test]
fn bare_keys_build_the_global_layer_which_is_always_present() {
let empty: BuildOutput<Action> = from_str("", resolver).unwrap();
assert_eq!(empty.layers.keys().collect::<Vec<_>>(), vec![GLOBAL_LAYER]);
assert!(empty.global().is_empty());
let out = from_str("[keys]\n\"ctrl+q\" = \"quit\"\n", resolver).unwrap();
assert_eq!(out.layers.keys().collect::<Vec<_>>(), vec![GLOBAL_LAYER]);
}
#[test]
fn named_layers_are_parsed_under_their_names() {
let toml = "\
[keys]\n\"ctrl+q\" = \"quit\"\n\
[layers.panel]\n\"ctrl+s\" = \"split\"\n";
let out = from_str(toml, resolver).unwrap();
assert!(out.warnings.is_empty());
assert_eq!(
out.layers.keys().map(String::as_str).collect::<Vec<_>>(),
vec!["global", "panel"]
);
let q = KeyInput::new(Key::Char('q'), Modifiers::CTRL);
let s = KeyInput::new(Key::Char('s'), Modifiers::CTRL);
assert_eq!(out.global().get(&q), Some(&Action::Quit));
assert_eq!(out.layers["panel"].get(&s), Some(&Action::Split));
assert_eq!(out.global().get(&s), None);
assert_eq!(out.layers["panel"].get(&q), None);
}
#[test]
fn a_layer_with_no_keys_section_still_gets_an_empty_global() {
let toml = "[layers.panel]\n\"ctrl+s\" = \"split\"\n";
let out = from_str(toml, resolver).unwrap();
assert!(out.global().is_empty());
assert!(!out.layers["panel"].is_empty());
}
#[test]
fn same_chord_in_two_layers_is_an_override_not_a_conflict() {
let toml = "\
[keys]\n\"ctrl+s\" = \"save\"\n\
[layers.panel]\n\"ctrl+s\" = \"split\"\n";
let out = from_str(toml, resolver).unwrap();
assert!(
out.warnings.is_empty(),
"cross-layer override must not warn: {:?}",
out.warnings
);
let s = KeyInput::new(Key::Char('s'), Modifiers::CTRL);
assert_eq!(out.global().get(&s), Some(&Action::Save));
assert_eq!(out.layers["panel"].get(&s), Some(&Action::Split));
}
#[test]
fn explicit_global_layer_merges_into_the_top_level_keys() {
let toml = "\
[keys]\n\"ctrl+q\" = \"quit\"\n\
[layers.global]\n\"ctrl+s\" = \"save\"\n";
let out = from_str(toml, resolver).unwrap();
assert!(out.warnings.is_empty());
assert_eq!(out.layers.keys().collect::<Vec<_>>(), vec![GLOBAL_LAYER]);
let q = KeyInput::new(Key::Char('q'), Modifiers::CTRL);
let s = KeyInput::new(Key::Char('s'), Modifiers::CTRL);
assert_eq!(out.global().get(&q), Some(&Action::Quit));
assert_eq!(out.global().get(&s), Some(&Action::Save));
}
#[test]
fn keys_and_explicit_global_colliding_on_a_chord_conflict_last_wins() {
let toml = "\
[keys]\n\"ctrl+q\" = \"quit\"\n\
[layers.global]\n\"ctrl+q\" = \"save\"\n";
let out = from_str(toml, resolver).unwrap();
assert_eq!(
out.warnings,
vec![Warning::Conflict {
chord: "ctrl+q".to_string(),
contenders: vec!["quit".to_string(), "save".to_string()],
winner: "save".to_string(),
}]
);
let q = KeyInput::new(Key::Char('q'), Modifiers::CTRL);
assert_eq!(out.global().get(&q), Some(&Action::Save));
}
#[test]
fn conflict_within_a_named_layer_is_reported() {
let toml = "\
[layers.panel]\n\"ctrl+a\" = \"quit\"\n\"control+a\" = \"save\"\n";
let out = from_str(toml, resolver).unwrap();
assert_eq!(
out.warnings,
vec![Warning::Conflict {
chord: "ctrl+a".to_string(),
contenders: vec!["save".to_string(), "quit".to_string()],
winner: "quit".to_string(),
}]
);
}
#[test]
fn cross_shadow_is_checked_against_global_only() {
let toml = "\
[layers.panel]\n\"j\" = \"top\"\n\
[[sequences]]\nkeys = [\"j\", \"k\"]\naction = \"save\"\n";
let out = from_str(toml, resolver).unwrap();
assert!(
out.warnings.is_empty(),
"a non-global chord must not cross-shadow a global sequence: {:?}",
out.warnings
);
}
#[test]
fn unknown_action_in_a_named_layer_is_a_warning_carrying_no_layer_name() {
let toml = "[layers.panel]\n\"ctrl+z\" = \"undo\"\n";
let out = from_str(toml, resolver).unwrap();
assert_eq!(
out.warnings,
vec![Warning::UnknownAction {
key: "ctrl+z".to_string(),
action: "undo".to_string(),
}]
);
assert!(out.layers["panel"].is_empty());
}
#[test]
fn malformed_key_in_a_named_layer_is_a_fatal_error() {
let toml = "[layers.panel]\n\"ctrl+nope\" = \"quit\"\n";
let err = from_str(toml, resolver).unwrap_err();
assert!(matches!(err, BuildError::KeyParse { .. }));
}
#[test]
fn sequences_do_not_create_extra_layers() {
let toml = "\
[keys]\n\"ctrl+q\" = \"quit\"\n\
[[sequences]]\nkeys = [\"ctrl+x\", \"ctrl+s\"]\naction = \"save\"\n";
let out = from_str(toml, resolver).unwrap();
assert!(out.warnings.is_empty());
assert_eq!(out.layers.keys().collect::<Vec<_>>(), vec![GLOBAL_LAYER]);
assert!(!out.sequences.is_empty());
}
#[test]
fn unknown_actions_across_layers_warn_global_first_then_name_order() {
let toml = "\
[keys]\n\"a\" = \"nope_global\"\n\
[layers.zeta]\n\"b\" = \"nope_zeta\"\n\
[layers.alpha]\n\"c\" = \"nope_alpha\"\n";
let out = from_str(toml, resolver).unwrap();
let unknown_actions: Vec<&str> = out
.warnings
.iter()
.filter_map(|w| match w {
Warning::UnknownAction { action, .. } => Some(action.as_str()),
_ => None,
})
.collect();
assert_eq!(
unknown_actions,
vec!["nope_global", "nope_alpha", "nope_zeta"]
);
}
#[test]
fn unknown_top_level_field_is_a_fatal_error() {
let err = from_str("[kesy]\n\"ctrl+q\" = \"quit\"\n", resolver).unwrap_err();
assert!(matches!(err, BuildError::Toml(_)));
}
#[test]
fn unknown_sequence_field_is_a_fatal_error() {
let toml = "[[sequences]]\nkeys = [\"g\"]\naction = \"top\"\nlayer = \"panel\"\n";
let err = from_str(toml, resolver).unwrap_err();
assert!(matches!(err, BuildError::Toml(_)));
}
#[test]
fn unknown_action_is_a_warning_not_a_failure() {
let toml = "[keys]\n\"ctrl+q\" = \"quit\"\n\"ctrl+z\" = \"undo\"\n";
let out = from_str(toml, resolver).unwrap();
let q = KeyInput::new(Key::Char('q'), Modifiers::CTRL);
assert_eq!(out.global().get(&q), Some(&Action::Quit));
assert_eq!(
out.warnings,
vec![Warning::UnknownAction {
key: "ctrl+z".to_string(),
action: "undo".to_string(),
}]
);
}
#[test]
fn distinct_spellings_of_same_chord_conflict() {
let toml = "[keys]\n\"ctrl+a\" = \"quit\"\n\"control+a\" = \"save\"\n";
let out = from_str(toml, resolver).unwrap();
let a = KeyInput::new(Key::Char('a'), Modifiers::CTRL);
assert!(out.global().get(&a).is_some());
let conflicts: Vec<_> = out
.warnings
.iter()
.filter(|w| matches!(w, Warning::Conflict { .. }))
.collect();
assert_eq!(conflicts.len(), 1);
}
#[test]
fn legacy_lints_are_opt_in_and_separate_from_warnings() {
let toml = "[keys]\n\"cmd+s\" = \"save\"\n";
let out = from_str(toml, resolver).unwrap();
assert!(out.warnings.is_empty());
assert_eq!(
keymap_core::legacy_lints(out.global()),
vec![keymap_core::LegacyLint::Unrepresentable {
chord: "super+s".to_string(),
}]
);
}
#[test]
fn malformed_key_is_a_fatal_error() {
let toml = "[keys]\n\"ctrl+nope\" = \"quit\"\n";
let err = from_str(toml, resolver).unwrap_err();
assert!(matches!(err, BuildError::KeyParse { .. }));
}
#[test]
fn malformed_toml_is_a_fatal_error() {
let err = from_str("this is not toml", resolver).unwrap_err();
assert!(matches!(err, BuildError::Toml(_)));
}
#[test]
fn empty_config_builds_empty_map() {
let out: BuildOutput<Action> = from_str("", resolver).unwrap();
assert!(out.global().is_empty());
assert!(out.sequences.is_empty());
assert!(out.warnings.is_empty());
}
#[test]
fn builds_sequence_bindings() {
let toml = "\
[keys]\n\"ctrl+q\" = \"quit\"\n\
[[sequences]]\nkeys = [\"ctrl+x\", \"ctrl+s\"]\naction = \"save\"\n\
[[sequences]]\nkeys = [\"ctrl+x\", \"ctrl+c\"]\naction = \"quit\"\n";
let out = from_str(toml, resolver).unwrap();
assert!(out.warnings.is_empty());
let q = KeyInput::new(Key::Char('q'), Modifiers::CTRL);
assert_eq!(out.global().get(&q), Some(&Action::Quit));
let save = seq(&[('x', Modifiers::CTRL), ('s', Modifiers::CTRL)]);
assert_eq!(out.sequences.lookup(&save), Match::Exact(&Action::Save));
assert_eq!(out.sequences.lookup(&save[..1]), Match::Prefix);
}
#[test]
fn sequence_unknown_action_is_a_warning() {
let toml = "[[sequences]]\nkeys = [\"ctrl+x\", \"ctrl+z\"]\naction = \"undo\"\n";
let out = from_str(toml, resolver).unwrap();
assert!(out.sequences.is_empty());
assert_eq!(
out.warnings,
vec![Warning::UnknownAction {
key: "ctrl+x ctrl+z".to_string(),
action: "undo".to_string(),
}]
);
}
#[test]
fn sequence_prefix_shadow_is_a_warning_and_drops_the_later() {
let toml = "\
[[sequences]]\nkeys = [\"g\"]\naction = \"top\"\n\
[[sequences]]\nkeys = [\"g\", \"g\"]\naction = \"split\"\n";
let out = from_str(toml, resolver).unwrap();
assert_eq!(
out.sequences.lookup(&seq(&[('g', Modifiers::NONE)])),
Match::Exact(&Action::Top)
);
assert_eq!(
out.warnings,
vec![Warning::PrefixShadow {
prefix: vec!["g".to_string()],
prefix_action: "top".to_string(),
shadowed: vec!["g".to_string(), "g".to_string()],
shadowed_action: "split".to_string(),
}]
);
}
#[test]
fn sequence_prefix_shadow_reverse_order_drops_the_later_short_one() {
let toml = "\
[[sequences]]\nkeys = [\"g\", \"g\"]\naction = \"split\"\n\
[[sequences]]\nkeys = [\"g\"]\naction = \"top\"\n";
let out = from_str(toml, resolver).unwrap();
assert_eq!(
out.sequences
.lookup(&seq(&[('g', Modifiers::NONE), ('g', Modifiers::NONE)])),
Match::Exact(&Action::Split)
);
assert_eq!(
out.warnings,
vec![Warning::PrefixShadow {
prefix: vec!["g".to_string()],
prefix_action: "top".to_string(),
shadowed: vec!["g".to_string(), "g".to_string()],
shadowed_action: "split".to_string(),
}]
);
}
#[test]
fn same_sequence_three_times_reports_pairwise_conflicts() {
let toml = "\
[[sequences]]\nkeys = [\"ctrl+x\"]\naction = \"save\"\n\
[[sequences]]\nkeys = [\"ctrl+x\"]\naction = \"split\"\n\
[[sequences]]\nkeys = [\"ctrl+x\"]\naction = \"quit\"\n";
let out = from_str(toml, resolver).unwrap();
assert_eq!(
out.sequences.lookup(&seq(&[('x', Modifiers::CTRL)])),
Match::Exact(&Action::Quit)
);
assert_eq!(
out.warnings,
vec![
Warning::Conflict {
chord: "ctrl+x".to_string(),
contenders: vec!["save".to_string(), "split".to_string()],
winner: "split".to_string(),
},
Warning::Conflict {
chord: "ctrl+x".to_string(),
contenders: vec!["split".to_string(), "quit".to_string()],
winner: "quit".to_string(),
},
]
);
}
#[test]
fn empty_sequence_is_a_warning() {
let toml = "[[sequences]]\nkeys = []\naction = \"save\"\n";
let out = from_str(toml, resolver).unwrap();
assert!(out.sequences.is_empty());
assert_eq!(
out.warnings,
vec![Warning::EmptySequence {
action: "save".to_string(),
}]
);
}
#[test]
fn duplicate_sequence_conflicts_and_last_wins() {
let toml = "\
[[sequences]]\nkeys = [\"ctrl+x\", \"ctrl+s\"]\naction = \"save\"\n\
[[sequences]]\nkeys = [\"ctrl+x\", \"ctrl+s\"]\naction = \"split\"\n";
let out = from_str(toml, resolver).unwrap();
let s = seq(&[('x', Modifiers::CTRL), ('s', Modifiers::CTRL)]);
assert_eq!(out.sequences.lookup(&s), Match::Exact(&Action::Split));
assert_eq!(
out.warnings,
vec![Warning::Conflict {
chord: "ctrl+x ctrl+s".to_string(),
contenders: vec!["save".to_string(), "split".to_string()],
winner: "split".to_string(),
}]
);
}
#[test]
fn malformed_sequence_key_is_a_fatal_error() {
let toml = "[[sequences]]\nkeys = [\"ctrl+x\", \"ctrl+nope\"]\naction = \"save\"\n";
let err = from_str(toml, resolver).unwrap_err();
assert!(matches!(err, BuildError::KeyParse { .. }));
}
#[test]
fn single_chord_shadowing_a_sequence_is_an_advisory_warning() {
let toml = "\
[keys]\n\"j\" = \"top\"\n\
[[sequences]]\nkeys = [\"j\", \"k\"]\naction = \"save\"\n";
let out = from_str(toml, resolver).unwrap();
assert_eq!(
out.global()
.get(&KeyInput::new(Key::Char('j'), Modifiers::NONE)),
Some(&Action::Top)
);
assert_eq!(
out.sequences
.lookup(&seq(&[('j', Modifiers::NONE), ('k', Modifiers::NONE)])),
Match::Exact(&Action::Save)
);
assert_eq!(
out.warnings,
vec![Warning::SequenceShadow {
chord: "j".to_string(),
chord_action: "top".to_string(),
sequence: vec!["j".to_string(), "k".to_string()],
sequence_action: "save".to_string(),
}]
);
}
#[test]
fn length_one_sequence_equal_to_a_single_chord_shadows() {
let toml = "\
[keys]\n\"q\" = \"quit\"\n\
[[sequences]]\nkeys = [\"q\"]\naction = \"save\"\n";
let out = from_str(toml, resolver).unwrap();
assert_eq!(
out.warnings,
vec![Warning::SequenceShadow {
chord: "q".to_string(),
chord_action: "quit".to_string(),
sequence: vec!["q".to_string()],
sequence_action: "save".to_string(),
}]
);
}
#[test]
fn disjoint_first_keys_do_not_shadow() {
let toml = "\
[keys]\n\"j\" = \"top\"\n\
[[sequences]]\nkeys = [\"g\", \"g\"]\naction = \"save\"\n";
let out = from_str(toml, resolver).unwrap();
assert!(out.warnings.is_empty());
}
#[test]
fn one_chord_shadowing_several_sequences_reports_the_lex_first() {
let toml = "\
[keys]\n\"j\" = \"top\"\n\
[[sequences]]\nkeys = [\"j\", \"k\"]\naction = \"save\"\n\
[[sequences]]\nkeys = [\"j\", \"a\"]\naction = \"quit\"\n";
let out = from_str(toml, resolver).unwrap();
assert_eq!(
out.warnings,
vec![Warning::SequenceShadow {
chord: "j".to_string(),
chord_action: "top".to_string(),
sequence: vec!["j".to_string(), "a".to_string()],
sequence_action: "quit".to_string(),
}]
);
}
#[test]
fn multiple_shadowing_chords_emit_in_canonical_chord_order() {
let toml = "\
[keys]\n\"z\" = \"top\"\n\"j\" = \"quit\"\n\
[[sequences]]\nkeys = [\"z\", \"x\"]\naction = \"save\"\n\
[[sequences]]\nkeys = [\"j\", \"k\"]\naction = \"split\"\n";
let out = from_str(toml, resolver).unwrap();
assert_eq!(
out.warnings,
vec![
Warning::SequenceShadow {
chord: "j".to_string(),
chord_action: "quit".to_string(),
sequence: vec!["j".to_string(), "k".to_string()],
sequence_action: "split".to_string(),
},
Warning::SequenceShadow {
chord: "z".to_string(),
chord_action: "top".to_string(),
sequence: vec!["z".to_string(), "x".to_string()],
sequence_action: "save".to_string(),
},
]
);
}
#[test]
fn unknown_single_chord_does_not_shadow() {
let toml = "\
[keys]\n\"j\" = \"nonexistent\"\n\
[[sequences]]\nkeys = [\"j\", \"k\"]\naction = \"save\"\n";
let out = from_str(toml, resolver).unwrap();
assert_eq!(
out.warnings,
vec![Warning::UnknownAction {
key: "j".to_string(),
action: "nonexistent".to_string(),
}]
);
}
#[test]
fn cross_shadow_coexists_with_conflict_and_comes_last() {
let toml = "\
[keys]\n\"ctrl+a\" = \"quit\"\n\"control+a\" = \"save\"\n\"j\" = \"top\"\n\
[[sequences]]\nkeys = [\"j\", \"k\"]\naction = \"split\"\n";
let out = from_str(toml, resolver).unwrap();
assert_eq!(
out.warnings,
vec![
Warning::Conflict {
chord: "ctrl+a".to_string(),
contenders: vec!["save".to_string(), "quit".to_string()],
winner: "quit".to_string(),
},
Warning::SequenceShadow {
chord: "j".to_string(),
chord_action: "top".to_string(),
sequence: vec!["j".to_string(), "k".to_string()],
sequence_action: "split".to_string(),
},
]
);
}
#[test]
fn chord_matching_a_non_first_sequence_key_does_not_shadow() {
let toml = "\
[keys]\n\"j\" = \"top\"\n\
[[sequences]]\nkeys = [\"g\", \"j\"]\naction = \"save\"\n";
let out = from_str(toml, resolver).unwrap();
assert!(out.warnings.is_empty());
}
fn km_pairs(km: &Keymap<String>) -> Vec<(KeyInput, String)> {
let mut v: Vec<_> = km.iter().map(|(k, a)| (*k, a.clone())).collect();
v.sort_by_key(|(k, _)| k.to_string());
v
}
fn seq_pairs(s: &SequenceKeymap<String>) -> Vec<(Vec<KeyInput>, String)> {
let mut v: Vec<_> = s.bindings().map(|(p, a)| (p, a.clone())).collect();
v.sort_by_key(|(p, _)| render_sequence(p).join(" "));
v
}
fn assert_round_trips(km: &Keymap<String>, seq: &SequenceKeymap<String>) {
let toml = to_toml(km, seq, |a: &String| Some(a.as_str()));
let out = from_str(&toml, |name: &str| Some(name.to_owned())).unwrap();
assert!(
out.warnings.is_empty(),
"round-trip warned: {:?}",
out.warnings
);
assert_eq!(km_pairs(km), km_pairs(out.global()));
assert_eq!(seq_pairs(seq), seq_pairs(&out.sequences));
}
fn norm(key: Key, mods: Modifiers) -> KeyInput {
KeyInput::normalized(key, mods)
}
#[test]
fn to_toml_round_trips_keys_and_sequences() {
let mut km = Keymap::new();
km.bind(norm(Key::Char('q'), Modifiers::CTRL), "quit".to_owned());
km.bind(norm(Key::Char('s'), Modifiers::CTRL), "save".to_owned());
km.bind(norm(Key::Char('a'), Modifiers::SHIFT), "all".to_owned());
km.bind(
norm(Key::Char('a'), Modifiers::CTRL | Modifiers::SHIFT),
"alt_all".to_owned(),
);
let mut seq = SequenceKeymap::new();
seq.bind(
[
norm(Key::Char('x'), Modifiers::CTRL),
norm(Key::Char('s'), Modifiers::CTRL),
],
"seq_save".to_owned(),
)
.unwrap();
seq.bind(
[
norm(Key::Char('g'), Modifiers::NONE),
norm(Key::Char('g'), Modifiers::NONE),
],
"top".to_owned(),
)
.unwrap();
assert_round_trips(&km, &seq);
}
#[test]
fn to_toml_is_deterministic_and_canonically_ordered() {
let mut km = Keymap::new();
km.bind(norm(Key::Char('z'), Modifiers::CTRL), "z".to_owned());
km.bind(norm(Key::Char('a'), Modifiers::CTRL), "a".to_owned());
let seq = SequenceKeymap::new();
let first = to_toml(&km, &seq, |a: &String| Some(a.as_str()));
let second = to_toml(&km, &seq, |a: &String| Some(a.as_str()));
assert_eq!(first, second, "output must be deterministic");
let a_at = first.find("ctrl+a").unwrap();
let z_at = first.find("ctrl+z").unwrap();
assert!(
a_at < z_at,
"keys must be emitted in canonical order:\n{first}"
);
}
#[test]
fn to_toml_omits_unnameable_bindings() {
let mut km = Keymap::new();
km.bind(norm(Key::Char('q'), Modifiers::CTRL), "quit".to_owned());
km.bind(norm(Key::Char('x'), Modifiers::CTRL), "secret".to_owned());
let seq = SequenceKeymap::new();
let toml = to_toml(&km, &seq, |a: &String| {
(a != "secret").then_some(a.as_str())
});
let out = from_str(&toml, |name: &str| Some(name.to_owned())).unwrap();
assert_eq!(
out.global().get(&norm(Key::Char('q'), Modifiers::CTRL)),
Some(&"quit".to_owned())
);
assert_eq!(
out.global().get(&norm(Key::Char('x'), Modifiers::CTRL)),
None
);
}
#[test]
fn to_toml_empty_maps_emit_empty_string() {
let km: Keymap<String> = Keymap::new();
let seq: SequenceKeymap<String> = SequenceKeymap::new();
assert_eq!(to_toml(&km, &seq, |a: &String| Some(a.as_str())), "");
}
#[test]
fn to_toml_round_trips_adversarial_names_and_chords() {
let mut km = Keymap::new();
km.bind(
norm(Key::Char('a'), Modifiers::NONE),
"quit\"; [injected]\nx = \"oops".to_owned(),
);
km.bind(norm(Key::Char('+'), Modifiers::NONE), "plus".to_owned());
km.bind(norm(Key::Char(' '), Modifiers::NONE), "space".to_owned());
km.bind(norm(Key::Char('"'), Modifiers::NONE), "quote".to_owned());
km.bind(norm(Key::Char('あ'), Modifiers::NONE), "hira".to_owned());
km.bind(norm(Key::Char('F'), Modifiers::NONE), "cap_f".to_owned());
let mut seq = SequenceKeymap::new();
seq.bind(
[
norm(Key::Char('z'), Modifiers::NONE),
norm(Key::Char(' '), Modifiers::NONE),
norm(Key::Char('+'), Modifiers::NONE),
],
"z_space_plus".to_owned(),
)
.unwrap();
assert_round_trips(&km, &seq);
}
#[test]
fn shadow_matching_is_on_the_parsed_chord_not_the_label() {
let toml = "\
[keys]\n\"ctrl+x\" = \"top\"\n\
[[sequences]]\nkeys = [\"ctrl+x\", \"ctrl+s\"]\naction = \"save\"\n";
let out = from_str(toml, resolver).unwrap();
assert_eq!(
out.warnings,
vec![Warning::SequenceShadow {
chord: "ctrl+x".to_string(),
chord_action: "top".to_string(),
sequence: vec!["ctrl+x".to_string(), "ctrl+s".to_string()],
sequence_action: "save".to_string(),
}]
);
let toml = "\
[keys]\n\"ctrl+x\" = \"top\"\n\
[[sequences]]\nkeys = [\"ctrl+shift+x\", \"ctrl+s\"]\naction = \"save\"\n";
let out = from_str(toml, resolver).unwrap();
assert!(out.warnings.is_empty());
}
#[test]
fn to_toml_layered_round_trips_named_layers() {
let mut global = Keymap::new();
global.bind(norm(Key::Char('q'), Modifiers::CTRL), "quit".to_owned());
let mut panel = Keymap::new();
panel.bind(norm(Key::Char('s'), Modifiers::CTRL), "split".to_owned());
panel.bind(
norm(Key::Char('q'), Modifiers::CTRL),
"panel_quit".to_owned(),
);
let mut layers = BTreeMap::new();
layers.insert(GLOBAL_LAYER.to_string(), global);
layers.insert("panel".to_string(), panel);
let mut seq = SequenceKeymap::new();
seq.bind(
[
norm(Key::Char('x'), Modifiers::CTRL),
norm(Key::Char('s'), Modifiers::CTRL),
],
"seq_save".to_owned(),
)
.unwrap();
let toml = to_toml_layered(&layers, &seq, |a: &String| Some(a.as_str()));
let out = from_str(&toml, |name: &str| Some(name.to_owned())).unwrap();
assert!(
out.warnings.is_empty(),
"round-trip warned: {:?}",
out.warnings
);
assert_eq!(km_pairs(&layers["global"]), km_pairs(out.global()));
assert_eq!(km_pairs(&layers["panel"]), km_pairs(&out.layers["panel"]));
assert_eq!(seq_pairs(&seq), seq_pairs(&out.sequences));
}
#[test]
fn to_toml_layered_matches_to_toml_for_a_global_only_set() {
let mut global = Keymap::new();
global.bind(norm(Key::Char('q'), Modifiers::CTRL), "quit".to_owned());
global.bind(norm(Key::Char('s'), Modifiers::CTRL), "save".to_owned());
let mut seq = SequenceKeymap::new();
seq.bind(
[
norm(Key::Char('g'), Modifiers::NONE),
norm(Key::Char('g'), Modifiers::NONE),
],
"top".to_owned(),
)
.unwrap();
let mut layers = BTreeMap::new();
layers.insert(GLOBAL_LAYER.to_string(), global.clone());
let plain = to_toml(&global, &seq, |a: &String| Some(a.as_str()));
let layered = to_toml_layered(&layers, &seq, |a: &String| Some(a.as_str()));
assert_eq!(plain, layered);
let empty_layers: BTreeMap<String, Keymap<String>> = BTreeMap::new();
assert_eq!(
to_toml_layered(&empty_layers, &SequenceKeymap::new(), |a: &String| Some(
a.as_str()
)),
""
);
}
#[test]
fn to_toml_layered_drops_empty_layers() {
let mut global = Keymap::new();
global.bind(norm(Key::Char('q'), Modifiers::CTRL), "quit".to_owned());
let mut layers = BTreeMap::new();
layers.insert(GLOBAL_LAYER.to_string(), global);
layers.insert("panel".to_string(), Keymap::<String>::new());
let toml = to_toml_layered(&layers, &SequenceKeymap::new(), |a: &String| {
Some(a.as_str())
});
assert!(
!toml.contains("panel"),
"empty layer must not be emitted:\n{toml}"
);
let out = from_str(&toml, |name: &str| Some(name.to_owned())).unwrap();
assert_eq!(out.layers.keys().collect::<Vec<_>>(), vec![GLOBAL_LAYER]);
}
#[test]
fn tombstone_false_was_build_error_in_0_1_0_now_accepted() {
let toml = "[keys]\n\"ctrl+q\" = false\n";
let out = from_str(toml, resolver).unwrap();
let q = KeyInput::new(Key::Char('q'), Modifiers::CTRL);
assert_eq!(out.global().get(&q), None);
let global_unbinds = out
.unbinds
.get(GLOBAL_LAYER)
.expect("global unbinds present");
assert!(global_unbinds.contains(&q));
assert!(out.warnings.is_empty());
}
#[test]
fn tombstone_true_is_still_an_error() {
let toml = "[keys]\n\"ctrl+q\" = true\n";
let err = from_str(toml, resolver).unwrap_err();
assert!(
matches!(err, BuildError::InvalidTombstone { .. }),
"expected InvalidTombstone, got {err:?}"
);
}
#[test]
fn tombstone_in_named_layer_lands_in_unbinds_not_keymap() {
let toml = "[layers.panel]\n\"ctrl+s\" = false\n";
let out = from_str(toml, resolver).unwrap();
let s = KeyInput::new(Key::Char('s'), Modifiers::CTRL);
assert!(out.layers["panel"].is_empty());
let panel_unbinds = out.unbinds.get("panel").expect("panel unbinds present");
assert!(panel_unbinds.contains(&s));
}
#[test]
fn tombstone_coexists_with_bindings_in_same_layer() {
let toml = "[keys]\n\"ctrl+q\" = \"quit\"\n\"ctrl+s\" = false\n";
let out = from_str(toml, resolver).unwrap();
let q = KeyInput::new(Key::Char('q'), Modifiers::CTRL);
let s = KeyInput::new(Key::Char('s'), Modifiers::CTRL);
assert_eq!(out.global().get(&q), Some(&Action::Quit));
assert_eq!(out.global().get(&s), None);
let global_unbinds = out.unbinds.get(GLOBAL_LAYER).unwrap();
assert!(global_unbinds.contains(&s));
}
#[test]
fn empty_config_has_empty_unbinds() {
let out: BuildOutput<Action> =
from_str("[keys]\n\"ctrl+q\" = \"quit\"\n", resolver).unwrap();
assert!(out.unbinds.is_empty());
}
#[test]
fn to_toml_layered_with_empty_unbinds_matches_0_1_0_behavior() {
let mut global = Keymap::new();
global.bind(norm(Key::Char('q'), Modifiers::CTRL), "quit".to_owned());
let mut layers = BTreeMap::new();
layers.insert(GLOBAL_LAYER.to_string(), global.clone());
let seq = SequenceKeymap::new();
let empty_unbinds: BTreeMap<String, Vec<KeyInput>> = BTreeMap::new();
let plain = to_toml_layered(&layers, &seq, |a: &String| Some(a.as_str()));
let with_empty =
to_toml_layered_with_unbinds(&layers, &seq, &empty_unbinds, |a: &String| {
Some(a.as_str())
});
assert_eq!(
plain, with_empty,
"empty unbinds must produce byte-identical output to to_toml_layered"
);
}
#[test]
fn to_toml_layered_emits_tombstones_and_round_trips() {
let global: Keymap<String> = Keymap::new(); let mut layers = BTreeMap::new();
layers.insert(GLOBAL_LAYER.to_string(), global);
let ctrl_s = norm(Key::Char('s'), Modifiers::CTRL);
let mut unbinds: BTreeMap<String, Vec<KeyInput>> = BTreeMap::new();
unbinds.insert(GLOBAL_LAYER.to_string(), vec![ctrl_s]);
let toml_str = to_toml_layered_with_unbinds(
&layers,
&SequenceKeymap::new(),
&unbinds,
|a: &String| Some(a.as_str()),
);
assert!(
toml_str.contains("false"),
"tombstone must be emitted as `= false`:\n{toml_str}"
);
let out = from_str(&toml_str, |name: &str| Some(name.to_owned())).unwrap();
let global_unbinds = out
.unbinds
.get(GLOBAL_LAYER)
.expect("global unbinds present");
assert!(global_unbinds.contains(&ctrl_s));
assert!(out.warnings.is_empty());
}
#[test]
fn to_toml_layered_tombstone_injection_safe() {
let space = norm(Key::Char(' '), Modifiers::NONE);
let layers: BTreeMap<String, Keymap<String>> = BTreeMap::new();
let mut unbinds: BTreeMap<String, Vec<KeyInput>> = BTreeMap::new();
unbinds.insert(GLOBAL_LAYER.to_string(), vec![space]);
let toml_str = to_toml_layered_with_unbinds(
&layers,
&SequenceKeymap::new(),
&unbinds,
|a: &String| Some(a.as_str()),
);
let out = from_str(&toml_str, |name: &str| Some(name.to_owned())).unwrap();
let global_unbinds = out
.unbinds
.get(GLOBAL_LAYER)
.expect("global unbinds present");
assert!(global_unbinds.contains(&space));
}
fn make_output(toml: &str) -> BuildOutput<String> {
from_str(toml, |name: &str| Some(name.to_owned())).unwrap()
}
#[test]
fn merge_overlay_chord_wins_silently_with_override_note() {
let base = make_output("[keys]\n\"ctrl+s\" = \"save_base\"\n");
let overlay = make_output("[keys]\n\"ctrl+s\" = \"save_overlay\"\n");
let merged = merge(base, overlay);
let s = norm(Key::Char('s'), Modifiers::CTRL);
assert_eq!(
merged.output.global().get(&s),
Some(&"save_overlay".to_owned()),
"overlay wins"
);
assert!(
merged.output.warnings.is_empty(),
"override must not produce a Warning"
);
assert!(
merged.notes.iter().any(|n| matches!(n,
MergeNote::Overrode { layer, chord }
if layer == GLOBAL_LAYER && chord == "ctrl+s"
)),
"Overrode note must be emitted: {:?}",
merged.notes
);
}
#[test]
fn merge_layer_union_adds_layers_from_overlay() {
let base = make_output("[keys]\n\"ctrl+q\" = \"quit\"\n");
let overlay = make_output("[layers.panel]\n\"ctrl+s\" = \"split\"\n");
let merged = merge(base, overlay);
assert!(
merged.output.layers.contains_key("panel"),
"panel layer added"
);
let s = norm(Key::Char('s'), Modifiers::CTRL);
assert_eq!(
merged.output.layers["panel"].get(&s),
Some(&"split".to_owned())
);
assert!(
merged.notes.is_empty(),
"no notes for pure add: {:?}",
merged.notes
);
}
#[test]
fn merge_sequence_exact_overlay_wins() {
let base =
make_output("[[sequences]]\nkeys = [\"ctrl+x\", \"ctrl+s\"]\naction = \"save_base\"\n");
let overlay = make_output(
"[[sequences]]\nkeys = [\"ctrl+x\", \"ctrl+s\"]\naction = \"save_overlay\"\n",
);
let merged = merge(base, overlay);
let xs = vec![
norm(Key::Char('x'), Modifiers::CTRL),
norm(Key::Char('s'), Modifiers::CTRL),
];
assert_eq!(
merged.output.sequences.lookup(&xs),
keymap_seq::Match::Exact(&"save_overlay".to_owned()),
"overlay sequence wins"
);
assert!(merged.output.warnings.is_empty());
}
#[test]
fn merge_sequence_prefix_clash_drops_base_with_note() {
let base = make_output("[[sequences]]\nkeys = [\"g\", \"g\"]\naction = \"top_base\"\n");
let overlay = make_output("[[sequences]]\nkeys = [\"g\"]\naction = \"top_overlay\"\n");
let merged = merge(base, overlay);
let g = vec![norm(Key::Char('g'), Modifiers::NONE)];
assert_eq!(
merged.output.sequences.lookup(&g),
keymap_seq::Match::Exact(&"top_overlay".to_owned()),
"overlay wins"
);
assert!(
merged
.notes
.iter()
.any(|n| matches!(n, MergeNote::DroppedSequence { .. })),
"DroppedSequence note must be emitted: {:?}",
merged.notes
);
}
#[test]
fn merge_unbind_removes_from_base_with_unbound_note() {
let base = make_output("[keys]\n\"ctrl+s\" = \"save\"\n");
let overlay = make_output("[keys]\n\"ctrl+s\" = false\n");
let merged = merge(base, overlay);
let s = norm(Key::Char('s'), Modifiers::CTRL);
assert_eq!(
merged.output.global().get(&s),
None,
"chord removed from base"
);
assert!(
merged.notes.iter().any(|n| matches!(n,
MergeNote::Unbound { layer, chord }
if layer == GLOBAL_LAYER && chord == "ctrl+s"
)),
"Unbound note must be emitted: {:?}",
merged.notes
);
assert!(merged.output.warnings.is_empty());
}
#[test]
fn merge_unbind_of_absent_chord_produces_miss_note() {
let base = make_output("[keys]\n\"ctrl+q\" = \"quit\"\n");
let overlay = make_output("[keys]\n\"ctrl+s\" = false\n");
let merged = merge(base, overlay);
assert!(
merged.notes.iter().any(|n| matches!(n,
MergeNote::UnbindMiss { chord, .. }
if chord == "ctrl+s"
)),
"UnbindMiss note expected: {:?}",
merged.notes
);
let q = norm(Key::Char('q'), Modifiers::CTRL);
assert_eq!(merged.output.global().get(&q), Some(&"quit".to_owned()));
}
#[test]
fn merge_warnings_are_concatenated_not_mixed_into_notes() {
let base = from_str("[keys]\n\"ctrl+z\" = \"nope_base\"\n", resolver).unwrap();
let overlay = from_str("[keys]\n\"ctrl+y\" = \"nope_overlay\"\n", resolver).unwrap();
assert_eq!(base.warnings.len(), 1);
assert_eq!(overlay.warnings.len(), 1);
let merged = merge(base, overlay);
assert_eq!(merged.output.warnings.len(), 2, "both warnings carried");
assert!(merged.notes.is_empty());
}
#[test]
fn warning_kind_covers_all_variants() {
let conflict = Warning::Conflict {
chord: "ctrl+a".to_string(),
contenders: vec!["quit".to_string(), "save".to_string()],
winner: "save".to_string(),
};
assert_eq!(conflict.kind(), WarningKind::Conflict);
let unknown = Warning::UnknownAction {
key: "ctrl+z".to_string(),
action: "undo".to_string(),
};
assert_eq!(unknown.kind(), WarningKind::UnknownAction);
let prefix = Warning::PrefixShadow {
prefix: vec!["g".to_string()],
prefix_action: "top".to_string(),
shadowed: vec!["g".to_string(), "g".to_string()],
shadowed_action: "top2".to_string(),
};
assert_eq!(prefix.kind(), WarningKind::PrefixShadow);
let empty = Warning::EmptySequence {
action: "quit".to_string(),
};
assert_eq!(empty.kind(), WarningKind::EmptySequence);
let shadow = Warning::SequenceShadow {
chord: "j".to_string(),
chord_action: "down".to_string(),
sequence: vec!["j".to_string(), "k".to_string()],
sequence_action: "top".to_string(),
};
assert_eq!(shadow.kind(), WarningKind::SequenceShadow);
}
#[test]
fn warning_display_is_human_readable() {
let conflict = Warning::Conflict {
chord: "ctrl+a".to_string(),
contenders: vec!["quit".to_string(), "save".to_string()],
winner: "save".to_string(),
};
let s = conflict.to_string();
assert!(
s.contains("ctrl+a"),
"conflict display must mention the chord: {s}"
);
assert!(
s.contains("save"),
"conflict display must mention the winner: {s}"
);
let unknown = Warning::UnknownAction {
key: "ctrl+z".to_string(),
action: "undo".to_string(),
};
let s = unknown.to_string();
assert!(
s.contains("undo"),
"unknown-action display must mention the action: {s}"
);
let prefix = Warning::PrefixShadow {
prefix: vec!["g".to_string()],
prefix_action: "top".to_string(),
shadowed: vec!["g".to_string(), "g".to_string()],
shadowed_action: "top2".to_string(),
};
let s = prefix.to_string();
assert!(
!s.is_empty(),
"prefix-shadow display must not be empty: {s}"
);
let empty_seq = Warning::EmptySequence {
action: "quit".to_string(),
};
let s = empty_seq.to_string();
assert!(
s.contains("quit"),
"empty-sequence display must mention the action: {s}"
);
let shadow = Warning::SequenceShadow {
chord: "j".to_string(),
chord_action: "down".to_string(),
sequence: vec!["j".to_string(), "k".to_string()],
sequence_action: "top".to_string(),
};
let s = shadow.to_string();
assert!(
s.contains('j'),
"sequence-shadow display must mention the chord: {s}"
);
}
}