Files
hemx/hemx-derive/src/lib.rs
T
slhx agent e7211df4c5 fix(forms): support reserved control identifiers
Normalize raw Rust identifiers when matching generated form contracts so reserved HTML control names remain usable.

req: form/004
2026-07-16 10:00:43 +02:00

1204 lines
40 KiB
Rust

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<bool>,
event_key: ::std::option::Option<::std::string::String>,
state_version: u32,
encoded_state: ::std::string::String,
) -> ::std::result::Result<::std::vec::Vec<u8>, ::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<Self, ::hemx::FormError> {
Ok(Self {
#(#decode_fields),*
})
}
}
impl #impl_generics #ident #ty_generics #where_clause {
pub const FORM: ::hemx::Form<Self> = ::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::<Path, Token![,]>::parse_terminated.parse(attr) {
Ok(components) => components.into_iter().collect::<Vec<_>>(),
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<PathBuf> {
generated_path("hemx.syms")
}
fn generated_path(file: &str) -> Option<PathBuf> {
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<Type> {
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<Type> {
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<String> {
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::<Vec<_>>();
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<Type> {
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<proc_macro2::TokenStream> {
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::<Vec<_>>();
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::<Result<Vec<_>, _>>()?
}
} 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<u32> {
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<GeneratedFormField> {
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<String> {
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<String> {
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<String> {
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<String> {
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<Path>) -> Result<ItemFn, String> {
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<String> {
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::<Vec<_>>();
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::<Vec<_>>();
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<String> {
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<String> {
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<Type> {
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<String> {
component_handler_idents(items)
.into_iter()
.map(|handler| handler.ident.to_string())
.collect()
}
fn component_handler_idents(items: &[Item]) -> Vec<ComponentHandler> {
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<String> {
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<String> {
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::<Vec<_>>();
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<String>) {}
);
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<Email>);
let multiple: Type = parse_quote!(Vec<Email>);
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<CreateTodo>);
let imported_form: Type = parse_quote!(Form<CreateTodo>);
let name_suffix_impostor: Type = parse_quote!(CreateTodoForm);
let nongeneric_impostor: Type = parse_quote!(Form);
let foreign_form: Type = parse_quote!(other::Form<CreateTodo>);
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<App>) -> 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<impl hemx::IntoEffect, super::Error> {
Ok(hemx::EventName::new("sync-result").emit(""))
}
#[hemx::handler]
async fn async_result(app: super::App) -> Result<impl hemx::IntoEffect, super::Error> {
Ok(hemx::EventName::new("async-result").emit(""))
}
#[hemx::handler]
async fn async_form_result(app: super::App, form: super::NewTodo) -> Result<impl hemx::IntoEffect, super::Error> {
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}");
}
}