Write procedural macro to reduce boiler plate for actions

This commit is contained in:
2025-12-31 18:07:13 +02:00
parent a168c00ee7
commit 498cb43390
9 changed files with 389 additions and 234 deletions

12
macros/Cargo.toml Normal file
View File

@@ -0,0 +1,12 @@
[package]
name = "brittling_macros"
version = "0.1.0"
edition = "2024"
[lib]
proc-macro = true
[dependencies]
quote = "1.0.42"
syn = "2.0.112"

126
macros/src/lib.rs Normal file
View File

@@ -0,0 +1,126 @@
// TODO: Clean this up
use proc_macro::TokenStream;
use quote::{format_ident, quote};
use syn::{FnArg, ImplItem, ItemImpl, Pat, Type, parse_macro_input};
#[proc_macro_attribute]
pub fn component(attr: TokenStream, item: TokenStream) -> TokenStream {
let mut input = parse_macro_input!(item as ItemImpl);
let struct_name = &input.self_ty;
// Use the attribute argument as the Enum name,
// or fallback to the [Struct]Action logic if empty.
let enum_name = if !attr.is_empty() {
format_ident!("{}", attr.to_string().trim())
} else {
let struct_name_str = quote!(#struct_name).to_string().replace(" ", "");
format_ident!("{}Action", struct_name_str)
};
let mut enum_variants = Vec::new();
let mut match_arms = Vec::new();
for item in &mut input.items {
if let ImplItem::Fn(method) = item {
// Check for #[action]
let has_action = method
.attrs
.iter()
.any(|attr| attr.path().is_ident("action"));
if has_action {
// Remove the #[action] attribute so it doesn't cause compilation errors
method.attrs.retain(|attr| !attr.path().is_ident("action"));
let method_name = &method.sig.ident;
let variant_name = format_ident!(
"{}",
snake_to_pascal(&method_name.to_string())
);
let mut variant_fields = Vec::new();
let mut call_args = Vec::new();
for arg in method.sig.inputs.iter().skip(1) {
// Skip 'self'
if let FnArg::Typed(pat_type) = arg {
if is_sender_type(&pat_type.ty) {
call_args.push(quote!(tx));
continue;
}
let ty = &pat_type.ty;
variant_fields.push(quote!(#ty));
if let Pat::Ident(pi) = &*pat_type.pat {
let arg_ident = &pi.ident;
call_args.push(quote!(#arg_ident));
}
}
}
if variant_fields.is_empty() {
enum_variants.push(quote!(#variant_name));
match_arms.push(quote! {
#enum_name::#variant_name => self.#method_name(tx)
});
} else {
enum_variants
.push(quote!(#variant_name(#(#variant_fields),*)));
// Extracting bindings for the match arm: Enum::Var(a, b) -> self.method(a, b, tx)
let pattern_args = (0..variant_fields.len())
.map(|i| format_ident!("arg_{}", i));
let pattern_args_clone = pattern_args.clone();
match_arms.push(quote! {
#enum_name::#variant_name(#(#pattern_args),*) => self.#method_name(#(#pattern_args_clone,)* tx)
});
}
}
}
}
let expanded = quote! {
#input
#[derive(::core::clone::Clone, ::core::fmt::Debug)]
pub enum #enum_name {
#(#enum_variants),*
}
impl crate::app::common::Component<#enum_name> for #struct_name {
fn handle_action(
&mut self,
action: #enum_name,
tx: &'static ::tokio::sync::mpsc::UnboundedSender<crate::app::Action>,
) -> ::core::result::Result<(), ::tokio::sync::mpsc::error::SendError<crate::app::Action>> {
match action {
#(#match_arms),*
}
}
}
};
TokenStream::from(expanded)
}
fn snake_to_pascal(s: &str) -> String {
s.split('_')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(f) => {
f.to_uppercase().collect::<String>() + chars.as_str()
}
}
})
.collect()
}
// Helper to detect if an argument is the Sender to inject it from handle_action
fn is_sender_type(ty: &Type) -> bool {
let s = quote!(#ty).to_string();
s.contains("UnboundedSender")
}