use proc_macro::TokenStream; use proc_macro2::TokenStream as TokenStream2; use quote::{format_ident, quote}; use std::path::PathBuf; use syn::parse::Parser; use syn::punctuated::Punctuated; use syn::{ parse_macro_input, parse_quote, Fields, FnArg, GenericArgument, Item, ItemFn, ItemMod, ItemStruct, LitStr, Pat, Path, PathArguments, ReturnType, Token, Type, }; #[proc_macro_attribute] pub fn handler(attr: TokenStream, item: TokenStream) -> TokenStream { let placement = attr.to_string(); let is_client = match placement.as_str() { "" => false, "client" => true, _ => { return syn::Error::new( proc_macro2::Span::call_site(), "unsupported hemx handler placement; expected #[hemx::handler] or #[hemx::handler(client)]", ) .into_compile_error() .into(); } }; let function = parse_macro_input!(item as ItemFn); let name = function.sig.ident.to_string(); let Some(syms_path) = syms_path() else { let message = "#[hemx::handler] requires generated hemx files; add hemx_build::app().run()? to build.rs or run inside a Cargo crate"; return quote!( #function compile_error!(#message); ) .into(); }; if !syms_path.exists() { let message = "#[hemx::handler] could not find generated hemx symbols; add hemx_build::app().run()? to build.rs or check template generation"; return quote!( #function compile_error!(#message); ) .into(); } if !syms_contains_handle(&syms_path, &name) { let message = format!( "unknown hemx handle `{name}`; add `data-hemx-handle=\"{name}\"` to a template or rename this handler" ); return quote!( #function compile_error!(#message); ) .into(); } if !has_form_param(&function) && !has_non_unit_return(&function) { let message = format!( "hemx handler `{name}` must accept a form/context parameter or return a value implementing IntoEffect" ); return quote!( #function compile_error!(#message); ) .into(); } if handle_requires_form(&syms_path, &name) && !has_form_param(&function) { let message = format!( "hemx handler `{name}` handles a generated form and must accept a typed form argument" ); return quote!( #function compile_error!(#message); ) .into(); } let missing_params = missing_handle_params(&syms_path, &name, &function); if !missing_params.is_empty() { let message = format!( "hemx handler `{name}` is missing generated param argument(s): {}", missing_params.join(", ") ); return quote!( #function compile_error!(#message); ) .into(); } if !is_client { return quote!(#function).into(); } let input_count = function.sig.inputs.len(); if !matches!(input_count, 0 | 2) || function.sig.asyncness.is_some() || function.sig.unsafety.is_some() || function.sig.constness.is_some() || !function.sig.generics.params.is_empty() { let message = format!( "client-local hemx handler `{name}` must be safe, synchronous, non-generic, and accept either no parameters or `(hemx::wasm::ClientEvent, hemx::wasm::ClientState)`" ); return quote!( #function compile_error!(#message); ) .into(); } let function_name = &function.sig.ident; let export_name = format_ident!("__hemx_client_{function_name}"); let export_module = format_ident!("__hemx_client_export_{function_name}"); let invoke_handler = if input_count == 0 { quote!(super::#function_name()) } else { quote!(super::#function_name(event, state)) }; quote!( #function #[cfg(target_arch = "wasm32")] mod #export_module { use ::hemx::wasm as wasm_bindgen; #[::hemx::wasm::wasm_bindgen(js_name = #export_name)] #[allow(clippy::too_many_arguments)] pub fn invoke( event_version: u32, event_kind: ::std::string::String, event_value: ::std::option::Option<::std::string::String>, event_checked: ::std::option::Option, event_key: ::std::option::Option<::std::string::String>, state_version: u32, encoded_state: ::std::string::String, ) -> ::std::result::Result<::std::vec::Vec, ::hemx::wasm::JsValue> { let (event, state) = ::hemx::wasm::decode_client_inputs( event_version, event_kind, event_value, event_checked, event_key, state_version, encoded_state, ) .map_err(|error| ::hemx::wasm::JsValue::from_str(&error))?; Ok(::hemx::wasm::encode_handler_effect( #invoke_handler, crate::ui::BUILD_FINGERPRINT, )) } } ) .into() } #[proc_macro_attribute] pub fn surface(_attr: TokenStream, item: TokenStream) -> TokenStream { inject_surface_include(item) } #[proc_macro_attribute] pub fn form(attr: TokenStream, item: TokenStream) -> TokenStream { let form_name = parse_macro_input!(attr as LitStr).value(); let form_struct = parse_macro_input!(item as ItemStruct); let Some(syms_path) = syms_path() else { let message = "#[hemx::form] requires generated hemx files; add hemx_build::app().run()? to build.rs or run inside a Cargo crate"; return quote!( #form_struct compile_error!(#message); ) .into(); }; let errors = form_contract_errors(&syms_path, &form_name, &form_struct); if errors.is_empty() { let ident = &form_struct.ident; let resource_id = form_resource_id(&syms_path, &form_name).expect("checked form exists in hemx.syms"); let mut generics = form_struct.generics.clone(); for ty in form_parser_types(&form_struct) { generics .make_where_clause() .predicates .push(parse_quote!(#ty: ::hemx::FormValue)); } let decode_fields = form_decode_fields(&syms_path, &form_name, &form_struct); let (impl_generics, ty_generics, where_clause) = generics.split_for_impl(); quote!( #form_struct impl #impl_generics ::hemx::FormModel for #ident #ty_generics #where_clause {} impl #impl_generics ::hemx::FromForm for #ident #ty_generics #where_clause { fn from_form_fields( __hemx_fields: &[(String, String)], ) -> Result { Ok(Self { #(#decode_fields),* }) } } impl #impl_generics #ident #ty_generics #where_clause { pub const FORM: ::hemx::Form = ::hemx::Form::new(#resource_id); } ) .into() } else { let message = errors.join("; "); quote!( #form_struct compile_error!(#message); ) .into() } } #[proc_macro_attribute] pub fn component(attr: TokenStream, item: TokenStream) -> TokenStream { let component_name = if attr.is_empty() { None } else { Some(parse_macro_input!(attr as LitStr).value()) }; let module = parse_macro_input!(item as ItemMod); let Some((_, items)) = &module.content else { return quote!( #module compile_error!("#[hemx::component] must be used on an inline module"); ) .into(); }; let Some(syms_path) = syms_path() else { return quote!(#module).into(); }; if !syms_path.exists() { return quote!(#module).into(); } let component_filter = component_name.as_deref(); let errors = component_contract_errors(&syms_path, component_filter, items); if !errors.is_empty() { let message = errors.join("; "); return quote!( #module compile_error!(#message); ) .into(); } let module = match component_name.as_deref() { Some(component) => add_component_register_helper(module, component), None => module, }; quote!(#module).into() } #[proc_macro_attribute] pub fn app(attr: TokenStream, item: TokenStream) -> TokenStream { let components = match Punctuated::::parse_terminated.parse(attr) { Ok(components) => components.into_iter().collect::>(), Err(error) => return error.to_compile_error().into(), }; let function = parse_macro_input!(item as ItemFn); match add_app_registry_helper(function, components) { Ok(function) => quote!(#function).into(), Err(message) => quote!(compile_error!(#message);).into(), } } fn inject_surface_include(item: TokenStream) -> TokenStream { let item = item.to_string(); let Some(insert_at) = item.rfind('}') else { return compile_error("#[hemx::surface] must be used on an inline module"); }; let include = surface_include(); let expanded = format!("{}{}{}", &item[..insert_at], include, &item[insert_at..]); expanded .parse() .unwrap_or_else(|_| compile_error("#[hemx::surface] could not expand this module")) } fn surface_include() -> String { let Some(path) = generated_path("hemx.generated.rs") else { return format!( " compile_error!({:?}); ", "#[hemx::surface] requires generated hemx files; add hemx_build::app().run()? to build.rs or run inside a Cargo crate" ); }; if path.exists() { format!(" include!({:?}); ", path.display().to_string()) } else { format!( " compile_error!({:?}); ", format!( "#[hemx::surface] could not find generated hemx module; add hemx_build::app().run()? to build.rs or check template generation" ) ) } } fn syms_path() -> Option { generated_path("hemx.syms") } fn generated_path(file: &str) -> Option { std::env::var_os("OUT_DIR").map(|out_dir| PathBuf::from(out_dir).join(file)) } fn has_form_param(function: &ItemFn) -> bool { handler_form_model_type(function).is_some() } fn form_model_type(ty: &Type) -> Option { let Type::Path(path) = ty else { return None; }; let mut segments = path.path.segments.iter(); let first = segments.next()?; let last = path .path .segments .last() .expect("path has at least one segment"); let path_is_form = if path.path.segments.len() == 1 { first.ident == "Form" } else { first.ident == "hemx" && last.ident == "Form" }; if !path_is_form { return None; } let PathArguments::AngleBracketed(args) = &last.arguments else { return None; }; args.args.iter().find_map(|arg| match arg { GenericArgument::Type(ty) => Some(ty.clone()), _ => None, }) } fn handler_form_model_type(function: &ItemFn) -> Option { function.sig.inputs.iter().rev().find_map(|arg| match arg { FnArg::Typed(arg) => form_model_type(&arg.ty), FnArg::Receiver(_) => None, }) } fn has_non_unit_return(function: &ItemFn) -> bool { match &function.sig.output { ReturnType::Default => false, ReturnType::Type(_, ty) => { !matches!(ty.as_ref(), Type::Tuple(tuple) if tuple.elems.is_empty()) } } } fn returns_result(output: &ReturnType) -> bool { match output { ReturnType::Type(_, ty) => is_type_named(ty, "Result"), ReturnType::Default => false, } } fn syms_contains_handle(path: &PathBuf, ident: &str) -> bool { let Ok(syms) = std::fs::read_to_string(path) else { return true; }; syms.lines().any(|line| { let mut fields = line.split('\t'); matches!(fields.next(), Some("handle")) && fields .nth(1) .is_some_and(|handle_ident| handle_ident == ident) }) } fn handle_requires_form(path: &PathBuf, ident: &str) -> bool { let Ok(syms) = std::fs::read_to_string(path) else { return false; }; syms.lines().any(|line| { let mut fields = line.split('\t'); matches!(fields.next(), Some("handle_form")) && fields .next() .is_some_and(|handle_ident| handle_ident == ident) }) } #[derive(Debug, Eq, PartialEq)] struct GeneratedFormField { name: String, ident: String, required: bool, multiple: bool, } fn form_contract_errors( syms_path: &PathBuf, form_name: &str, form_struct: &ItemStruct, ) -> Vec { if !syms_path.exists() { return vec![ "#[hemx::form] could not find generated hemx symbols; add hemx_build::app().run()? to build.rs or check template generation" .to_owned(), ]; } let expected = form_fields(syms_path, form_name); if expected.is_empty() { return vec![format!( "unknown hemx form `{form_name}`; add data-hemx-form=\"{form_name}\" to a template or rename this form binding" )]; } let Fields::Named(fields) = &form_struct.fields else { return vec![format!( "hemx form `{form_name}` must be a struct with named fields" )]; }; let actual = fields .named .iter() .filter_map(|field| { field .ident .as_ref() .map(|ident| (form_field_name(ident), &field.ty)) }) .collect::>(); let mut errors = Vec::new(); for field in expected { let Some((_, ty)) = actual.iter().find(|(ident, _)| ident == &field.ident) else { errors.push(format!( "hemx form `{form_name}` is missing field `{}` for form control `{}`", field.ident, field.name )); continue; }; let optional = is_type_named(ty, "Option"); let multiple = is_type_named(ty, "Vec"); if field.required && optional { errors.push(format!( "hemx form `{form_name}` field `{}` is required in HTML and must not be Option<_>", field.ident )); } if field.multiple && !multiple { errors.push(format!( "hemx form `{form_name}` field `{}` accepts multiple values and must be Vec<_>", field.ident )); } if !field.multiple && multiple { errors.push(format!( "hemx form `{form_name}` field `{}` accepts one value and must not be Vec<_>", field.ident )); } } errors } fn form_parser_types(form_struct: &ItemStruct) -> Vec { let Fields::Named(fields) = &form_struct.fields else { return Vec::new(); }; fields .named .iter() .map(|field| parser_type(&field.ty).clone()) .collect() } fn form_field_name(ident: &syn::Ident) -> String { ident.to_string().trim_start_matches("r#").to_owned() } fn form_decode_fields( syms_path: &PathBuf, form_name: &str, form_struct: &ItemStruct, ) -> Vec { let Fields::Named(fields) = &form_struct.fields else { return Vec::new(); }; let actual = fields .named .iter() .filter_map(|field| field.ident.as_ref().map(|ident| (ident, &field.ty))) .collect::>(); form_fields(syms_path, form_name) .into_iter() .filter_map(|field| { let (ident, ty) = actual .iter() .find(|(ident, _)| form_field_name(ident) == field.ident)?; let control_name = field.name; let parser = parser_type(ty); Some(if field.multiple { quote! { #ident: __hemx_fields .iter() .filter_map(|(__hemx_name, __hemx_value)| (__hemx_name == #control_name).then_some(__hemx_value.as_str()) ) .map(|__hemx_value| { <#parser as ::hemx::FormValue>::parse_form_value(__hemx_value) .map_err(|_| ::hemx::FormError::new(format!("invalid form field `{}`", #control_name))) }) .collect::, _>>()? } } else if is_type_named(ty, "Option") { quote! { #ident: match __hemx_fields .iter() .find_map(|(__hemx_name, __hemx_value)| (__hemx_name == #control_name).then_some(__hemx_value.as_str()) ) { Some(__hemx_value) => Some( <#parser as ::hemx::FormValue>::parse_form_value(__hemx_value) .map_err(|_| ::hemx::FormError::new(format!("invalid form field `{}`", #control_name)))? ), None => None, } } } else { quote! { #ident: { let Some(__hemx_value) = __hemx_fields .iter() .find_map(|(__hemx_name, __hemx_value)| (__hemx_name == #control_name).then_some(__hemx_value.as_str()) ) else { return Err(::hemx::FormError::new(format!("missing form field `{}`", #control_name))); }; <#parser as ::hemx::FormValue>::parse_form_value(__hemx_value) .map_err(|_| ::hemx::FormError::new(format!("invalid form field `{}`", #control_name)))? } } }) }) .collect() } fn parser_type(ty: &Type) -> &Type { generic_inner_type(ty, "Option") .or_else(|| generic_inner_type(ty, "Vec")) .unwrap_or(ty) } fn generic_inner_type<'a>(ty: &'a Type, name: &str) -> Option<&'a Type> { let Type::Path(path) = ty else { return None; }; let segment = path.path.segments.last()?; if segment.ident != name { return None; } let PathArguments::AngleBracketed(args) = &segment.arguments else { return None; }; args.args.iter().find_map(|arg| match arg { GenericArgument::Type(ty) => Some(ty), _ => None, }) } fn form_resource_id(path: &PathBuf, form_name: &str) -> Option { let syms = std::fs::read_to_string(path).ok()?; syms.lines().find_map(|line| { let mut fields = line.split('\t'); if !matches!(fields.next(), Some("form")) { return None; } if fields.nth(1)? != form_name { return None; } fields.next()?.parse().ok() }) } fn form_fields(path: &PathBuf, form_name: &str) -> Vec { let Ok(syms) = std::fs::read_to_string(path) else { return Vec::new(); }; syms.lines() .filter_map(|line| { let mut fields = line.split('\t'); if !matches!(fields.next(), Some("form_field")) { return None; } if fields.next()? != form_name { return None; } let name = fields.next()?.to_string(); Some(GeneratedFormField { ident: rust_ident(&name)?, name, required: fields.next() == Some("true"), multiple: fields.next() == Some("true"), }) }) .collect() } fn is_type_named(ty: &Type, name: &str) -> bool { match ty { Type::Path(path) => path .path .segments .last() .is_some_and(|segment| segment.ident == name), _ => false, } } fn rust_ident(name: &str) -> Option { let mut out = String::new(); for ch in name.chars() { if ch == '-' || ch == '_' || ch.is_ascii_alphanumeric() { out.push(if ch == '-' { '_' } else { ch }); } else { return None; } } let first = out.chars().next()?; if !(first == '_' || first.is_ascii_alphabetic()) { return None; } Some(out) } fn missing_handle_params(path: &PathBuf, ident: &str, function: &ItemFn) -> Vec { let required = handle_params(path, ident); if required.is_empty() { return Vec::new(); } let args = handler_arg_names(function); required .into_iter() .filter(|param| !args.contains(param)) .collect() } fn handle_params(path: &PathBuf, ident: &str) -> Vec { let Ok(syms) = std::fs::read_to_string(path) else { return Vec::new(); }; syms.lines() .filter_map(|line| { let mut fields = line.split('\t'); if !matches!(fields.next(), Some("handle_param")) { return None; } if fields.next()? != ident { return None; } fields.next().map(ToOwned::to_owned) }) .collect() } fn handler_arg_names(function: &ItemFn) -> Vec { function .sig .inputs .iter() .filter_map(|arg| match arg { FnArg::Typed(arg) => match arg.pat.as_ref() { Pat::Ident(ident) => Some(ident.ident.to_string()), _ => None, }, FnArg::Receiver(_) => None, }) .collect() } fn add_app_registry_helper(mut function: ItemFn, components: Vec) -> Result { if components.is_empty() { return Err("#[hemx::app] requires component registry module(s), for example #[hemx::app(todo_handlers, auth_handlers)]".to_owned()); } let Some(state) = function.sig.inputs.iter().find_map(|arg| match arg { FnArg::Typed(arg) => match arg.pat.as_ref() { Pat::Ident(ident) => Some(ident.ident.clone()), _ => None, }, FnArg::Receiver(_) => None, }) else { return Err( "#[hemx::app] must be used on a registry function with a named app state argument" .to_owned(), ); }; let body = function.block; function.block = syn::parse2(quote!({ let __hemx_registry = (|| #body)(); #( let __hemx_registry = #components::register_with_state( __hemx_registry, ::hemx_axum::State(#state.clone()), ); )* __hemx_registry })) .expect("generated app registry helper parses"); Ok(function) } struct ComponentHandler { ident: syn::Ident, is_async: bool, returns_result: bool, typed_arg_count: usize, } fn add_component_register_helper(mut module: ItemMod, component: &str) -> ItemMod { let Some((_, items)) = &mut module.content else { return module; }; if items.iter().any(|item| match item { Item::Fn(function) => { function.sig.ident == "register" || function.sig.ident == "register_with_state" } _ => false, }) { return module; } let component_ident = format_ident!("{}", component); let handlers = component_handler_idents(items); let Some(state_ty) = component_state_type(items) else { return module; }; if handlers.is_empty() { return module; } let calls = handlers .iter() .map(|handler| component_registration_call(handler, &component_ident)); let register: Item = syn::parse2(quote! { pub fn register( registry: ::hemx_axum::StateHandlerRegistry<#state_ty>, ) -> ::hemx_axum::StateHandlerRegistry<#state_ty> where #state_ty: Clone + Send + Sync + 'static, { registry #(#calls)* } }) .expect("generated component register helper parses"); let calls = handlers .iter() .map(|handler| component_registration_call(handler, &component_ident)); let register_with_state: Item = syn::parse2(quote! { pub fn register_with_state( registry: ::hemx_axum::HandlerRegistry, state: #state_ty, ) -> ::hemx_axum::HandlerRegistry where #state_ty: Clone + Send + Sync + 'static, { registry .with_state(state) #(#calls)* .into_registry() } }) .expect("generated component state register helper parses"); items.push(register); items.push(register_with_state); module } fn component_registration_call( handler: &ComponentHandler, component_ident: &syn::Ident, ) -> TokenStream2 { let ident = &handler.ident; if handler.typed_arg_count == 1 && handler.is_async && handler.returns_result { quote!(.on_state_async_result(super::#component_ident::#ident, #ident)) } else if handler.typed_arg_count == 1 && handler.returns_result { quote!(.on_state_result(super::#component_ident::#ident, #ident)) } else if handler.typed_arg_count == 1 && handler.is_async { quote!(.on_state_async(super::#component_ident::#ident, #ident)) } else if handler.typed_arg_count == 1 { quote!(.on_state(super::#component_ident::#ident, #ident)) } else if handler.is_async && handler.returns_result { quote!(.on_async_result(super::#component_ident::#ident, #ident)) } else if handler.returns_result { quote!(.on_result(super::#component_ident::#ident, #ident)) } else if handler.is_async { quote!(.on_async(super::#component_ident::#ident, #ident)) } else { quote!(.on(super::#component_ident::#ident, #ident)) } } fn component_contract_errors( path: &PathBuf, component: Option<&str>, items: &[Item], ) -> Vec { let generated = syms_handles(path, component); let implemented = component_handler_names(items); let mut errors = Vec::new(); if let Some(component) = component.filter(|_| !implemented.is_empty() && generated.is_empty()) { let available = syms_components(path); let repair = if available.is_empty() { "no generated components with handles are available; add a data-hemx-handle to the component template or check build.rs generation".to_owned() } else { format!("available generated components: {}", available.join(", ")) }; errors.push(format!( "#[hemx::component({component:?})] does not match any generated handles; {repair}" )); } let ambiguous = duplicate_names(&generated); if !ambiguous.is_empty() { errors.push(format!( "#[hemx::component] ambiguous generated handle name(s): {}; make handle names unique for this component before generated registration", ambiguous.join(", ") )); } let missing = generated .iter() .filter(|handle| !implemented.contains(handle)) .cloned() .collect::>(); if !missing.is_empty() { errors.push(format!( "#[hemx::component] missing handler implementation(s): {}", missing.join(", ") )); } let extras = implemented .iter() .filter(|handler| !generated.contains(handler)) .cloned() .collect::>(); if !extras.is_empty() { errors.push(format!( "#[hemx::component] handler(s) not declared by this component's generated handles: {}; move them to the matching component module or add data-hemx-handle in .heml", extras.join(", ") )); } errors } #[cfg(test)] fn missing_component_handlers( path: &PathBuf, component: Option<&str>, items: &[Item], ) -> Vec { component_contract_errors(path, component, items) .into_iter() .find_map(|error| { error .strip_prefix("#[hemx::component] missing handler implementation(s): ") .map(|missing| missing.split(", ").map(ToOwned::to_owned).collect()) }) .unwrap_or_default() } fn duplicate_names(names: &[String]) -> Vec { let mut counts = std::collections::BTreeMap::<&str, usize>::new(); for name in names { *counts.entry(name.as_str()).or_default() += 1; } counts .into_iter() .filter(|(_, count)| *count > 1) .map(|(name, _)| name.to_owned()) .collect() } fn component_state_type(items: &[Item]) -> Option { items.iter().find_map(|item| match item { Item::Fn(function) if has_handler_attr(function) => { function.sig.inputs.iter().find_map(|arg| match arg { FnArg::Typed(arg) => Some((*arg.ty).clone()), FnArg::Receiver(_) => None, }) } _ => None, }) } fn component_handler_names(items: &[Item]) -> Vec { component_handler_idents(items) .into_iter() .map(|handler| handler.ident.to_string()) .collect() } fn component_handler_idents(items: &[Item]) -> Vec { items .iter() .filter_map(|item| match item { Item::Fn(function) if has_handler_attr(function) => Some(ComponentHandler { ident: function.sig.ident.clone(), is_async: function.sig.asyncness.is_some(), returns_result: returns_result(&function.sig.output), typed_arg_count: function .sig .inputs .iter() .filter(|arg| matches!(arg, FnArg::Typed(_))) .count(), }), _ => None, }) .collect() } fn has_handler_attr(function: &ItemFn) -> bool { function.attrs.iter().any(|attr| { attr.path() .segments .last() .is_some_and(|segment| segment.ident == "handler") }) } fn syms_handles(path: &PathBuf, component: Option<&str>) -> Vec { let Ok(syms) = std::fs::read_to_string(path) else { return Vec::new(); }; syms.lines() .filter_map(|line| { let mut fields = line.split('\t'); if !matches!(fields.next(), Some("handle")) { return None; } let symbol = fields.next()?; if let Some(component) = component { if symbol_component(symbol) != Some(component) { return None; } } fields.next().map(ToOwned::to_owned) }) .collect() } fn syms_components(path: &PathBuf) -> Vec { let Ok(syms) = std::fs::read_to_string(path) else { return Vec::new(); }; let mut components = syms .lines() .filter_map(|line| { let mut fields = line.split('\t'); matches!(fields.next(), Some("handle")) .then(|| fields.next()) .flatten() .and_then(symbol_component) .map(ToOwned::to_owned) }) .collect::>(); components.sort_unstable(); components.dedup(); components } fn symbol_component(symbol: &str) -> Option<&str> { let path = symbol.split_once("::")?.0; path.rsplit_once('/') .map_or(path, |(_, stem)| stem) .strip_suffix(".heml") } fn compile_error(message: &str) -> TokenStream { format!("compile_error!({message:?});") .parse() .expect("compile_error expansion is valid") } #[cfg(test)] mod tests { use super::{ add_app_registry_helper, add_component_register_helper, component_handler_names, form_model_type, handle_params, handle_requires_form, has_form_param, has_non_unit_return, missing_component_handlers, missing_handle_params, parser_type, syms_contains_handle, }; use quote::quote; use syn::{parse_quote, ItemFn, Type}; #[test] fn syms_lookup_matches_handle_ident() { let path = std::env::temp_dir().join("hemx-derive-syms-test.syms"); std::fs::write( &path, "hemx-syms-v1\nslot\ttemplates/a.heml::count\tcount\t1\nhandle\ttemplates/a.heml::create\tcreate\t2\nhandle_form\tcreate\tnew_todo\nhandle_param\tcreate\ttodo_id\n", ) .unwrap(); assert!(syms_contains_handle(&path, "create")); assert!(!syms_contains_handle(&path, "missing")); assert!(handle_requires_form(&path, "create")); assert!(!handle_requires_form(&path, "missing")); assert_eq!(handle_params(&path, "create"), vec!["todo_id"]); assert!(handle_params(&path, "missing").is_empty()); let _ = std::fs::remove_file(path); } #[test] fn handler_shape_accepts_form_or_effect_return() { // req: derive_handler/001 let with_form = parse_quote!( fn save(form: hemx::Form) {} ); let with_return = parse_quote!( fn ping() -> impl hemx::IntoEffect { hemx::advanced::EffectBatch::default() } ); let empty = parse_quote!( fn noop() {} ); assert!(has_form_param(&with_form)); assert!(has_non_unit_return(&with_return)); assert!(!has_form_param(&empty)); assert!(!has_non_unit_return(&empty)); } #[test] fn form_parser_type_uses_option_and_vec_inner_types() { // req: form/004 let required: Type = parse_quote!(Email); let optional: Type = parse_quote!(Option); let multiple: Type = parse_quote!(Vec); let required_parser = parser_type(&required); let optional_parser = parser_type(&optional); let multiple_parser = parser_type(&multiple); assert_eq!( quote!(#required_parser).to_string(), quote!(#required).to_string() ); assert_eq!( quote!(#optional_parser).to_string(), quote!(#required).to_string() ); assert_eq!( quote!(#multiple_parser).to_string(), quote!(#required).to_string() ); } #[test] fn form_param_matches_form_type_not_name_suffix() { // req: form/004 req: form/006 let qualified_form: Type = parse_quote!(hemx::Form); let imported_form: Type = parse_quote!(Form); let name_suffix_impostor: Type = parse_quote!(CreateTodoForm); let nongeneric_impostor: Type = parse_quote!(Form); let foreign_form: Type = parse_quote!(other::Form); assert!(form_model_type(&qualified_form).is_some()); assert!(form_model_type(&imported_form).is_some()); assert!(form_model_type(&name_suffix_impostor).is_none()); assert!(form_model_type(&nongeneric_impostor).is_none()); assert!(form_model_type(&foreign_form).is_none()); } #[test] fn handler_params_match_generated_param_names() { let path = std::env::temp_dir().join("hemx-derive-param-test.syms"); std::fs::write( &path, "hemx-syms-v1\nhandle_param\tshow\ttodo_id\nhandle_param\tshow\tmode\n", ) .unwrap(); let complete: ItemFn = parse_quote!( fn show(todo_id: String, mode: String) -> impl hemx::IntoEffect { hemx::advanced::EffectBatch::default() } ); let missing: ItemFn = parse_quote!( fn show(todo_id: String) -> impl hemx::IntoEffect { hemx::advanced::EffectBatch::default() } ); assert!(missing_handle_params(&path, "show", &complete).is_empty()); assert_eq!(missing_handle_params(&path, "show", &missing), vec!["mode"]); let _ = std::fs::remove_file(path); } #[test] fn component_handlers_match_generated_handles() { let path = std::env::temp_dir().join("hemx-derive-component-test.syms"); std::fs::write( &path, "hemx-syms-v1\nhandle\ttemplates/a.heml::create\tcreate\t1\nhandle\ttemplates/a.heml::delete\tdelete\t2\nhandle\ttemplates/other.heml::archive\tarchive\t3\n", ) .unwrap(); let module: syn::ItemMod = parse_quote! { mod component { #[hemx::handler] fn create() -> impl hemx::IntoEffect { hemx::advanced::EffectBatch::default() } } }; let (_, items) = module.content.expect("inline module"); assert_eq!(component_handler_names(&items), vec!["create"]); assert_eq!( missing_component_handlers(&path, None, &items), vec!["delete", "archive"] ); assert_eq!( missing_component_handlers(&path, Some("a"), &items), vec!["delete"] ); let _ = std::fs::remove_file(path); } #[test] fn app_macro_generates_single_registry_entry_point() { // req: derive_app/001 req: component/003 let function = parse_quote! { fn registry(state: std::sync::Arc) -> hemx_axum::HandlerRegistry { hemx_axum::interactions(ui::BUILD_FINGERPRINT) } }; let function = add_app_registry_helper( function, vec![parse_quote!(counter_handlers), parse_quote!(todo_handlers)], ) .unwrap(); let generated = quote!(#function).to_string(); assert!( generated.contains("counter_handlers :: register_with_state"), "{generated}" ); assert!( generated.contains("todo_handlers :: register_with_state"), "{generated}" ); assert!(generated.contains("hemx_axum :: State"), "{generated}"); assert!(generated.contains("state . clone"), "{generated}"); } #[test] fn component_macro_generates_registration_helpers() { // req: component/003 req: derive_handler/003 let module = parse_quote! { mod handlers { #[hemx::handler] fn sync_form(app: super::App, form: super::NewTodo) -> impl hemx::IntoEffect { hemx::EventName::new("sync-form").emit("") } #[hemx::handler] async fn async_state(app: super::App) -> impl hemx::IntoEffect { hemx::EventName::new("async-state").emit("") } #[hemx::handler] fn sync_result(app: super::App) -> Result { Ok(hemx::EventName::new("sync-result").emit("")) } #[hemx::handler] async fn async_result(app: super::App) -> Result { Ok(hemx::EventName::new("async-result").emit("")) } #[hemx::handler] async fn async_form_result(app: super::App, form: super::NewTodo) -> Result { Ok(hemx::EventName::new("async-form-result").emit("")) } } }; let module = add_component_register_helper(module, "todos"); let generated = quote!(#module).to_string(); assert!(generated.contains("register_with_state"), "{generated}"); assert!(generated.contains("StateHandlerRegistry"), "{generated}"); assert!( generated.contains("super :: todos :: sync_form"), "{generated}" ); assert!(generated.contains(". on"), "{generated}"); assert!(generated.contains(". on_state_async"), "{generated}"); assert!(generated.contains(". on_state_result"), "{generated}"); assert!(generated.contains(". on_state_async_result"), "{generated}"); assert!(generated.contains(". on_async_result"), "{generated}"); } }