Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 108 additions & 55 deletions keymap_derive/src/item.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use keymap_parser::{parse_seq, Node};
use syn::{punctuated::Punctuated, token::Comma, Attribute, LitStr, Token, Variant};
use syn::{punctuated::Punctuated, spanned::Spanned, token::Comma, Token, Variant};

/// An attribute path name #[key(...)]
const KEY_IDENT: &str = "key";
Expand All @@ -14,44 +14,133 @@ pub(crate) struct Item<'a> {
pub nodes: Vec<Vec<Node>>,
pub ignore: bool,
pub description: String,
pub symbol: Option<String>,
pub help: Option<String>,
}

/// Helper struct representing the arguments parsed from a `#[key(...)]` attribute.
///
/// It supports a hybrid syntax:
/// 1. Positional string literals (e.g. `"ctrl-b"`, `"space"`), which represent the keys to bind.
/// 2. The `ignore` boolean flag (e.g. `#[key(ignore)]`).
/// 3. Named name-value fields:
/// - `symbol = "..."` (e.g. `symbol = "^B"`) defining a custom quick visual symbol for display.
/// - `help = "..."` (e.g. `help = "jump"`) defining a short help text description for the binding.
///
/// Example:
///
/// #[key("ctrl-b", symbol = "^B", help = "jump")]
/// | |______| |__________| |___________|
/// path keys symbol help
struct KeyAttrArgs {
keys: Vec<String>,
ignore: bool,
symbol: Option<String>,
help: Option<String>,
}

impl syn::parse::Parse for KeyAttrArgs {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
let mut keys = Vec::new();
let mut ignore = false;
let mut symbol = None;
let mut help = None;

while !input.is_empty() {
if input.peek(syn::LitStr) {
// Parse positional key bindings like "ctrl-b"
let lit: syn::LitStr = input.parse()?;
keys.push(lit.value());
} else if input.peek(syn::Ident) {
let ident: syn::Ident = input.parse()?;
if ident == "ignore" {
// Parse the single 'ignore' flag
ignore = true;
} else if ident == "symbol" {
// Parse 'symbol = "..."'
let _: Token![=] = input.parse()?;
let lit: syn::LitStr = input.parse()?;
symbol = Some(lit.value());
} else if ident == "help" {
// Parse 'help = "..."'
let _: Token![=] = input.parse()?;
let lit: syn::LitStr = input.parse()?;
help = Some(lit.value());
} else {
return Err(syn::Error::new(
ident.span(),
format!("Unknown key attribute argument: {}", ident),
));
}
} else {
return Err(syn::Error::new(
input.span(),
"Expected string literal or identifier",
));
}

// Consume optional comma separator if there are remaining arguments
if !input.is_empty() {
let _: Token![,] = input.parse()?;
}
}

Ok(KeyAttrArgs {
keys,
ignore,
symbol,
help,
})
}
}

pub(crate) fn parse_items(
variants: &Punctuated<Variant, Comma>,
) -> Result<Vec<Item<'_>>, syn::Error> {
// NOTE: All variants are parsed, even those without the #[key(...)] attribute.
// This allows the deserializer to override keys and descriptions for variants that don't define them explicitly.
variants
.iter()
.map(|variant| {
let ignore = parse_ignore(variant);
let (keys, nodes) = parse_keys(variant, ignore)?;
let mut keys = Vec::new();
let mut nodes = Vec::new();
let mut ignore = false;
let mut symbol = None;
let mut help = None;

for attr in &variant.attrs {
if attr.path().is_ident(KEY_IDENT) {
let args: KeyAttrArgs = attr.parse_args()?;
if args.ignore {
ignore = true;
}
for key in args.keys {
let seq = parse_seq(&key).map_err(|e| {
syn::Error::new(attr.span(), format!("Invalid key \"{key}\": {e}"))
})?;
keys.push(key);
nodes.push(seq);
}
if args.symbol.is_some() {
symbol = args.symbol;
}
if args.help.is_some() {
help = args.help;
}
}
}

Ok(Item {
variant,
ignore,
description: parse_doc(variant),
keys,
nodes,
symbol,
help,
})
})
.collect()
}

fn parse_ignore(variant: &Variant) -> bool {
variant.attrs.iter().any(|attr| {
let mut ignore = false;
if attr.path().is_ident(KEY_IDENT) {
let _ = attr.parse_nested_meta(|meta| {
ignore = meta.path.is_ident("ignore");
Ok(())
});
}

ignore
})
}

