use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use syn::{
parse::{Parse, ParseStream},
punctuated::Punctuated,
Expr, ExprLit, Ident, ItemFn, Lit, Meta, Token,
};
#[derive(Debug)]
struct OnArgs {
message_type: Ident,
selector: Option<String>,
}
impl Parse for OnArgs {
fn parse(input: ParseStream) -> syn::Result<Self> {
let message_type: Ident = input.parse()?;
let selector = if input.peek(Token![,]) {
input.parse::<Token![,]>()?;
let metas = Punctuated::<Meta, Token![,]>::parse_terminated(input)?;
let mut sel: Option<String> = None;
for meta in &metas {
match meta {
Meta::NameValue(nv) if nv.path.is_ident("selector") => {
if sel.is_some() {
return Err(syn::Error::new_spanned(
nv,
"duplicate `selector` argument",
));
}
if let Expr::Lit(ExprLit {
lit: Lit::Str(lit_str),
..
}) = &nv.value
{
sel = Some(lit_str.value());
} else {
return Err(syn::Error::new_spanned(
&nv.value,
"expected a string literal for `selector`",
));
}
}
other => {
return Err(syn::Error::new_spanned(
other,
"unknown argument; expected `selector = \"...\"`",
));
}
}
}
sel
} else {
None
};
Ok(OnArgs {
message_type,
selector,
})
}
}
pub fn on_handler_impl(attr: TokenStream, item: TokenStream) -> TokenStream {
let args: OnArgs = match syn::parse2(attr) {
Ok(a) => a,
Err(e) => return e.to_compile_error(),
};
let func: ItemFn = match syn::parse2(item) {
Ok(f) => f,
Err(e) => return e.to_compile_error(),
};
let fn_name = &func.sig.ident;
let dispatch_name = format_ident!("__on_dispatch_{}", fn_name);
let msg_variant = &args.message_type;
let call_expr = quote! {
self.#fn_name(payload, ctx);
};
let (selector_items, selector_gate) = if let Some(ref selector_str) = args.selector {
let selector_const = format_ident!("__ON_SELECTOR_{}", fn_name.to_string().to_uppercase());
let items = quote! {
#[doc(hidden)]
#[allow(non_upper_case_globals)]
const #selector_const: &str = #selector_str;
};
let gate = quote! {
{
static __ON_SELECTOR_PARSED:
::std::sync::OnceLock<textual::routing::Selector> =
::std::sync::OnceLock::new();
let __on_selector = __ON_SELECTOR_PARSED.get_or_init(|| {
textual::routing::Selector::parse(#selector_str).unwrap_or_else(|err| {
panic!(
"#[on(..., selector = {:?})]: invalid selector: {}",
#selector_str, err
)
})
});
let __on_matches = textual::message::Message::control_meta(payload)
.map(|meta| __on_selector.matches(&meta))
.unwrap_or(false);
if !__on_matches {
return false;
}
}
};
(items, gate)
} else {
(quote! {}, quote! {})
};
let expanded = quote! {
#func
#selector_items
#[doc(hidden)]
#[allow(non_snake_case)]
fn #dispatch_name(
&mut self,
event: &textual::message::MessageEvent,
ctx: &mut textual::event::WidgetCtx,
) -> bool {
if let Some(payload) = event.downcast_ref::<#msg_variant>() {
#selector_gate
#call_expr
return true;
}
false
}
};
expanded
}
#[cfg(test)]
mod tests {
use super::*;
use quote::quote;
#[test]
fn parse_type_only() {
let attr: TokenStream = quote! { ButtonPressed };
let args: OnArgs = syn::parse2(attr).unwrap();
assert_eq!(args.message_type, "ButtonPressed");
assert!(args.selector.is_none());
}
#[test]
fn parse_type_with_selector() {
let attr: TokenStream = quote! { ButtonPressed, selector = "#save" };
let args: OnArgs = syn::parse2(attr).unwrap();
assert_eq!(args.message_type, "ButtonPressed");
assert_eq!(args.selector.as_deref(), Some("#save"));
}
#[test]
fn parse_unknown_arg_errors() {
let attr: TokenStream = quote! { ButtonPressed, unknown = "foo" };
let result: Result<OnArgs, _> = syn::parse2(attr);
assert!(result.is_err());
}
#[test]
fn parse_duplicate_selector_errors() {
let attr: TokenStream = quote! { ButtonPressed, selector = "#a", selector = "#b" };
let result: Result<OnArgs, _> = syn::parse2(attr);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("duplicate"),
"expected 'duplicate' in error: {err_msg}"
);
}
#[test]
fn generates_dispatch_no_selector() {
let attr = quote! { ButtonPressed };
let item = quote! {
fn handle_button(&mut self, event: &ButtonPressed, ctx: &mut EventCtx) {
}
};
let output = on_handler_impl(attr, item);
let output_str = output.to_string();
assert!(output_str.contains("__on_dispatch_handle_button"));
assert!(output_str.contains("ButtonPressed"));
assert!(!output_str.contains("__ON_SELECTOR"));
assert!(!output_str.contains("control_meta"));
assert!(output_str.contains("MessageEvent"));
assert!(output_str.contains("WidgetCtx"));
assert!(!output_str.contains("_sender"));
}
#[test]
fn dispatcher_carries_no_dead_code_allow() {
let output = on_handler_impl(
quote! { ButtonPressed },
quote! { fn handle_button(&mut self, event: &ButtonPressed, ctx: &mut WidgetCtx) {} },
)
.to_string();
assert!(
!output.contains("dead_code"),
"generated __on_dispatch_* must not suppress the unused-method warning: {output}"
);
}
#[test]
fn generates_dispatch_with_selector() {
let attr = quote! { ButtonPressed, selector = "#save" };
let item = quote! {
fn handle_save(&mut self, event: &ButtonPressed, ctx: &mut EventCtx) {
}
};
let output = on_handler_impl(attr, item);
let output_str = output.to_string();
assert!(output_str.contains("__on_dispatch_handle_save"));
assert!(output_str.contains("__ON_SELECTOR_HANDLE_SAVE"));
assert!(output_str.contains("\"#save\""));
assert!(output_str.contains("control_meta"));
assert!(output_str.contains("Selector"));
assert!(output_str.contains("MessageEvent"));
assert!(output_str.contains("WidgetCtx"));
assert!(!output_str.contains("_sender"));
}
}