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>,
}
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)]
#[non_exhaustive]
pub enum BuildError {
Toml(toml::de::Error),
KeyParse {
key: String,
source: ParseKeyInputError,
},
}
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:?}"),
}
}
}
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),
}
}
}
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, String>,
#[serde(default)]
layers: BTreeMap<String, BTreeMap<String, String>>,
#[serde(default)]
sequences: Vec<RawSequence>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawSequence {
keys: Vec<String>,
action: String,
}
type SequenceBuild<A> = (SequenceKeymap<A>, HashMap<Vec<KeyInput>, String>);
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 explicit_global = layers.remove(GLOBAL_LAYER).unwrap_or_default();
let global_entries = keys.into_iter().chain(explicit_global);
let (global_keymap, global_names) = build_layer(global_entries, &mut resolve, &mut warnings)?;
built.insert(GLOBAL_LAYER.to_string(), global_keymap);
for (name, raw_keys) in layers {
let (keymap, _names) = build_layer(raw_keys, &mut resolve, &mut warnings)?;
built.insert(name, keymap);
}
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,
})
}
fn build_layer<A, I, F>(
entries: I,
resolve: &mut F,
warnings: &mut Vec<Warning>,
) -> Result<(Keymap<A>, HashMap<KeyInput, String>), BuildError>
where
I: IntoIterator<Item = (String, String)>,
F: FnMut(&str) -> Option<A>,
{
let mut order: Vec<KeyInput> = Vec::new();
let mut groups: HashMap<KeyInput, Vec<(String, String)>> = HashMap::new();
for (raw_key, action_name) in entries {
let chord = raw_key
.parse::<KeyInput>()
.map_err(|source| BuildError::KeyParse {
key: raw_key.clone(),
source,
})?;
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))
}
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>,
{
let mut root = toml::Table::new();
let mut named = toml::Table::new();
for (name, keymap) in layers {
let table = keymap_to_table(keymap, &mut name_of);
if table.is_empty() {
continue;
}
if name == GLOBAL_LAYER {
root.insert("keys".to_string(), toml::Value::Table(table));
} else {
named.insert(name.clone(), toml::Value::Table(table));
}
}
insert_sequences(&mut root, sequences, &mut 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")
}
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: BTreeMap<String, Keymap<String>> = BTreeMap::new();
assert_eq!(
to_toml_layered(&empty, &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]);
}
}