1use std::collections::{BTreeMap, HashMap};
101
102use keymap_core::{KeyInput, Keymap, ParseKeyInputError};
103use keymap_seq::{SeqBindError, SequenceKeymap};
104use serde::Deserialize;
105
106pub const GLOBAL_LAYER: &str = "global";
115
116#[derive(Debug, Clone)]
118#[non_exhaustive]
119pub struct BuildOutput<A> {
120 pub layers: BTreeMap<String, Keymap<A>>,
131 pub sequences: SequenceKeymap<A>,
135 pub warnings: Vec<Warning>,
138 pub unbinds: BTreeMap<String, Vec<KeyInput>>,
146}
147
148impl<A> BuildOutput<A> {
149 #[must_use]
161 pub fn global(&self) -> &Keymap<A> {
162 self.layers
163 .get(GLOBAL_LAYER)
164 .expect("the global layer is always inserted")
165 }
166}
167
168#[derive(Debug, Clone, PartialEq, Eq)]
170#[non_exhaustive]
171pub enum Warning {
172 Conflict {
179 chord: String,
181 contenders: Vec<String>,
183 winner: String,
185 },
186 UnknownAction {
189 key: String,
192 action: String,
194 },
195 PrefixShadow {
200 prefix: Vec<String>,
202 prefix_action: String,
204 shadowed: Vec<String>,
206 shadowed_action: String,
208 },
209 EmptySequence {
211 action: String,
213 },
214 SequenceShadow {
226 chord: String,
228 chord_action: String,
230 sequence: Vec<String>,
234 sequence_action: String,
236 },
237}
238
239#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
248#[non_exhaustive]
249pub enum WarningKind {
250 Conflict,
252 UnknownAction,
254 PrefixShadow,
256 EmptySequence,
258 SequenceShadow,
260}
261
262impl Warning {
263 #[must_use]
268 pub fn kind(&self) -> WarningKind {
269 match self {
270 Warning::Conflict { .. } => WarningKind::Conflict,
271 Warning::UnknownAction { .. } => WarningKind::UnknownAction,
272 Warning::PrefixShadow { .. } => WarningKind::PrefixShadow,
273 Warning::EmptySequence { .. } => WarningKind::EmptySequence,
274 Warning::SequenceShadow { .. } => WarningKind::SequenceShadow,
275 }
277 }
278}
279
280impl core::fmt::Display for Warning {
281 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
287 match self {
288 Warning::Conflict { chord, winner, .. } => {
289 write!(f, "conflict on {chord:?}: {winner:?} wins")
290 }
291 Warning::UnknownAction { key, action } => {
292 write!(f, "unknown action {action:?} for key {key:?}")
293 }
294 Warning::PrefixShadow {
295 prefix,
296 shadowed,
297 prefix_action,
298 ..
299 } => {
300 write!(
301 f,
302 "prefix shadow: {:?} ({prefix_action:?}) shadows {:?}; later binding dropped",
303 prefix.join(" "),
304 shadowed.join(" ")
305 )
306 }
307 Warning::EmptySequence { action } => {
308 write!(f, "empty sequence for action {action:?}")
309 }
310 Warning::SequenceShadow {
311 chord,
312 sequence,
313 chord_action,
314 ..
315 } => {
316 write!(
317 f,
318 "sequence shadow: chord {chord:?} ({chord_action:?}) shadows sequence {:?}",
319 sequence.join(" ")
320 )
321 }
322 }
323 }
324}
325
326#[derive(Debug)]
328#[non_exhaustive]
329pub enum BuildError {
330 Toml(toml::de::Error),
332 KeyParse {
334 key: String,
336 source: ParseKeyInputError,
338 },
339 InvalidTombstone {
346 key: String,
348 },
349}
350
351impl core::fmt::Display for BuildError {
352 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
353 match self {
354 BuildError::Toml(_) => f.write_str("invalid TOML"),
355 BuildError::KeyParse { key, .. } => write!(f, "invalid key string {key:?}"),
356 BuildError::InvalidTombstone { key } => {
357 write!(
358 f,
359 "invalid tombstone for {key:?}: use `= false` to unbind, not `= true`"
360 )
361 }
362 }
363 }
364}
365
366impl std::error::Error for BuildError {
367 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
368 match self {
369 BuildError::Toml(e) => Some(e),
370 BuildError::KeyParse { source, .. } => Some(source),
371 BuildError::InvalidTombstone { .. } => None,
372 }
373 }
374}
375
376impl From<toml::de::Error> for BuildError {
377 fn from(e: toml::de::Error) -> Self {
378 BuildError::Toml(e)
379 }
380}
381
382#[derive(Deserialize)]
388#[serde(deny_unknown_fields)]
389struct RawConfig {
390 #[serde(default)]
391 keys: BTreeMap<String, RawValue>,
392 #[serde(default)]
393 layers: BTreeMap<String, BTreeMap<String, RawValue>>,
394 #[serde(default)]
395 sequences: Vec<RawSequence>,
396}
397
398#[derive(Deserialize)]
407#[serde(untagged)]
408enum RawValue {
409 Action(String),
411 Bool(bool),
413}
414
415#[derive(Deserialize)]
416#[serde(deny_unknown_fields)]
417struct RawSequence {
418 keys: Vec<String>,
419 action: String,
420}
421
422type SequenceBuild<A> = (SequenceKeymap<A>, HashMap<Vec<KeyInput>, String>);
425
426type LayerBuild<A> = (Keymap<A>, HashMap<KeyInput, String>, Vec<KeyInput>);
430
431pub fn from_str<A, F>(toml_str: &str, mut resolve: F) -> Result<BuildOutput<A>, BuildError>
443where
444 F: FnMut(&str) -> Option<A>,
445{
446 let RawConfig {
447 keys,
448 mut layers,
449 sequences: raw_sequences,
450 } = toml::from_str(toml_str)?;
451
452 let mut warnings = Vec::new();
453 let mut built: BTreeMap<String, Keymap<A>> = BTreeMap::new();
454 let mut unbinds: BTreeMap<String, Vec<KeyInput>> = BTreeMap::new();
455
456 let explicit_global = layers.remove(GLOBAL_LAYER).unwrap_or_default();
463 let global_entries = keys.into_iter().chain(explicit_global);
464 let (global_keymap, global_names, global_unbinds) =
465 build_layer(global_entries, &mut resolve, &mut warnings)?;
466 built.insert(GLOBAL_LAYER.to_string(), global_keymap);
467 if !global_unbinds.is_empty() {
468 unbinds.insert(GLOBAL_LAYER.to_string(), global_unbinds);
469 }
470
471 for (name, raw_keys) in layers {
475 let (keymap, _names, layer_unbinds) = build_layer(raw_keys, &mut resolve, &mut warnings)?;
478 built.insert(name.clone(), keymap);
479 if !layer_unbinds.is_empty() {
480 unbinds.insert(name, layer_unbinds);
481 }
482 }
483
484 let (sequences, seq_names) = build_sequences(raw_sequences, &mut resolve, &mut warnings)?;
487 detect_cross_shadows(&global_names, &seq_names, &mut warnings);
488
489 Ok(BuildOutput {
490 layers: built,
491 sequences,
492 warnings,
493 unbinds,
494 })
495}
496
497fn build_layer<A, I, F>(
514 entries: I,
515 resolve: &mut F,
516 warnings: &mut Vec<Warning>,
517) -> Result<LayerBuild<A>, BuildError>
518where
519 I: IntoIterator<Item = (String, RawValue)>,
520 F: FnMut(&str) -> Option<A>,
521{
522 let mut order: Vec<KeyInput> = Vec::new();
525 let mut groups: HashMap<KeyInput, Vec<(String, String)>> = HashMap::new();
526 let mut tombstone_order: Vec<KeyInput> = Vec::new();
527 let mut tombstone_set: std::collections::HashSet<KeyInput> = std::collections::HashSet::new();
528
529 for (raw_key, raw_value) in entries {
530 let chord = raw_key
531 .parse::<KeyInput>()
532 .map_err(|source| BuildError::KeyParse {
533 key: raw_key.clone(),
534 source,
535 })?;
536
537 match raw_value {
538 RawValue::Bool(false) => {
539 if tombstone_set.insert(chord) {
541 tombstone_order.push(chord);
542 }
543 }
544 RawValue::Bool(true) => {
545 return Err(BuildError::InvalidTombstone { key: raw_key });
546 }
547 RawValue::Action(action_name) => {
548 let entry = groups.entry(chord).or_default();
549 if entry.is_empty() {
550 order.push(chord);
551 }
552 entry.push((raw_key, action_name));
553 }
554 }
555 }
556
557 let mut keymap = Keymap::new();
558 let mut names: HashMap<KeyInput, String> = HashMap::new();
559 for chord in order {
560 let Some(entries) = groups.remove(&chord) else {
561 continue;
562 };
563
564 let mut resolved: Vec<(String, A)> = Vec::new();
565 for (raw_key, action_name) in entries {
566 match resolve(&action_name) {
567 Some(action) => resolved.push((action_name, action)),
568 None => warnings.push(Warning::UnknownAction {
569 key: raw_key,
570 action: action_name,
571 }),
572 }
573 }
574
575 if resolved.len() > 1 {
576 let contenders: Vec<String> = resolved.iter().map(|(name, _)| name.clone()).collect();
577 if let Some(winner) = contenders.last().cloned() {
578 warnings.push(Warning::Conflict {
579 chord: chord.to_string(),
580 contenders,
581 winner,
582 });
583 }
584 }
585
586 if let Some((name, action)) = resolved.pop() {
588 keymap.bind(chord, action);
589 names.insert(chord, name);
590 }
591 }
592
593 Ok((keymap, names, tombstone_order))
594}
595
596pub fn to_toml<A, F>(keymap: &Keymap<A>, sequences: &SequenceKeymap<A>, mut name_of: F) -> String
636where
637 F: FnMut(&A) -> Option<&str>,
638{
639 let mut root = toml::Table::new();
640
641 let keys = keymap_to_table(keymap, &mut name_of);
642 if !keys.is_empty() {
643 root.insert("keys".to_string(), toml::Value::Table(keys));
644 }
645 insert_sequences(&mut root, sequences, &mut name_of);
646
647 toml::to_string(&root).expect("string-only TOML value always serializes")
649}
650
651pub fn to_toml_layered<A, F>(
669 layers: &BTreeMap<String, Keymap<A>>,
670 sequences: &SequenceKeymap<A>,
671 mut name_of: F,
672) -> String
673where
674 F: FnMut(&A) -> Option<&str>,
675{
676 to_toml_layered_impl(layers, sequences, &BTreeMap::new(), &mut name_of)
677}
678
679pub fn to_toml_layered_with_unbinds<A, F>(
697 layers: &BTreeMap<String, Keymap<A>>,
698 sequences: &SequenceKeymap<A>,
699 unbinds: &BTreeMap<String, Vec<KeyInput>>,
700 mut name_of: F,
701) -> String
702where
703 F: FnMut(&A) -> Option<&str>,
704{
705 to_toml_layered_impl(layers, sequences, unbinds, &mut name_of)
706}
707
708fn to_toml_layered_impl<A, F>(
710 layers: &BTreeMap<String, Keymap<A>>,
711 sequences: &SequenceKeymap<A>,
712 unbinds: &BTreeMap<String, Vec<KeyInput>>,
713 name_of: &mut F,
714) -> String
715where
716 F: FnMut(&A) -> Option<&str>,
717{
718 let mut root = toml::Table::new();
719 let mut named = toml::Table::new();
720
721 let mut all_layer_names: std::collections::BTreeSet<&str> =
724 layers.keys().map(String::as_str).collect();
725 for name in unbinds.keys() {
726 all_layer_names.insert(name.as_str());
727 }
728
729 for name in all_layer_names {
730 let mut table = if let Some(keymap) = layers.get(name) {
731 keymap_to_table(keymap, name_of)
732 } else {
733 toml::Table::new()
734 };
735
736 if let Some(layer_unbinds) = unbinds.get(name) {
739 for chord in layer_unbinds {
740 table.insert(chord.to_string(), toml::Value::Boolean(false));
741 }
742 }
743
744 if table.is_empty() {
745 continue;
746 }
747 if name == GLOBAL_LAYER {
748 root.insert("keys".to_string(), toml::Value::Table(table));
749 } else {
750 named.insert(name.to_string(), toml::Value::Table(table));
751 }
752 }
753
754 insert_sequences(&mut root, sequences, name_of);
755 if !named.is_empty() {
756 root.insert("layers".to_string(), toml::Value::Table(named));
757 }
758
759 toml::to_string(&root).expect("string-only TOML value always serializes")
761}
762
763#[derive(Debug)]
770#[non_exhaustive]
771pub struct Merged<A> {
772 pub output: BuildOutput<A>,
776 pub notes: Vec<MergeNote>,
779}
780
781#[derive(Debug, Clone, PartialEq, Eq)]
789#[non_exhaustive]
790pub enum MergeNote {
791 Overrode {
794 layer: String,
796 chord: String,
798 },
799 Unbound {
802 layer: String,
804 chord: String,
806 },
807 UnbindMiss {
810 layer: String,
812 chord: String,
814 },
815 DroppedSequence {
819 sequence: Vec<String>,
821 },
822}
823
824#[must_use]
858pub fn merge<A: Clone>(mut base: BuildOutput<A>, overlay: BuildOutput<A>) -> Merged<A> {
859 let mut notes: Vec<MergeNote> = Vec::new();
860
861 base.warnings.extend(overlay.warnings);
863
864 for (layer_name, chords) in &overlay.unbinds {
868 for chord in chords {
869 let removed = base
870 .layers
871 .get_mut(layer_name)
872 .and_then(|layer| layer.unbind(chord));
873 if removed.is_some() {
874 notes.push(MergeNote::Unbound {
875 layer: layer_name.clone(),
876 chord: chord.to_string(),
877 });
878 } else {
879 notes.push(MergeNote::UnbindMiss {
880 layer: layer_name.clone(),
881 chord: chord.to_string(),
882 });
883 }
884 }
885 }
886
887 for (layer_name, overlay_keymap) in overlay.layers {
892 let base_layer = base.layers.entry(layer_name.clone()).or_default();
893
894 let keys: Vec<KeyInput> = overlay_keymap.iter().map(|(k, _)| *k).collect();
896 let mut owned_overlay = overlay_keymap;
897 for chord in keys {
898 if base_layer.contains(&chord) {
899 notes.push(MergeNote::Overrode {
900 layer: layer_name.clone(),
901 chord: chord.to_string(),
902 });
903 }
904 if let Some(action) = owned_overlay.unbind(&chord) {
905 base_layer.bind(chord, action);
906 }
907 }
908 }
909
910 let overlay_seqs: Vec<(Vec<KeyInput>, A)> = overlay
920 .sequences
921 .bindings()
922 .map(|(path, action)| (path.clone(), action.clone()))
923 .collect();
924
925 for (path, action) in overlay_seqs {
926 use keymap_seq::SeqBindError;
927 while let Err(SeqBindError::PrefixShadow { sequence, conflict }) =
930 base.sequences.bind(path.iter().copied(), action.clone())
931 {
932 let victim = if sequence == path { conflict } else { sequence };
934 notes.push(MergeNote::DroppedSequence {
935 sequence: render_sequence(&victim),
936 });
937 let remaining: Vec<(Vec<KeyInput>, A)> = base
941 .sequences
942 .bindings()
943 .filter(|(p, _)| *p != victim.as_slice())
944 .map(|(p, a)| (p.clone(), a.clone()))
945 .collect();
946 base.sequences = SequenceKeymap::new();
947 for (p, a) in remaining {
948 let _ = base.sequences.bind(p.iter().copied(), a);
952 }
953 }
954 }
955
956 Merged {
957 output: base,
958 notes,
959 }
960}
961
962fn keymap_to_table<A, F>(keymap: &Keymap<A>, name_of: &mut F) -> toml::Table
966where
967 F: FnMut(&A) -> Option<&str>,
968{
969 let mut table = toml::Table::new();
970 for (chord, action) in keymap.iter() {
971 if let Some(name) = name_of(action) {
972 table.insert(chord.to_string(), toml::Value::String(name.to_string()));
973 }
974 }
975 table
976}
977
978fn insert_sequences<A, F>(root: &mut toml::Table, sequences: &SequenceKeymap<A>, name_of: &mut F)
982where
983 F: FnMut(&A) -> Option<&str>,
984{
985 let mut seqs: Vec<(Vec<String>, String)> = sequences
986 .bindings()
987 .filter_map(|(path, action)| {
988 name_of(action).map(|name| (render_sequence(&path), name.to_string()))
989 })
990 .collect();
991 seqs.sort_by_key(|(chords, _)| chords.join(" "));
992
993 if seqs.is_empty() {
994 return;
995 }
996 let array = seqs
997 .into_iter()
998 .map(|(chords, name)| {
999 let mut entry = toml::Table::new();
1000 entry.insert(
1001 "keys".to_string(),
1002 toml::Value::Array(chords.into_iter().map(toml::Value::String).collect()),
1003 );
1004 entry.insert("action".to_string(), toml::Value::String(name));
1005 toml::Value::Table(entry)
1006 })
1007 .collect();
1008 root.insert("sequences".to_string(), toml::Value::Array(array));
1009}
1010
1011fn detect_cross_shadows(
1020 single_names: &HashMap<KeyInput, String>,
1021 seq_names: &HashMap<Vec<KeyInput>, String>,
1022 warnings: &mut Vec<Warning>,
1023) {
1024 let mut singles: Vec<(&KeyInput, &String)> = single_names.iter().collect();
1025 singles.sort_by_key(|(chord, _)| chord.to_string());
1026
1027 for (chord, chord_action) in singles {
1028 let mut shadowed: Vec<(&Vec<KeyInput>, &String)> = seq_names
1029 .iter()
1030 .filter(|(seq, _)| seq.first() == Some(chord))
1031 .collect();
1032 shadowed.sort_by_key(|(seq, _)| render_sequence(seq).join(" "));
1033
1034 if let Some((sequence, sequence_action)) = shadowed.first() {
1035 warnings.push(Warning::SequenceShadow {
1036 chord: chord.to_string(),
1037 chord_action: chord_action.clone(),
1038 sequence: render_sequence(sequence),
1039 sequence_action: (*sequence_action).clone(),
1040 });
1041 }
1042 }
1043}
1044
1045fn build_sequences<A, F>(
1064 raw_sequences: Vec<RawSequence>,
1065 resolve: &mut F,
1066 warnings: &mut Vec<Warning>,
1067) -> Result<SequenceBuild<A>, BuildError>
1068where
1069 F: FnMut(&str) -> Option<A>,
1070{
1071 let mut sequences = SequenceKeymap::new();
1072 let mut names: HashMap<Vec<KeyInput>, String> = HashMap::new();
1075
1076 for raw_seq in raw_sequences {
1077 let mut keys = Vec::with_capacity(raw_seq.keys.len());
1078 for raw_key in &raw_seq.keys {
1079 let chord = raw_key
1080 .parse::<KeyInput>()
1081 .map_err(|source| BuildError::KeyParse {
1082 key: raw_key.clone(),
1083 source,
1084 })?;
1085 keys.push(chord);
1086 }
1087
1088 let Some(action) = resolve(&raw_seq.action) else {
1089 warnings.push(Warning::UnknownAction {
1090 key: render_sequence(&keys).join(" "),
1091 action: raw_seq.action,
1092 });
1093 continue;
1094 };
1095
1096 match sequences.bind(keys.iter().copied(), action) {
1097 Ok(None) => {
1098 names.insert(keys, raw_seq.action);
1099 }
1100 Ok(Some(_)) => {
1101 let previous = names.insert(keys.clone(), raw_seq.action.clone());
1103 warnings.push(Warning::Conflict {
1104 chord: render_sequence(&keys).join(" "),
1105 contenders: vec![previous.unwrap_or_default(), raw_seq.action.clone()],
1106 winner: raw_seq.action,
1107 });
1108 }
1109 Err(SeqBindError::Empty) => {
1110 warnings.push(Warning::EmptySequence {
1111 action: raw_seq.action,
1112 });
1113 }
1114 Err(SeqBindError::PrefixShadow { sequence, conflict }) => {
1115 let conflict_action = names.get(&conflict).cloned().unwrap_or_default();
1116 let (prefix, prefix_action, shadowed, shadowed_action) =
1118 if sequence.len() <= conflict.len() {
1119 (sequence, raw_seq.action, conflict, conflict_action)
1120 } else {
1121 (conflict, conflict_action, sequence, raw_seq.action)
1122 };
1123 warnings.push(Warning::PrefixShadow {
1124 prefix: render_sequence(&prefix),
1125 prefix_action,
1126 shadowed: render_sequence(&shadowed),
1127 shadowed_action,
1128 });
1129 }
1130 Err(_) => {}
1133 }
1134 }
1135
1136 Ok((sequences, names))
1137}
1138
1139fn render_sequence(keys: &[KeyInput]) -> Vec<String> {
1141 keys.iter().map(ToString::to_string).collect()
1142}
1143
1144#[cfg(test)]
1145mod tests {
1146 use super::*;
1147 use keymap_core::{Key, Modifiers};
1148
1149 #[derive(Debug, Clone, PartialEq)]
1150 enum Action {
1151 Quit,
1152 Save,
1153 Split,
1154 Top,
1155 }
1156
1157 fn resolver(name: &str) -> Option<Action> {
1158 match name {
1159 "quit" => Some(Action::Quit),
1160 "save" => Some(Action::Save),
1161 "split" => Some(Action::Split),
1162 "top" => Some(Action::Top),
1163 _ => None,
1164 }
1165 }
1166
1167 use keymap_seq::Match;
1168
1169 fn seq(keys: &[(char, Modifiers)]) -> Vec<KeyInput> {
1170 keys.iter()
1171 .map(|&(c, m)| KeyInput::new(Key::Char(c), m))
1172 .collect()
1173 }
1174
1175 #[test]
1176 fn builds_bindings_and_resolves_actions() {
1177 let toml = "[keys]\n\"ctrl+q\" = \"quit\"\n\"ctrl+s\" = \"save\"\n";
1178 let out = from_str(toml, resolver).unwrap();
1179 assert!(out.warnings.is_empty());
1180 let q = KeyInput::new(Key::Char('q'), Modifiers::CTRL);
1181 assert_eq!(out.global().get(&q), Some(&Action::Quit));
1182 }
1183
1184 #[test]
1185 fn bare_keys_build_the_global_layer_which_is_always_present() {
1186 let empty: BuildOutput<Action> = from_str("", resolver).unwrap();
1189 assert_eq!(empty.layers.keys().collect::<Vec<_>>(), vec![GLOBAL_LAYER]);
1190 assert!(empty.global().is_empty());
1191
1192 let out = from_str("[keys]\n\"ctrl+q\" = \"quit\"\n", resolver).unwrap();
1193 assert_eq!(out.layers.keys().collect::<Vec<_>>(), vec![GLOBAL_LAYER]);
1194 }
1195
1196 #[test]
1197 fn named_layers_are_parsed_under_their_names() {
1198 let toml = "\
1199[keys]\n\"ctrl+q\" = \"quit\"\n\
1200[layers.panel]\n\"ctrl+s\" = \"split\"\n";
1201 let out = from_str(toml, resolver).unwrap();
1202 assert!(out.warnings.is_empty());
1203 assert_eq!(
1205 out.layers.keys().map(String::as_str).collect::<Vec<_>>(),
1206 vec!["global", "panel"]
1207 );
1208 let q = KeyInput::new(Key::Char('q'), Modifiers::CTRL);
1209 let s = KeyInput::new(Key::Char('s'), Modifiers::CTRL);
1210 assert_eq!(out.global().get(&q), Some(&Action::Quit));
1211 assert_eq!(out.layers["panel"].get(&s), Some(&Action::Split));
1212 assert_eq!(out.global().get(&s), None);
1214 assert_eq!(out.layers["panel"].get(&q), None);
1215 }
1216
1217 #[test]
1218 fn a_layer_with_no_keys_section_still_gets_an_empty_global() {
1219 let toml = "[layers.panel]\n\"ctrl+s\" = \"split\"\n";
1220 let out = from_str(toml, resolver).unwrap();
1221 assert!(out.global().is_empty());
1222 assert!(!out.layers["panel"].is_empty());
1223 }
1224
1225 #[test]
1226 fn same_chord_in_two_layers_is_an_override_not_a_conflict() {
1227 let toml = "\
1231[keys]\n\"ctrl+s\" = \"save\"\n\
1232[layers.panel]\n\"ctrl+s\" = \"split\"\n";
1233 let out = from_str(toml, resolver).unwrap();
1234 assert!(
1235 out.warnings.is_empty(),
1236 "cross-layer override must not warn: {:?}",
1237 out.warnings
1238 );
1239 let s = KeyInput::new(Key::Char('s'), Modifiers::CTRL);
1240 assert_eq!(out.global().get(&s), Some(&Action::Save));
1241 assert_eq!(out.layers["panel"].get(&s), Some(&Action::Split));
1242 }
1243
1244 #[test]
1245 fn explicit_global_layer_merges_into_the_top_level_keys() {
1246 let toml = "\
1247[keys]\n\"ctrl+q\" = \"quit\"\n\
1248[layers.global]\n\"ctrl+s\" = \"save\"\n";
1249 let out = from_str(toml, resolver).unwrap();
1250 assert!(out.warnings.is_empty());
1251 assert_eq!(out.layers.keys().collect::<Vec<_>>(), vec![GLOBAL_LAYER]);
1253 let q = KeyInput::new(Key::Char('q'), Modifiers::CTRL);
1254 let s = KeyInput::new(Key::Char('s'), Modifiers::CTRL);
1255 assert_eq!(out.global().get(&q), Some(&Action::Quit));
1256 assert_eq!(out.global().get(&s), Some(&Action::Save));
1257 }
1258
1259 #[test]
1260 fn keys_and_explicit_global_colliding_on_a_chord_conflict_last_wins() {
1261 let toml = "\
1265[keys]\n\"ctrl+q\" = \"quit\"\n\
1266[layers.global]\n\"ctrl+q\" = \"save\"\n";
1267 let out = from_str(toml, resolver).unwrap();
1268 assert_eq!(
1269 out.warnings,
1270 vec![Warning::Conflict {
1271 chord: "ctrl+q".to_string(),
1272 contenders: vec!["quit".to_string(), "save".to_string()],
1273 winner: "save".to_string(),
1274 }]
1275 );
1276 let q = KeyInput::new(Key::Char('q'), Modifiers::CTRL);
1277 assert_eq!(out.global().get(&q), Some(&Action::Save));
1278 }
1279
1280 #[test]
1281 fn conflict_within_a_named_layer_is_reported() {
1282 let toml = "\
1285[layers.panel]\n\"ctrl+a\" = \"quit\"\n\"control+a\" = \"save\"\n";
1286 let out = from_str(toml, resolver).unwrap();
1287 assert_eq!(
1288 out.warnings,
1289 vec![Warning::Conflict {
1290 chord: "ctrl+a".to_string(),
1291 contenders: vec!["save".to_string(), "quit".to_string()],
1292 winner: "quit".to_string(),
1293 }]
1294 );
1295 }
1296
1297 #[test]
1298 fn cross_shadow_is_checked_against_global_only() {
1299 let toml = "\
1303[layers.panel]\n\"j\" = \"top\"\n\
1304[[sequences]]\nkeys = [\"j\", \"k\"]\naction = \"save\"\n";
1305 let out = from_str(toml, resolver).unwrap();
1306 assert!(
1307 out.warnings.is_empty(),
1308 "a non-global chord must not cross-shadow a global sequence: {:?}",
1309 out.warnings
1310 );
1311 }
1312
1313 #[test]
1314 fn unknown_action_in_a_named_layer_is_a_warning_carrying_no_layer_name() {
1315 let toml = "[layers.panel]\n\"ctrl+z\" = \"undo\"\n";
1319 let out = from_str(toml, resolver).unwrap();
1320 assert_eq!(
1321 out.warnings,
1322 vec![Warning::UnknownAction {
1323 key: "ctrl+z".to_string(),
1324 action: "undo".to_string(),
1325 }]
1326 );
1327 assert!(out.layers["panel"].is_empty());
1328 }
1329
1330 #[test]
1331 fn malformed_key_in_a_named_layer_is_a_fatal_error() {
1332 let toml = "[layers.panel]\n\"ctrl+nope\" = \"quit\"\n";
1334 let err = from_str(toml, resolver).unwrap_err();
1335 assert!(matches!(err, BuildError::KeyParse { .. }));
1336 }
1337
1338 #[test]
1339 fn sequences_do_not_create_extra_layers() {
1340 let toml = "\
1343[keys]\n\"ctrl+q\" = \"quit\"\n\
1344[[sequences]]\nkeys = [\"ctrl+x\", \"ctrl+s\"]\naction = \"save\"\n";
1345 let out = from_str(toml, resolver).unwrap();
1346 assert!(out.warnings.is_empty());
1347 assert_eq!(out.layers.keys().collect::<Vec<_>>(), vec![GLOBAL_LAYER]);
1348 assert!(!out.sequences.is_empty());
1349 }
1350
1351 #[test]
1352 fn unknown_actions_across_layers_warn_global_first_then_name_order() {
1353 let toml = "\
1356[keys]\n\"a\" = \"nope_global\"\n\
1357[layers.zeta]\n\"b\" = \"nope_zeta\"\n\
1358[layers.alpha]\n\"c\" = \"nope_alpha\"\n";
1359 let out = from_str(toml, resolver).unwrap();
1360 let unknown_actions: Vec<&str> = out
1361 .warnings
1362 .iter()
1363 .filter_map(|w| match w {
1364 Warning::UnknownAction { action, .. } => Some(action.as_str()),
1365 _ => None,
1366 })
1367 .collect();
1368 assert_eq!(
1369 unknown_actions,
1370 vec!["nope_global", "nope_alpha", "nope_zeta"]
1371 );
1372 }
1373
1374 #[test]
1375 fn unknown_top_level_field_is_a_fatal_error() {
1376 let err = from_str("[kesy]\n\"ctrl+q\" = \"quit\"\n", resolver).unwrap_err();
1378 assert!(matches!(err, BuildError::Toml(_)));
1379 }
1380
1381 #[test]
1382 fn unknown_sequence_field_is_a_fatal_error() {
1383 let toml = "[[sequences]]\nkeys = [\"g\"]\naction = \"top\"\nlayer = \"panel\"\n";
1386 let err = from_str(toml, resolver).unwrap_err();
1387 assert!(matches!(err, BuildError::Toml(_)));
1388 }
1389
1390 #[test]
1391 fn unknown_action_is_a_warning_not_a_failure() {
1392 let toml = "[keys]\n\"ctrl+q\" = \"quit\"\n\"ctrl+z\" = \"undo\"\n";
1393 let out = from_str(toml, resolver).unwrap();
1394 let q = KeyInput::new(Key::Char('q'), Modifiers::CTRL);
1396 assert_eq!(out.global().get(&q), Some(&Action::Quit));
1397 assert_eq!(
1398 out.warnings,
1399 vec![Warning::UnknownAction {
1400 key: "ctrl+z".to_string(),
1401 action: "undo".to_string(),
1402 }]
1403 );
1404 }
1405
1406 #[test]
1407 fn distinct_spellings_of_same_chord_conflict() {
1408 let toml = "[keys]\n\"ctrl+a\" = \"quit\"\n\"control+a\" = \"save\"\n";
1412 let out = from_str(toml, resolver).unwrap();
1413 let a = KeyInput::new(Key::Char('a'), Modifiers::CTRL);
1414 assert!(out.global().get(&a).is_some());
1416 let conflicts: Vec<_> = out
1417 .warnings
1418 .iter()
1419 .filter(|w| matches!(w, Warning::Conflict { .. }))
1420 .collect();
1421 assert_eq!(conflicts.len(), 1);
1422 }
1423
1424 #[test]
1425 fn legacy_lints_are_opt_in_and_separate_from_warnings() {
1426 let toml = "[keys]\n\"cmd+s\" = \"save\"\n";
1430 let out = from_str(toml, resolver).unwrap();
1431 assert!(out.warnings.is_empty());
1432 assert_eq!(
1433 keymap_core::legacy_lints(out.global()),
1434 vec![keymap_core::LegacyLint::Unrepresentable {
1435 chord: "super+s".to_string(),
1436 }]
1437 );
1438 }
1439
1440 #[test]
1441 fn malformed_key_is_a_fatal_error() {
1442 let toml = "[keys]\n\"ctrl+nope\" = \"quit\"\n";
1443 let err = from_str(toml, resolver).unwrap_err();
1444 assert!(matches!(err, BuildError::KeyParse { .. }));
1445 }
1446
1447 #[test]
1448 fn malformed_toml_is_a_fatal_error() {
1449 let err = from_str("this is not toml", resolver).unwrap_err();
1450 assert!(matches!(err, BuildError::Toml(_)));
1451 }
1452
1453 #[test]
1454 fn empty_config_builds_empty_map() {
1455 let out: BuildOutput<Action> = from_str("", resolver).unwrap();
1456 assert!(out.global().is_empty());
1457 assert!(out.sequences.is_empty());
1458 assert!(out.warnings.is_empty());
1459 }
1460
1461 #[test]
1462 fn builds_sequence_bindings() {
1463 let toml = "\
1464[keys]\n\"ctrl+q\" = \"quit\"\n\
1465[[sequences]]\nkeys = [\"ctrl+x\", \"ctrl+s\"]\naction = \"save\"\n\
1466[[sequences]]\nkeys = [\"ctrl+x\", \"ctrl+c\"]\naction = \"quit\"\n";
1467 let out = from_str(toml, resolver).unwrap();
1468 assert!(out.warnings.is_empty());
1469 let q = KeyInput::new(Key::Char('q'), Modifiers::CTRL);
1471 assert_eq!(out.global().get(&q), Some(&Action::Quit));
1472 let save = seq(&[('x', Modifiers::CTRL), ('s', Modifiers::CTRL)]);
1474 assert_eq!(out.sequences.lookup(&save), Match::Exact(&Action::Save));
1475 assert_eq!(out.sequences.lookup(&save[..1]), Match::Prefix);
1476 }
1477
1478 #[test]
1479 fn sequence_unknown_action_is_a_warning() {
1480 let toml = "[[sequences]]\nkeys = [\"ctrl+x\", \"ctrl+z\"]\naction = \"undo\"\n";
1481 let out = from_str(toml, resolver).unwrap();
1482 assert!(out.sequences.is_empty());
1483 assert_eq!(
1484 out.warnings,
1485 vec![Warning::UnknownAction {
1486 key: "ctrl+x ctrl+z".to_string(),
1487 action: "undo".to_string(),
1488 }]
1489 );
1490 }
1491
1492 #[test]
1493 fn sequence_prefix_shadow_is_a_warning_and_drops_the_later() {
1494 let toml = "\
1496[[sequences]]\nkeys = [\"g\"]\naction = \"top\"\n\
1497[[sequences]]\nkeys = [\"g\", \"g\"]\naction = \"split\"\n";
1498 let out = from_str(toml, resolver).unwrap();
1499 assert_eq!(
1501 out.sequences.lookup(&seq(&[('g', Modifiers::NONE)])),
1502 Match::Exact(&Action::Top)
1503 );
1504 assert_eq!(
1505 out.warnings,
1506 vec![Warning::PrefixShadow {
1507 prefix: vec!["g".to_string()],
1508 prefix_action: "top".to_string(),
1509 shadowed: vec!["g".to_string(), "g".to_string()],
1510 shadowed_action: "split".to_string(),
1511 }]
1512 );
1513 }
1514
1515 #[test]
1516 fn sequence_prefix_shadow_reverse_order_drops_the_later_short_one() {
1517 let toml = "\
1520[[sequences]]\nkeys = [\"g\", \"g\"]\naction = \"split\"\n\
1521[[sequences]]\nkeys = [\"g\"]\naction = \"top\"\n";
1522 let out = from_str(toml, resolver).unwrap();
1523 assert_eq!(
1525 out.sequences
1526 .lookup(&seq(&[('g', Modifiers::NONE), ('g', Modifiers::NONE)])),
1527 Match::Exact(&Action::Split)
1528 );
1529 assert_eq!(
1530 out.warnings,
1531 vec![Warning::PrefixShadow {
1532 prefix: vec!["g".to_string()],
1533 prefix_action: "top".to_string(),
1534 shadowed: vec!["g".to_string(), "g".to_string()],
1535 shadowed_action: "split".to_string(),
1536 }]
1537 );
1538 }
1539
1540 #[test]
1541 fn same_sequence_three_times_reports_pairwise_conflicts() {
1542 let toml = "\
1543[[sequences]]\nkeys = [\"ctrl+x\"]\naction = \"save\"\n\
1544[[sequences]]\nkeys = [\"ctrl+x\"]\naction = \"split\"\n\
1545[[sequences]]\nkeys = [\"ctrl+x\"]\naction = \"quit\"\n";
1546 let out = from_str(toml, resolver).unwrap();
1547 assert_eq!(
1549 out.sequences.lookup(&seq(&[('x', Modifiers::CTRL)])),
1550 Match::Exact(&Action::Quit)
1551 );
1552 assert_eq!(
1554 out.warnings,
1555 vec![
1556 Warning::Conflict {
1557 chord: "ctrl+x".to_string(),
1558 contenders: vec!["save".to_string(), "split".to_string()],
1559 winner: "split".to_string(),
1560 },
1561 Warning::Conflict {
1562 chord: "ctrl+x".to_string(),
1563 contenders: vec!["split".to_string(), "quit".to_string()],
1564 winner: "quit".to_string(),
1565 },
1566 ]
1567 );
1568 }
1569
1570 #[test]
1571 fn empty_sequence_is_a_warning() {
1572 let toml = "[[sequences]]\nkeys = []\naction = \"save\"\n";
1573 let out = from_str(toml, resolver).unwrap();
1574 assert!(out.sequences.is_empty());
1575 assert_eq!(
1576 out.warnings,
1577 vec![Warning::EmptySequence {
1578 action: "save".to_string(),
1579 }]
1580 );
1581 }
1582
1583 #[test]
1584 fn duplicate_sequence_conflicts_and_last_wins() {
1585 let toml = "\
1586[[sequences]]\nkeys = [\"ctrl+x\", \"ctrl+s\"]\naction = \"save\"\n\
1587[[sequences]]\nkeys = [\"ctrl+x\", \"ctrl+s\"]\naction = \"split\"\n";
1588 let out = from_str(toml, resolver).unwrap();
1589 let s = seq(&[('x', Modifiers::CTRL), ('s', Modifiers::CTRL)]);
1590 assert_eq!(out.sequences.lookup(&s), Match::Exact(&Action::Split));
1591 assert_eq!(
1592 out.warnings,
1593 vec![Warning::Conflict {
1594 chord: "ctrl+x ctrl+s".to_string(),
1595 contenders: vec!["save".to_string(), "split".to_string()],
1596 winner: "split".to_string(),
1597 }]
1598 );
1599 }
1600
1601 #[test]
1602 fn malformed_sequence_key_is_a_fatal_error() {
1603 let toml = "[[sequences]]\nkeys = [\"ctrl+x\", \"ctrl+nope\"]\naction = \"save\"\n";
1604 let err = from_str(toml, resolver).unwrap_err();
1605 assert!(matches!(err, BuildError::KeyParse { .. }));
1606 }
1607
1608 #[test]
1609 fn single_chord_shadowing_a_sequence_is_an_advisory_warning() {
1610 let toml = "\
1613[keys]\n\"j\" = \"top\"\n\
1614[[sequences]]\nkeys = [\"j\", \"k\"]\naction = \"save\"\n";
1615 let out = from_str(toml, resolver).unwrap();
1616 assert_eq!(
1618 out.global()
1619 .get(&KeyInput::new(Key::Char('j'), Modifiers::NONE)),
1620 Some(&Action::Top)
1621 );
1622 assert_eq!(
1623 out.sequences
1624 .lookup(&seq(&[('j', Modifiers::NONE), ('k', Modifiers::NONE)])),
1625 Match::Exact(&Action::Save)
1626 );
1627 assert_eq!(
1628 out.warnings,
1629 vec![Warning::SequenceShadow {
1630 chord: "j".to_string(),
1631 chord_action: "top".to_string(),
1632 sequence: vec!["j".to_string(), "k".to_string()],
1633 sequence_action: "save".to_string(),
1634 }]
1635 );
1636 }
1637
1638 #[test]
1639 fn length_one_sequence_equal_to_a_single_chord_shadows() {
1640 let toml = "\
1643[keys]\n\"q\" = \"quit\"\n\
1644[[sequences]]\nkeys = [\"q\"]\naction = \"save\"\n";
1645 let out = from_str(toml, resolver).unwrap();
1646 assert_eq!(
1647 out.warnings,
1648 vec![Warning::SequenceShadow {
1649 chord: "q".to_string(),
1650 chord_action: "quit".to_string(),
1651 sequence: vec!["q".to_string()],
1652 sequence_action: "save".to_string(),
1653 }]
1654 );
1655 }
1656
1657 #[test]
1658 fn disjoint_first_keys_do_not_shadow() {
1659 let toml = "\
1661[keys]\n\"j\" = \"top\"\n\
1662[[sequences]]\nkeys = [\"g\", \"g\"]\naction = \"save\"\n";
1663 let out = from_str(toml, resolver).unwrap();
1664 assert!(out.warnings.is_empty());
1665 }
1666
1667 #[test]
1668 fn one_chord_shadowing_several_sequences_reports_the_lex_first() {
1669 let toml = "\
1671[keys]\n\"j\" = \"top\"\n\
1672[[sequences]]\nkeys = [\"j\", \"k\"]\naction = \"save\"\n\
1673[[sequences]]\nkeys = [\"j\", \"a\"]\naction = \"quit\"\n";
1674 let out = from_str(toml, resolver).unwrap();
1675 assert_eq!(
1676 out.warnings,
1677 vec![Warning::SequenceShadow {
1678 chord: "j".to_string(),
1679 chord_action: "top".to_string(),
1680 sequence: vec!["j".to_string(), "a".to_string()],
1681 sequence_action: "quit".to_string(),
1682 }]
1683 );
1684 }
1685
1686 #[test]
1687 fn multiple_shadowing_chords_emit_in_canonical_chord_order() {
1688 let toml = "\
1692[keys]\n\"z\" = \"top\"\n\"j\" = \"quit\"\n\
1693[[sequences]]\nkeys = [\"z\", \"x\"]\naction = \"save\"\n\
1694[[sequences]]\nkeys = [\"j\", \"k\"]\naction = \"split\"\n";
1695 let out = from_str(toml, resolver).unwrap();
1696 assert_eq!(
1697 out.warnings,
1698 vec![
1699 Warning::SequenceShadow {
1700 chord: "j".to_string(),
1701 chord_action: "quit".to_string(),
1702 sequence: vec!["j".to_string(), "k".to_string()],
1703 sequence_action: "split".to_string(),
1704 },
1705 Warning::SequenceShadow {
1706 chord: "z".to_string(),
1707 chord_action: "top".to_string(),
1708 sequence: vec!["z".to_string(), "x".to_string()],
1709 sequence_action: "save".to_string(),
1710 },
1711 ]
1712 );
1713 }
1714
1715 #[test]
1716 fn unknown_single_chord_does_not_shadow() {
1717 let toml = "\
1720[keys]\n\"j\" = \"nonexistent\"\n\
1721[[sequences]]\nkeys = [\"j\", \"k\"]\naction = \"save\"\n";
1722 let out = from_str(toml, resolver).unwrap();
1723 assert_eq!(
1724 out.warnings,
1725 vec![Warning::UnknownAction {
1726 key: "j".to_string(),
1727 action: "nonexistent".to_string(),
1728 }]
1729 );
1730 }
1731
1732 #[test]
1733 fn cross_shadow_coexists_with_conflict_and_comes_last() {
1734 let toml = "\
1737[keys]\n\"ctrl+a\" = \"quit\"\n\"control+a\" = \"save\"\n\"j\" = \"top\"\n\
1738[[sequences]]\nkeys = [\"j\", \"k\"]\naction = \"split\"\n";
1739 let out = from_str(toml, resolver).unwrap();
1740 assert_eq!(
1741 out.warnings,
1742 vec![
1743 Warning::Conflict {
1746 chord: "ctrl+a".to_string(),
1747 contenders: vec!["save".to_string(), "quit".to_string()],
1748 winner: "quit".to_string(),
1749 },
1750 Warning::SequenceShadow {
1751 chord: "j".to_string(),
1752 chord_action: "top".to_string(),
1753 sequence: vec!["j".to_string(), "k".to_string()],
1754 sequence_action: "split".to_string(),
1755 },
1756 ]
1757 );
1758 }
1759
1760 #[test]
1761 fn chord_matching_a_non_first_sequence_key_does_not_shadow() {
1762 let toml = "\
1765[keys]\n\"j\" = \"top\"\n\
1766[[sequences]]\nkeys = [\"g\", \"j\"]\naction = \"save\"\n";
1767 let out = from_str(toml, resolver).unwrap();
1768 assert!(out.warnings.is_empty());
1769 }
1770
1771 fn km_pairs(km: &Keymap<String>) -> Vec<(KeyInput, String)> {
1778 let mut v: Vec<_> = km.iter().map(|(k, a)| (*k, a.clone())).collect();
1779 v.sort_by_key(|(k, _)| k.to_string());
1780 v
1781 }
1782
1783 fn seq_pairs(s: &SequenceKeymap<String>) -> Vec<(Vec<KeyInput>, String)> {
1784 let mut v: Vec<_> = s.bindings().map(|(p, a)| (p, a.clone())).collect();
1785 v.sort_by_key(|(p, _)| render_sequence(p).join(" "));
1786 v
1787 }
1788
1789 fn assert_round_trips(km: &Keymap<String>, seq: &SequenceKeymap<String>) {
1791 let toml = to_toml(km, seq, |a: &String| Some(a.as_str()));
1792 let out = from_str(&toml, |name: &str| Some(name.to_owned())).unwrap();
1793 assert!(
1794 out.warnings.is_empty(),
1795 "round-trip warned: {:?}",
1796 out.warnings
1797 );
1798 assert_eq!(km_pairs(km), km_pairs(out.global()));
1799 assert_eq!(seq_pairs(seq), seq_pairs(&out.sequences));
1800 }
1801
1802 fn norm(key: Key, mods: Modifiers) -> KeyInput {
1803 KeyInput::normalized(key, mods)
1804 }
1805
1806 #[test]
1807 fn to_toml_round_trips_keys_and_sequences() {
1808 let mut km = Keymap::new();
1809 km.bind(norm(Key::Char('q'), Modifiers::CTRL), "quit".to_owned());
1810 km.bind(norm(Key::Char('s'), Modifiers::CTRL), "save".to_owned());
1811 km.bind(norm(Key::Char('a'), Modifiers::SHIFT), "all".to_owned());
1814 km.bind(
1816 norm(Key::Char('a'), Modifiers::CTRL | Modifiers::SHIFT),
1817 "alt_all".to_owned(),
1818 );
1819
1820 let mut seq = SequenceKeymap::new();
1821 seq.bind(
1822 [
1823 norm(Key::Char('x'), Modifiers::CTRL),
1824 norm(Key::Char('s'), Modifiers::CTRL),
1825 ],
1826 "seq_save".to_owned(),
1827 )
1828 .unwrap();
1829 seq.bind(
1830 [
1831 norm(Key::Char('g'), Modifiers::NONE),
1832 norm(Key::Char('g'), Modifiers::NONE),
1833 ],
1834 "top".to_owned(),
1835 )
1836 .unwrap();
1837
1838 assert_round_trips(&km, &seq);
1839 }
1840
1841 #[test]
1842 fn to_toml_is_deterministic_and_canonically_ordered() {
1843 let mut km = Keymap::new();
1844 km.bind(norm(Key::Char('z'), Modifiers::CTRL), "z".to_owned());
1845 km.bind(norm(Key::Char('a'), Modifiers::CTRL), "a".to_owned());
1846 let seq = SequenceKeymap::new();
1847
1848 let first = to_toml(&km, &seq, |a: &String| Some(a.as_str()));
1849 let second = to_toml(&km, &seq, |a: &String| Some(a.as_str()));
1850 assert_eq!(first, second, "output must be deterministic");
1851 let a_at = first.find("ctrl+a").unwrap();
1853 let z_at = first.find("ctrl+z").unwrap();
1854 assert!(
1855 a_at < z_at,
1856 "keys must be emitted in canonical order:\n{first}"
1857 );
1858 }
1859
1860 #[test]
1861 fn to_toml_omits_unnameable_bindings() {
1862 let mut km = Keymap::new();
1863 km.bind(norm(Key::Char('q'), Modifiers::CTRL), "quit".to_owned());
1864 km.bind(norm(Key::Char('x'), Modifiers::CTRL), "secret".to_owned());
1865 let seq = SequenceKeymap::new();
1866
1867 let toml = to_toml(&km, &seq, |a: &String| {
1869 (a != "secret").then_some(a.as_str())
1870 });
1871 let out = from_str(&toml, |name: &str| Some(name.to_owned())).unwrap();
1872 assert_eq!(
1873 out.global().get(&norm(Key::Char('q'), Modifiers::CTRL)),
1874 Some(&"quit".to_owned())
1875 );
1876 assert_eq!(
1877 out.global().get(&norm(Key::Char('x'), Modifiers::CTRL)),
1878 None
1879 );
1880 }
1881
1882 #[test]
1883 fn to_toml_empty_maps_emit_empty_string() {
1884 let km: Keymap<String> = Keymap::new();
1885 let seq: SequenceKeymap<String> = SequenceKeymap::new();
1886 assert_eq!(to_toml(&km, &seq, |a: &String| Some(a.as_str())), "");
1887 }
1888
1889 #[test]
1890 fn to_toml_round_trips_adversarial_names_and_chords() {
1891 let mut km = Keymap::new();
1894 km.bind(
1895 norm(Key::Char('a'), Modifiers::NONE),
1896 "quit\"; [injected]\nx = \"oops".to_owned(),
1897 );
1898 km.bind(norm(Key::Char('+'), Modifiers::NONE), "plus".to_owned());
1900 km.bind(norm(Key::Char(' '), Modifiers::NONE), "space".to_owned());
1901 km.bind(norm(Key::Char('"'), Modifiers::NONE), "quote".to_owned());
1902 km.bind(norm(Key::Char('あ'), Modifiers::NONE), "hira".to_owned());
1903 km.bind(norm(Key::Char('F'), Modifiers::NONE), "cap_f".to_owned());
1904
1905 let mut seq = SequenceKeymap::new();
1909 seq.bind(
1910 [
1911 norm(Key::Char('z'), Modifiers::NONE),
1912 norm(Key::Char(' '), Modifiers::NONE),
1913 norm(Key::Char('+'), Modifiers::NONE),
1914 ],
1915 "z_space_plus".to_owned(),
1916 )
1917 .unwrap();
1918
1919 assert_round_trips(&km, &seq);
1920 }
1921
1922 #[test]
1923 fn shadow_matching_is_on_the_parsed_chord_not_the_label() {
1924 let toml = "\
1926[keys]\n\"ctrl+x\" = \"top\"\n\
1927[[sequences]]\nkeys = [\"ctrl+x\", \"ctrl+s\"]\naction = \"save\"\n";
1928 let out = from_str(toml, resolver).unwrap();
1929 assert_eq!(
1930 out.warnings,
1931 vec![Warning::SequenceShadow {
1932 chord: "ctrl+x".to_string(),
1933 chord_action: "top".to_string(),
1934 sequence: vec!["ctrl+x".to_string(), "ctrl+s".to_string()],
1935 sequence_action: "save".to_string(),
1936 }]
1937 );
1938
1939 let toml = "\
1941[keys]\n\"ctrl+x\" = \"top\"\n\
1942[[sequences]]\nkeys = [\"ctrl+shift+x\", \"ctrl+s\"]\naction = \"save\"\n";
1943 let out = from_str(toml, resolver).unwrap();
1944 assert!(out.warnings.is_empty());
1945 }
1946
1947 #[test]
1948 fn to_toml_layered_round_trips_named_layers() {
1949 let mut global = Keymap::new();
1950 global.bind(norm(Key::Char('q'), Modifiers::CTRL), "quit".to_owned());
1951 let mut panel = Keymap::new();
1952 panel.bind(norm(Key::Char('s'), Modifiers::CTRL), "split".to_owned());
1953 panel.bind(
1955 norm(Key::Char('q'), Modifiers::CTRL),
1956 "panel_quit".to_owned(),
1957 );
1958
1959 let mut layers = BTreeMap::new();
1960 layers.insert(GLOBAL_LAYER.to_string(), global);
1961 layers.insert("panel".to_string(), panel);
1962
1963 let mut seq = SequenceKeymap::new();
1964 seq.bind(
1965 [
1966 norm(Key::Char('x'), Modifiers::CTRL),
1967 norm(Key::Char('s'), Modifiers::CTRL),
1968 ],
1969 "seq_save".to_owned(),
1970 )
1971 .unwrap();
1972
1973 let toml = to_toml_layered(&layers, &seq, |a: &String| Some(a.as_str()));
1974 let out = from_str(&toml, |name: &str| Some(name.to_owned())).unwrap();
1975 assert!(
1976 out.warnings.is_empty(),
1977 "round-trip warned: {:?}",
1978 out.warnings
1979 );
1980 assert_eq!(km_pairs(&layers["global"]), km_pairs(out.global()));
1981 assert_eq!(km_pairs(&layers["panel"]), km_pairs(&out.layers["panel"]));
1982 assert_eq!(seq_pairs(&seq), seq_pairs(&out.sequences));
1983 }
1984
1985 #[test]
1986 fn to_toml_layered_matches_to_toml_for_a_global_only_set() {
1987 let mut global = Keymap::new();
1990 global.bind(norm(Key::Char('q'), Modifiers::CTRL), "quit".to_owned());
1991 global.bind(norm(Key::Char('s'), Modifiers::CTRL), "save".to_owned());
1992 let mut seq = SequenceKeymap::new();
1993 seq.bind(
1994 [
1995 norm(Key::Char('g'), Modifiers::NONE),
1996 norm(Key::Char('g'), Modifiers::NONE),
1997 ],
1998 "top".to_owned(),
1999 )
2000 .unwrap();
2001
2002 let mut layers = BTreeMap::new();
2003 layers.insert(GLOBAL_LAYER.to_string(), global.clone());
2004
2005 let plain = to_toml(&global, &seq, |a: &String| Some(a.as_str()));
2006 let layered = to_toml_layered(&layers, &seq, |a: &String| Some(a.as_str()));
2007 assert_eq!(plain, layered);
2008 let empty_layers: BTreeMap<String, Keymap<String>> = BTreeMap::new();
2010 assert_eq!(
2011 to_toml_layered(&empty_layers, &SequenceKeymap::new(), |a: &String| Some(
2012 a.as_str()
2013 )),
2014 ""
2015 );
2016 }
2017
2018 #[test]
2019 fn to_toml_layered_drops_empty_layers() {
2020 let mut global = Keymap::new();
2024 global.bind(norm(Key::Char('q'), Modifiers::CTRL), "quit".to_owned());
2025 let mut layers = BTreeMap::new();
2026 layers.insert(GLOBAL_LAYER.to_string(), global);
2027 layers.insert("panel".to_string(), Keymap::<String>::new());
2028
2029 let toml = to_toml_layered(&layers, &SequenceKeymap::new(), |a: &String| {
2030 Some(a.as_str())
2031 });
2032 assert!(
2033 !toml.contains("panel"),
2034 "empty layer must not be emitted:\n{toml}"
2035 );
2036 let out = from_str(&toml, |name: &str| Some(name.to_owned())).unwrap();
2037 assert_eq!(out.layers.keys().collect::<Vec<_>>(), vec![GLOBAL_LAYER]);
2038 }
2039
2040 #[test]
2046 fn tombstone_false_was_build_error_in_0_1_0_now_accepted() {
2047 let toml = "[keys]\n\"ctrl+q\" = false\n";
2049 let out = from_str(toml, resolver).unwrap();
2050 let q = KeyInput::new(Key::Char('q'), Modifiers::CTRL);
2052 assert_eq!(out.global().get(&q), None);
2053 let global_unbinds = out
2055 .unbinds
2056 .get(GLOBAL_LAYER)
2057 .expect("global unbinds present");
2058 assert!(global_unbinds.contains(&q));
2059 assert!(out.warnings.is_empty());
2061 }
2062
2063 #[test]
2065 fn tombstone_true_is_still_an_error() {
2066 let toml = "[keys]\n\"ctrl+q\" = true\n";
2067 let err = from_str(toml, resolver).unwrap_err();
2068 assert!(
2069 matches!(err, BuildError::InvalidTombstone { .. }),
2070 "expected InvalidTombstone, got {err:?}"
2071 );
2072 }
2073
2074 #[test]
2075 fn tombstone_in_named_layer_lands_in_unbinds_not_keymap() {
2076 let toml = "[layers.panel]\n\"ctrl+s\" = false\n";
2077 let out = from_str(toml, resolver).unwrap();
2078 let s = KeyInput::new(Key::Char('s'), Modifiers::CTRL);
2079 assert!(out.layers["panel"].is_empty());
2080 let panel_unbinds = out.unbinds.get("panel").expect("panel unbinds present");
2081 assert!(panel_unbinds.contains(&s));
2082 }
2083
2084 #[test]
2085 fn tombstone_coexists_with_bindings_in_same_layer() {
2086 let toml = "[keys]\n\"ctrl+q\" = \"quit\"\n\"ctrl+s\" = false\n";
2088 let out = from_str(toml, resolver).unwrap();
2089 let q = KeyInput::new(Key::Char('q'), Modifiers::CTRL);
2090 let s = KeyInput::new(Key::Char('s'), Modifiers::CTRL);
2091 assert_eq!(out.global().get(&q), Some(&Action::Quit));
2092 assert_eq!(out.global().get(&s), None);
2093 let global_unbinds = out.unbinds.get(GLOBAL_LAYER).unwrap();
2094 assert!(global_unbinds.contains(&s));
2095 }
2096
2097 #[test]
2098 fn empty_config_has_empty_unbinds() {
2099 let out: BuildOutput<Action> =
2101 from_str("[keys]\n\"ctrl+q\" = \"quit\"\n", resolver).unwrap();
2102 assert!(out.unbinds.is_empty());
2103 }
2104
2105 #[test]
2111 fn to_toml_layered_with_empty_unbinds_matches_0_1_0_behavior() {
2112 let mut global = Keymap::new();
2113 global.bind(norm(Key::Char('q'), Modifiers::CTRL), "quit".to_owned());
2114 let mut layers = BTreeMap::new();
2115 layers.insert(GLOBAL_LAYER.to_string(), global.clone());
2116 let seq = SequenceKeymap::new();
2117 let empty_unbinds: BTreeMap<String, Vec<KeyInput>> = BTreeMap::new();
2118
2119 let plain = to_toml_layered(&layers, &seq, |a: &String| Some(a.as_str()));
2120 let with_empty =
2121 to_toml_layered_with_unbinds(&layers, &seq, &empty_unbinds, |a: &String| {
2122 Some(a.as_str())
2123 });
2124 assert_eq!(
2125 plain, with_empty,
2126 "empty unbinds must produce byte-identical output to to_toml_layered"
2127 );
2128 }
2129
2130 #[test]
2131 fn to_toml_layered_emits_tombstones_and_round_trips() {
2132 let global: Keymap<String> = Keymap::new(); let mut layers = BTreeMap::new();
2136 layers.insert(GLOBAL_LAYER.to_string(), global);
2137
2138 let ctrl_s = norm(Key::Char('s'), Modifiers::CTRL);
2139 let mut unbinds: BTreeMap<String, Vec<KeyInput>> = BTreeMap::new();
2140 unbinds.insert(GLOBAL_LAYER.to_string(), vec![ctrl_s]);
2141
2142 let toml_str = to_toml_layered_with_unbinds(
2143 &layers,
2144 &SequenceKeymap::new(),
2145 &unbinds,
2146 |a: &String| Some(a.as_str()),
2147 );
2148 assert!(
2150 toml_str.contains("false"),
2151 "tombstone must be emitted as `= false`:\n{toml_str}"
2152 );
2153 let out = from_str(&toml_str, |name: &str| Some(name.to_owned())).unwrap();
2155 let global_unbinds = out
2156 .unbinds
2157 .get(GLOBAL_LAYER)
2158 .expect("global unbinds present");
2159 assert!(global_unbinds.contains(&ctrl_s));
2160 assert!(out.warnings.is_empty());
2161 }
2162
2163 #[test]
2164 fn to_toml_layered_tombstone_injection_safe() {
2165 let space = norm(Key::Char(' '), Modifiers::NONE);
2170 let layers: BTreeMap<String, Keymap<String>> = BTreeMap::new();
2171 let mut unbinds: BTreeMap<String, Vec<KeyInput>> = BTreeMap::new();
2172 unbinds.insert(GLOBAL_LAYER.to_string(), vec![space]);
2173
2174 let toml_str = to_toml_layered_with_unbinds(
2175 &layers,
2176 &SequenceKeymap::new(),
2177 &unbinds,
2178 |a: &String| Some(a.as_str()),
2179 );
2180 let out = from_str(&toml_str, |name: &str| Some(name.to_owned())).unwrap();
2182 let global_unbinds = out
2183 .unbinds
2184 .get(GLOBAL_LAYER)
2185 .expect("global unbinds present");
2186 assert!(global_unbinds.contains(&space));
2187 }
2188
2189 fn make_output(toml: &str) -> BuildOutput<String> {
2192 from_str(toml, |name: &str| Some(name.to_owned())).unwrap()
2193 }
2194
2195 #[test]
2196 fn merge_overlay_chord_wins_silently_with_override_note() {
2197 let base = make_output("[keys]\n\"ctrl+s\" = \"save_base\"\n");
2198 let overlay = make_output("[keys]\n\"ctrl+s\" = \"save_overlay\"\n");
2199 let merged = merge(base, overlay);
2200
2201 let s = norm(Key::Char('s'), Modifiers::CTRL);
2202 assert_eq!(
2203 merged.output.global().get(&s),
2204 Some(&"save_overlay".to_owned()),
2205 "overlay wins"
2206 );
2207 assert!(
2208 merged.output.warnings.is_empty(),
2209 "override must not produce a Warning"
2210 );
2211 assert!(
2212 merged.notes.iter().any(|n| matches!(n,
2213 MergeNote::Overrode { layer, chord }
2214 if layer == GLOBAL_LAYER && chord == "ctrl+s"
2215 )),
2216 "Overrode note must be emitted: {:?}",
2217 merged.notes
2218 );
2219 }
2220
2221 #[test]
2222 fn merge_layer_union_adds_layers_from_overlay() {
2223 let base = make_output("[keys]\n\"ctrl+q\" = \"quit\"\n");
2224 let overlay = make_output("[layers.panel]\n\"ctrl+s\" = \"split\"\n");
2225 let merged = merge(base, overlay);
2226
2227 assert!(
2228 merged.output.layers.contains_key("panel"),
2229 "panel layer added"
2230 );
2231 let s = norm(Key::Char('s'), Modifiers::CTRL);
2232 assert_eq!(
2233 merged.output.layers["panel"].get(&s),
2234 Some(&"split".to_owned())
2235 );
2236 assert!(
2237 merged.notes.is_empty(),
2238 "no notes for pure add: {:?}",
2239 merged.notes
2240 );
2241 }
2242
2243 #[test]
2244 fn merge_sequence_exact_overlay_wins() {
2245 let base =
2246 make_output("[[sequences]]\nkeys = [\"ctrl+x\", \"ctrl+s\"]\naction = \"save_base\"\n");
2247 let overlay = make_output(
2248 "[[sequences]]\nkeys = [\"ctrl+x\", \"ctrl+s\"]\naction = \"save_overlay\"\n",
2249 );
2250 let merged = merge(base, overlay);
2251
2252 let xs = vec![
2253 norm(Key::Char('x'), Modifiers::CTRL),
2254 norm(Key::Char('s'), Modifiers::CTRL),
2255 ];
2256 assert_eq!(
2257 merged.output.sequences.lookup(&xs),
2258 keymap_seq::Match::Exact(&"save_overlay".to_owned()),
2259 "overlay sequence wins"
2260 );
2261 assert!(merged.output.warnings.is_empty());
2262 }
2263
2264 #[test]
2265 fn merge_sequence_prefix_clash_drops_base_with_note() {
2266 let base = make_output("[[sequences]]\nkeys = [\"g\", \"g\"]\naction = \"top_base\"\n");
2269 let overlay = make_output("[[sequences]]\nkeys = [\"g\"]\naction = \"top_overlay\"\n");
2270 let merged = merge(base, overlay);
2271
2272 let g = vec![norm(Key::Char('g'), Modifiers::NONE)];
2273 assert_eq!(
2274 merged.output.sequences.lookup(&g),
2275 keymap_seq::Match::Exact(&"top_overlay".to_owned()),
2276 "overlay wins"
2277 );
2278 assert!(
2279 merged
2280 .notes
2281 .iter()
2282 .any(|n| matches!(n, MergeNote::DroppedSequence { .. })),
2283 "DroppedSequence note must be emitted: {:?}",
2284 merged.notes
2285 );
2286 }
2287
2288 #[test]
2289 fn merge_unbind_removes_from_base_with_unbound_note() {
2290 let base = make_output("[keys]\n\"ctrl+s\" = \"save\"\n");
2291 let overlay = make_output("[keys]\n\"ctrl+s\" = false\n");
2292 let merged = merge(base, overlay);
2293
2294 let s = norm(Key::Char('s'), Modifiers::CTRL);
2295 assert_eq!(
2296 merged.output.global().get(&s),
2297 None,
2298 "chord removed from base"
2299 );
2300 assert!(
2301 merged.notes.iter().any(|n| matches!(n,
2302 MergeNote::Unbound { layer, chord }
2303 if layer == GLOBAL_LAYER && chord == "ctrl+s"
2304 )),
2305 "Unbound note must be emitted: {:?}",
2306 merged.notes
2307 );
2308 assert!(merged.output.warnings.is_empty());
2309 }
2310
2311 #[test]
2312 fn merge_unbind_of_absent_chord_produces_miss_note() {
2313 let base = make_output("[keys]\n\"ctrl+q\" = \"quit\"\n");
2314 let overlay = make_output("[keys]\n\"ctrl+s\" = false\n");
2315 let merged = merge(base, overlay);
2316
2317 assert!(
2318 merged.notes.iter().any(|n| matches!(n,
2319 MergeNote::UnbindMiss { chord, .. }
2320 if chord == "ctrl+s"
2321 )),
2322 "UnbindMiss note expected: {:?}",
2323 merged.notes
2324 );
2325 let q = norm(Key::Char('q'), Modifiers::CTRL);
2327 assert_eq!(merged.output.global().get(&q), Some(&"quit".to_owned()));
2328 }
2329
2330 #[test]
2331 fn merge_warnings_are_concatenated_not_mixed_into_notes() {
2332 let base = from_str("[keys]\n\"ctrl+z\" = \"nope_base\"\n", resolver).unwrap();
2336 let overlay = from_str("[keys]\n\"ctrl+y\" = \"nope_overlay\"\n", resolver).unwrap();
2337 assert_eq!(base.warnings.len(), 1);
2338 assert_eq!(overlay.warnings.len(), 1);
2339 let merged = merge(base, overlay);
2340
2341 assert_eq!(merged.output.warnings.len(), 2, "both warnings carried");
2342 assert!(merged.notes.is_empty());
2343 }
2344
2345 #[test]
2350 fn warning_kind_covers_all_variants() {
2351 let conflict = Warning::Conflict {
2352 chord: "ctrl+a".to_string(),
2353 contenders: vec!["quit".to_string(), "save".to_string()],
2354 winner: "save".to_string(),
2355 };
2356 assert_eq!(conflict.kind(), WarningKind::Conflict);
2357
2358 let unknown = Warning::UnknownAction {
2359 key: "ctrl+z".to_string(),
2360 action: "undo".to_string(),
2361 };
2362 assert_eq!(unknown.kind(), WarningKind::UnknownAction);
2363
2364 let prefix = Warning::PrefixShadow {
2365 prefix: vec!["g".to_string()],
2366 prefix_action: "top".to_string(),
2367 shadowed: vec!["g".to_string(), "g".to_string()],
2368 shadowed_action: "top2".to_string(),
2369 };
2370 assert_eq!(prefix.kind(), WarningKind::PrefixShadow);
2371
2372 let empty = Warning::EmptySequence {
2373 action: "quit".to_string(),
2374 };
2375 assert_eq!(empty.kind(), WarningKind::EmptySequence);
2376
2377 let shadow = Warning::SequenceShadow {
2378 chord: "j".to_string(),
2379 chord_action: "down".to_string(),
2380 sequence: vec!["j".to_string(), "k".to_string()],
2381 sequence_action: "top".to_string(),
2382 };
2383 assert_eq!(shadow.kind(), WarningKind::SequenceShadow);
2384 }
2385
2386 #[test]
2390 fn warning_display_is_human_readable() {
2391 let conflict = Warning::Conflict {
2392 chord: "ctrl+a".to_string(),
2393 contenders: vec!["quit".to_string(), "save".to_string()],
2394 winner: "save".to_string(),
2395 };
2396 let s = conflict.to_string();
2397 assert!(
2398 s.contains("ctrl+a"),
2399 "conflict display must mention the chord: {s}"
2400 );
2401 assert!(
2402 s.contains("save"),
2403 "conflict display must mention the winner: {s}"
2404 );
2405
2406 let unknown = Warning::UnknownAction {
2407 key: "ctrl+z".to_string(),
2408 action: "undo".to_string(),
2409 };
2410 let s = unknown.to_string();
2411 assert!(
2412 s.contains("undo"),
2413 "unknown-action display must mention the action: {s}"
2414 );
2415
2416 let prefix = Warning::PrefixShadow {
2417 prefix: vec!["g".to_string()],
2418 prefix_action: "top".to_string(),
2419 shadowed: vec!["g".to_string(), "g".to_string()],
2420 shadowed_action: "top2".to_string(),
2421 };
2422 let s = prefix.to_string();
2423 assert!(
2424 !s.is_empty(),
2425 "prefix-shadow display must not be empty: {s}"
2426 );
2427
2428 let empty_seq = Warning::EmptySequence {
2429 action: "quit".to_string(),
2430 };
2431 let s = empty_seq.to_string();
2432 assert!(
2433 s.contains("quit"),
2434 "empty-sequence display must mention the action: {s}"
2435 );
2436
2437 let shadow = Warning::SequenceShadow {
2438 chord: "j".to_string(),
2439 chord_action: "down".to_string(),
2440 sequence: vec!["j".to_string(), "k".to_string()],
2441 sequence_action: "top".to_string(),
2442 };
2443 let s = shadow.to_string();
2444 assert!(
2445 s.contains('j'),
2446 "sequence-shadow display must mention the chord: {s}"
2447 );
2448 }
2449}