fn parse_doc(variant: &Variant) -> String {
variant
.attrs
Expand All @@ -70,39 +159,3 @@ fn parse_doc(variant: &Variant) -> String {
.collect::<Vec<_>>()
.join("\n")
}

/// Parse attribute arguments.
///
/// Example:
///
/// #[key("a", "g g")]
/// | |________|
/// path (args)
fn parse_args(attr: &Attribute) -> syn::Result<Punctuated<LitStr, Token![,]>> {
attr.parse_args_with(Punctuated::<LitStr, Token![,]>::parse_separated_nonempty)
}

fn parse_keys(variant: &Variant, ignore: bool) -> syn::Result<(Vec<String>, Vec<Vec<Node>>)> {
let mut keys = Vec::new();
let mut nodes = Vec::new();

for attr in &variant.attrs {
if !attr.path().is_ident(KEY_IDENT) || ignore {
continue;
}

// Collect arguments
//
// e.g. [["a"], ["g g"]]
for arg in parse_args(attr)? {
let val = arg.value();
let seq = parse_seq(&val)
.map_err(|e| syn::Error::new(arg.span(), format!("Invalid key \"{val}\": {e}")))?;

keys.push(val);
nodes.push(seq);
}
}

Ok((keys, nodes))
}
13 changes: 11 additions & 2 deletions keymap_derive/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,14 +187,23 @@ fn impl_keymap_config(name: &Ident, items: &Vec<Item>) -> proc_macro2::TokenStre
}
};

let symbol_opt = match &item.symbol {
Some(sym) => quote! { .with_symbol(Some(#sym)) },
None => quote! {},
};
let help_opt = match &item.help {
Some(h) => quote! { .with_help(Some(#h)) },
None => quote! {},
};

match_arms_deserialize.push(quote! {
#variant_name_str => Ok(#variant_expr_default),
});
match_arms.push(quote! {
#variant_pat => ::keymap::Item::new(
vec![#(#keys),*],
#doc.to_string()
),
) #symbol_opt #help_opt,
});

entries.push(quote! {
Expand All @@ -203,7 +212,7 @@ fn impl_keymap_config(name: &Ident, items: &Vec<Item>) -> proc_macro2::TokenStre
::keymap::Item::new(
vec![#(#keys),*],
#doc.to_string()
)
) #symbol_opt #help_opt
),
});
}
Expand Down
34 changes: 34 additions & 0 deletions keymap_derive/tests/derive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,20 @@ enum IgnoreTest {
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};
Expand Down Expand Up @@ -213,4 +227,24 @@ mod tests {
.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);
}
}
61 changes: 54 additions & 7 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,21 +240,47 @@ impl<T> Deref for DerivedConfig<T> {
/// # Example
///
/// ```ignore
/// let item = Item {
/// keys: vec!["a".into(), "b c".into()],
/// description: "Some command".into(),
/// };
/// let item = Item::new(vec!["a".into(), "b c".into()], "Some command".into());
/// ```
#[derive(Debug, Deserialize, PartialEq)]
#[serde(from = "ItemRaw")]
pub struct Item {
/// A collection of key expressions. Each expression will be run through
/// `keymap_parser::parse_seq`, so special notations like `@digit` or
/// multi-key sequences (e.g., `"d e"`) are supported.
pub keys: Vec<String>,

/// A short description for display or documentation purposes.
#[serde(default)]
pub description: String,

/// A short key symbol or shortcut representation for the user interface (e.g. `^B`).
pub symbol: Option<String>,

/// A short help description of the binding (e.g. `jump`).
pub help: Option<String>,
}

/// Raw deserialization target — Serde deserializes into this first,
/// then [`From<ItemRaw>`] converts it into [`Item`] with fallback logic.
#[derive(Deserialize)]
struct ItemRaw {
keys: Vec<String>,
#[serde(default)]
description: String,
symbol: Option<String>,
help: Option<String>,
}

impl From<ItemRaw> for Item {
fn from(raw: ItemRaw) -> Self {
let symbol = raw.symbol.or_else(|| raw.keys.first().cloned());
Self {
keys: raw.keys,
description: raw.description,
symbol,
help: raw.help,
}
}
}

impl<T> Config<T> {
Expand Down Expand Up @@ -439,7 +465,7 @@ impl<T> Config<T> {
}

impl Item {
/// Create a new `Item` with the given list of key expressions and a
/// Creates a new `Item` with the given list of key expressions and a
/// description.
///
/// # Parameters
Expand All @@ -454,7 +480,25 @@ impl Item {
/// let item = Item::new(vec!["c".into(), "x y".into()], "Some command".into());
/// ```
pub fn new(keys: Vec<String>, description: String) -> Self {
Self { keys, description }
let symbol = keys.first().cloned();
Self {
keys,
description,
symbol,
help: None,
}
}

/// Sets a custom key symbol for display.
pub fn with_symbol<S: Into<String>>(mut self, symbol: Option<S>) -> Self {
self.symbol = symbol.map(Into::into);
self
}

/// Sets a custom help text.
pub fn with_help<S: Into<String>>(mut self, help: Option<S>) -> Self {
self.help = help.map(Into::into);
self
}
}

Expand Down Expand Up @@ -576,6 +620,9 @@ where
if item.description.is_empty() {
item.description = config.items[pos].1.description.clone();
}
if item.help.is_none() {
item.help = config.items[pos].1.help.clone();
}
config.items[pos].1 = item;
} else {
// Append a new entry
Expand Down
Loading