use proc_macro::TokenStream; use quote::quote; use std::path::PathBuf; use syn::{ parse_macro_input, parse_quote, Fields, FnArg, GenericArgument, Item, ItemFn, ItemMod, ItemStruct, LitStr, Pat, PathArguments, ReturnType, Type, }; #[proc_macro_attribute] pub fn handler(_attr: TokenStream, item: TokenStream) -> TokenStream { let mut function = parse_macro_input!(item as ItemFn); let name = function.sig.ident.to_string(); let Some(syms_path) = syms_path() else { let message = "#[slhx::handler] requires OUT_DIR; run inside a Cargo crate with slhx_build::app() in build.rs"; return quote!( #function compile_error!(#message); ) .into(); }; if !syms_path.exists() { let message = format!( "#[slhx::handler] could not find {}; add slhx_build::app().run()? to build.rs or check template generation", syms_path.display() ); return quote!( #function compile_error!(#message); ) .into(); } if !syms_contains_handle(&syms_path, &name) { let message = format!( "unknown slhx handle `{name}`; add `data-slhx-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!( "slhx 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!( "slhx handler `{name}` handles a generated form and must accept slhx::Form<_>" ); return quote!( #function compile_error!(#message); ) .into(); } if handle_requires_form(&syms_path, &name) { add_form_model_bounds(&mut function); } let missing_params = missing_handle_params(&syms_path, &name, &function); if !missing_params.is_empty() { let message = format!( "slhx handler `{name}` is missing generated param argument(s): {}", missing_params.join(", ") ); return quote!( #function compile_error!(#message); ) .into(); } quote!(#function).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 = "#[slhx::form] requires OUT_DIR; run inside a Cargo crate with slhx_build::app() in build.rs"; 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 slhx.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: ::slhx::FormValue)); } let (impl_generics, ty_generics, where_clause) = generics.split_for_impl(); quote!( #form_struct impl #impl_generics ::slhx::FormModel for #ident #ty_generics #where_clause {} impl #impl_generics #ident #ty_generics #where_clause { pub const FORM: ::slhx::Form = ::slhx::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!("#[slhx::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 missing = missing_component_handlers(&syms_path, component_filter, items); if !missing.is_empty() { let message = format!( "#[slhx::component] missing handler implementation(s): {}", missing.join(", ") ); return quote!( #module compile_error!(#message); ) .into(); } quote!(#module).into() } #[proc_macro_attribute] pub fn app(_attr: TokenStream, item: TokenStream) -> TokenStream { item } fn inject_surface_include(item: TokenStream) -> TokenStream { let item = item.to_string(); let Some(insert_at) = item.rfind('}') else { return compile_error("#[slhx::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("#[slhx::surface] could not expand this module")) } fn surface_include() -> String { let Some(path) = generated_path("slhx.generated.rs") else { return format!( " compile_error!({:?}); ", "#[slhx::surface] requires OUT_DIR; run inside a Cargo crate with slhx_build::app() in build.rs" ); }; if path.exists() { format!(" include!({:?}); ", path.display().to_string()) } else { format!( " compile_error!({:?}); ", format!( "#[slhx::surface] could not find {}; add slhx_build::app().run()? to build.rs or check template generation", path.display() ) ) } } fn syms_path() -> Option { generated_path("slhx.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 { function.sig.inputs.iter().any(|arg| match arg { FnArg::Typed(arg) => is_form_type(&arg.ty), FnArg::Receiver(_) => false, }) } fn is_form_type(ty: &Type) -> bool { form_model_type(ty).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 == "slhx" && 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 form_model_types(function: &ItemFn) -> Vec { function .sig .inputs .iter() .filter_map(|arg| match arg { FnArg::Typed(arg) => form_model_type(&arg.ty), FnArg::Receiver(_) => None, }) .collect() } fn add_form_model_bounds(function: &mut ItemFn) { for ty in form_model_types(function) { let where_clause = function.sig.generics.make_where_clause(); where_clause.predicates.push(parse_quote!(#ty: ::slhx::FormModel)); } } 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 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![format!( "#[slhx::form] could not find {}; add slhx_build::app().run()? to build.rs or check template generation", syms_path.display() )]; } let expected = form_fields(syms_path, form_name); if expected.is_empty() { return vec![format!( "unknown slhx form `{form_name}`; add data-slhx-form=\"{form_name}\" to a template or rename this form binding" )]; } let Fields::Named(fields) = &form_struct.fields else { return vec![format!( "slhx form `{form_name}` must be a struct with named fields" )]; }; let actual = fields .named .iter() .filter_map(|field| field.ident.as_ref().map(|ident| (ident.to_string(), &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!( "slhx 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!( "slhx form `{form_name}` field `{}` is required in HTML and must not be Option<_>", field.ident )); } if !field.required && !optional && !multiple { errors.push(format!( "slhx form `{form_name}` field `{}` is optional in HTML and must be Option<_>", field.ident )); } if field.multiple && !multiple { errors.push(format!( "slhx form `{form_name}` field `{}` accepts multiple values and must be Vec<_>", field.ident )); } if !field.multiple && multiple { errors.push(format!( "slhx 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 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 missing_component_handlers(path: &PathBuf, component: Option<&str>, items: &[Item]) -> Vec { let implemented = component_handler_names(items); syms_handles(path, component) .into_iter() .filter(|handle| !implemented.contains(handle)) .collect() } fn component_handler_names(items: &[Item]) -> Vec { items .iter() .filter_map(|item| match item { Item::Fn(function) if has_handler_attr(function) => Some(function.sig.ident.to_string()), _ => 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 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::{component_handler_names, handle_params, handle_requires_form, has_form_param, has_non_unit_return, is_form_type, 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("slhx-derive-syms-test.syms"); std::fs::write( &path, "slhx-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: slhx::Form) {}); let with_return = parse_quote!(fn ping() -> impl slhx::IntoEffect { slhx::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!(slhx::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!(is_form_type(&qualified_form)); assert!(is_form_type(&imported_form)); assert!(!is_form_type(&name_suffix_impostor)); assert!(!is_form_type(&nongeneric_impostor)); assert!(!is_form_type(&foreign_form)); } #[test] fn handler_params_match_generated_param_names() { let path = std::env::temp_dir().join("slhx-derive-param-test.syms"); std::fs::write(&path, "slhx-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 slhx::IntoEffect { slhx::EffectBatch::default() }); let missing: ItemFn = parse_quote!(fn show(todo_id: String) -> impl slhx::IntoEffect { slhx::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("slhx-derive-component-test.syms"); std::fs::write( &path, "slhx-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 { #[slhx::handler] fn create() -> impl slhx::IntoEffect { slhx::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); } }