keymap_derive 1.0.0-rc.7

A derive macro to generate compile-time validated key mappings for enums, enabling declarative and ergonomic key binding support.
Documentation
// TODO: Fix release-please bug. See https://github.com/googleapis/release-please/issues/1662#issuecomment-1419080151
extern crate keymap_dev as keymap;

#[derive(Debug, PartialEq, Eq, keymap_derive::KeyMap, Clone)]
enum Action {
    /// Create a new file.
    /// Multi-line support.
    #[key("enter", "ctrl-b n")]
    Create,
    /// Delete a file
    #[key("d", "delete", "d d", "@lower")]
    Delete,
    /// Quit
    #[key("esc", "q")]
    Quit,

    /// Digit with char argument
    #[key("@digit")]
    Digit(char),

    /// Jump with char argument
    #[key("@any")]
    Jump(char),
}

#[allow(dead_code)]
#[derive(Debug, PartialEq, Eq, Clone)]
enum NoDefault {
    A,
    B,
}

#[allow(dead_code)]
#[derive(Debug, PartialEq, Eq, keymap_derive::KeyMap, Clone)]
enum IgnoreTest {
    /// Active variant
    #[key("a")]
    Active,
    /// Ignored variant (should NOT appear in keymap_config)
    #[key(ignore)]
    Ignored,
    /// Ignored with field (should NOT require Default)
    #[key(ignore)]
    IgnoredWithData(NoDefault),
}

#[allow(dead_code)]
#[derive(Debug, PartialEq, Eq, keymap_derive::KeyMap, Clone)]
enum CustomSymbolTest {
    /// Active item with custom symbol and help
    #[key("ctrl-b", symbol = "^B", help = "jump over obstacles")]
    Active,
    /// Active item with help but no symbol (falls back to ctrl-b)
    #[key("ctrl-b", help = "do jump")]
    FallbackSymbol,
    /// Active item with no symbol or help (falls back to ctrl-b)
    #[key("ctrl-b")]
    NoSymbolOrHelp,
}

#[cfg(test)]
mod tests {
    use keymap_dev::{Error, Item, KeyMap, KeyMapConfig, ToKeyMap};

    use super::*;

    struct Wrapper(keymap_parser::Node);

    fn wrap(s: &str) -> Vec<Wrapper> {
        keymap_parser::parse_seq(s)
            .unwrap()
            .into_iter()
            .map(Wrapper)
            .collect()
    }

    impl ToKeyMap for Wrapper {
        fn to_keymap(&self) -> Result<KeyMap, Error> {
            Ok(self.0.clone())
        }
    }

    #[test]
    fn test_derive_key() {
        let config = Action::keymap_config();

        [
            (Action::Create, "enter"),
            (Action::Delete, "d"),
            (Action::Delete, "d d"),
            (Action::Delete, "delete"),
        ]
        .iter()
        .for_each(|(action, input)| {
            let key = keymap_parser::parse_seq(input).unwrap();
            assert_eq!(action, config.get_item_by_keymaps(&key).unwrap().0);
        });
    }

    #[test]
    fn test_derive_char_group() {
        let config = Action::keymap_config();

        [
            (Action::Delete, "x"),      // @lower
            (Action::Digit('\0'), "1"), // @digit
        ]
        .iter()
        .for_each(|(action, input)| {
            let key = keymap_parser::parse_seq(input).unwrap();
            assert_eq!(action, config.get_item_by_keymaps(&key).unwrap().0);
        });
    }

    #[test]
    fn test_keymap_config() {
        let config = Action::keymap_config();

        assert_eq!(
            config.items,
            vec![
                (
                    Action::Create,
                    Item::new(
                        ["enter", "ctrl-b n"].map(ToString::to_string).to_vec(),
                        "Create a new file.\nMulti-line support.".to_string()
                    )
                ),
                (
                    Action::Delete,
                    Item::new(
                        ["d", "delete", "d d", "@lower"]
                            .map(ToString::to_string)
                            .to_vec(),
                        "Delete a file".to_string()
                    )
                ),
                (
                    Action::Quit,
                    Item::new(
                        ["esc", "q"].map(ToString::to_string).to_vec(),
                        "Quit".to_string()
                    )
                ),
                (
                    Action::Digit('\0'),
                    Item::new(
                        ["@digit"].map(ToString::to_string).to_vec(),
                        "Digit with char argument".to_string()
                    )
                ),
                (
                    Action::Jump('\0'),
                    Item::new(
                        ["@any"].map(ToString::to_string).to_vec(),
                        "Jump with char argument".to_string()
                    )
                ),
            ]
        );
    }

    #[test]
    fn test_bound_payload_extraction() {
        let config = Action::keymap_config();

        [
            ("1", Action::Digit('1')),
            ("A", Action::Jump('A')),
            ("enter", Action::Create),
        ]
        .into_iter()
        .for_each(|(input, expected)| {
            assert_eq!(expected, config.get_bound_seq(&wrap(input)).unwrap());
        });

        // get_bound_item_by_keymaps also returns the item
        let nodes = wrap("Q").into_iter().map(|w| w.0).collect::<Vec<_>>();
        let (bound_action, item) = config.get_bound_item_by_keymaps(&nodes).unwrap();
        assert_eq!(bound_action, Action::Jump('Q'));
        assert_eq!(item.description, "Jump with char argument");

        // Key::Space extracted as ' ' via @any
        let space = vec![Wrapper(keymap_parser::Node::new(
            0,
            keymap_parser::node::Key::Space,
        ))];
        assert_eq!(Action::Jump(' '), config.get_bound_seq(&space).unwrap());
    }

    #[test]
    fn test_key_ignore_not_in_config() {
        let config = IgnoreTest::keymap_config();

        assert_eq!(config.items.len(), 1);
        assert_eq!(
            config.items[0],
            (
                IgnoreTest::Active,
                Item::new(
                    ["a"].map(ToString::to_string).to_vec(),
                    "Active variant".to_string()
                )
            )
        );
    }

    #[test]
    fn test_ignored_variant_no_default_required() {
        let config = IgnoreTest::keymap_config();

        assert!(!config
            .items
            .iter()
            .any(|(v, _)| matches!(v, IgnoreTest::Ignored)));
        assert!(!config
            .items
            .iter()
            .any(|(v, _)| matches!(v, IgnoreTest::IgnoredWithData(_))));
    }

    #[test]
    fn test_custom_symbol_and_help() {
        let config = CustomSymbolTest::keymap_config();

        // 1. symbol and help both specified
        let (_, item1) = &config.items[0];
        assert_eq!(item1.symbol.as_deref(), Some("^B"));
        assert_eq!(item1.help.as_deref(), Some("jump over obstacles"));

        // 2. help specified, symbol omitted (should fallback to "ctrl-b")
        let (_, item2) = &config.items[1];
        assert_eq!(item2.symbol.as_deref(), Some("ctrl-b"));
        assert_eq!(item2.help.as_deref(), Some("do jump"));

        // 3. both symbol and help omitted (should fallback to "ctrl-b" and None)
        let (_, item3) = &config.items[2];
        assert_eq!(item3.symbol.as_deref(), Some("ctrl-b"));
        assert_eq!(item3.help.as_deref(), None);
    }
}