feat(api): streamline generated app authoring

Move the canonical examples toward generated component-root helpers, typed form decoding, async/state handler registration, and derive-driven app/component registry wiring. Tighten requirements and diagnostics for the server-first, selectorless authoring path.

Verified with cargo run -p slhx-xtask -- test, cargo check --workspace, redgate list, redgate refs, redgate health --strict, and git diff --check.

req: canonical/001

req: canonical/003

req: canonical/004

req: dx/002

req: derive_app/001

req: component/003

req: form/004

req: axum_integration/003
This commit is contained in:
slhx agent
2026-06-05 06:33:41 +02:00
parent eb6086616c
commit d4e865ef92
34 changed files with 4573 additions and 1156 deletions
+1
View File
@@ -8,5 +8,6 @@ proc-macro = true
path = "src/lib.rs"
[dependencies]
proc-macro2 = "1"
quote = "1"
syn = { version = "2", features = ["full"] }
+491 -78
View File
@@ -1,14 +1,16 @@
use proc_macro::TokenStream;
use quote::quote;
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, PathArguments, ReturnType, Type,
ItemStruct, LitStr, Pat, Path, PathArguments, ReturnType, Token, Type,
};
#[proc_macro_attribute]
pub fn handler(_attr: TokenStream, item: TokenStream) -> TokenStream {
let mut function = parse_macro_input!(item as ItemFn);
let function = parse_macro_input!(item as ItemFn);
let name = function.sig.ident.to_string();
let Some(syms_path) = syms_path() else {
@@ -49,7 +51,7 @@ pub fn handler(_attr: TokenStream, item: TokenStream) -> TokenStream {
}
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<_>"
"slhx handler `{name}` handles a generated form and must accept a typed form argument"
);
return quote!(
#function
@@ -57,9 +59,6 @@ pub fn handler(_attr: TokenStream, item: TokenStream) -> TokenStream {
)
.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!(
@@ -96,15 +95,29 @@ pub fn form(attr: TokenStream, item: TokenStream) -> TokenStream {
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 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));
generics
.make_where_clause()
.predicates
.push(parse_quote!(#ty: ::slhx::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 ::slhx::FormModel for #ident #ty_generics #where_clause {}
impl #impl_generics ::slhx::FromForm for #ident #ty_generics #where_clause {
fn from_form_fields(
__slhx_fields: &[(String, String)],
) -> Result<Self, ::slhx::FormError> {
Ok(Self {
#(#decode_fields),*
})
}
}
impl #impl_generics #ident #ty_generics #where_clause {
pub const FORM: ::slhx::Form<Self> = ::slhx::Form::new(#resource_id);
}
@@ -142,12 +155,9 @@ pub fn component(attr: TokenStream, item: TokenStream) -> TokenStream {
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(", ")
);
let errors = component_contract_errors(&syms_path, component_filter, items);
if !errors.is_empty() {
let message = errors.join("; ");
return quote!(
#module
compile_error!(#message);
@@ -155,12 +165,25 @@ pub fn component(attr: TokenStream, item: TokenStream) -> TokenStream {
.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 {
item
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 {
@@ -205,14 +228,7 @@ fn generated_path(file: &str) -> Option<PathBuf> {
}
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()
handler_form_model_type(function).is_some()
}
fn form_model_type(ty: &Type) -> Option<Type> {
@@ -221,7 +237,11 @@ fn form_model_type(ty: &Type) -> Option<Type> {
};
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 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 {
@@ -239,29 +259,26 @@ fn form_model_type(ty: &Type) -> Option<Type> {
})
}
fn form_model_types(function: &ItemFn) -> Vec<Type> {
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 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()),
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,
}
}
@@ -272,7 +289,9 @@ fn syms_contains_handle(path: &PathBuf, ident: &str) -> bool {
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)
&& fields
.nth(1)
.is_some_and(|handle_ident| handle_ident == ident)
})
}
@@ -283,7 +302,9 @@ fn handle_requires_form(path: &PathBuf, ident: &str) -> bool {
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)
&& fields
.next()
.is_some_and(|handle_ident| handle_ident == ident)
})
}
@@ -295,7 +316,11 @@ struct GeneratedFormField {
multiple: bool,
}
fn form_contract_errors(syms_path: &PathBuf, form_name: &str, form_struct: &ItemStruct) -> Vec<String> {
fn form_contract_errors(
syms_path: &PathBuf,
form_name: &str,
form_struct: &ItemStruct,
) -> Vec<String> {
if !syms_path.exists() {
return vec![
"#[slhx::form] could not find generated slhx symbols; add slhx_build::app().run()? to build.rs or check template generation"
@@ -316,7 +341,12 @@ fn form_contract_errors(syms_path: &PathBuf, form_name: &str, form_struct: &Item
let actual = fields
.named
.iter()
.filter_map(|field| field.ident.as_ref().map(|ident| (ident.to_string(), &field.ty)))
.filter_map(|field| {
field
.ident
.as_ref()
.map(|ident| (ident.to_string(), &field.ty))
})
.collect::<Vec<_>>();
let mut errors = Vec::new();
for field in expected {
@@ -335,12 +365,6 @@ fn form_contract_errors(syms_path: &PathBuf, form_name: &str, form_struct: &Item
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<_>",
@@ -368,6 +392,74 @@ fn form_parser_types(form_struct: &ItemStruct) -> Vec<Type> {
.collect()
}
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, _)| ident == &&field.ident)?;
let control_name = field.name;
let parser = parser_type(ty);
Some(if field.multiple {
quote! {
#ident: __slhx_fields
.iter()
.filter_map(|(__slhx_name, __slhx_value)|
(__slhx_name == #control_name).then_some(__slhx_value.as_str())
)
.map(|__slhx_value| {
<#parser as ::slhx::FormValue>::parse_form_value(__slhx_value)
.map_err(|_| ::slhx::FormError::new(format!("invalid form field `{}`", #control_name)))
})
.collect::<Result<Vec<_>, _>>()?
}
} else if is_type_named(ty, "Option") {
quote! {
#ident: match __slhx_fields
.iter()
.find_map(|(__slhx_name, __slhx_value)|
(__slhx_name == #control_name).then_some(__slhx_value.as_str())
)
{
Some(__slhx_value) => Some(
<#parser as ::slhx::FormValue>::parse_form_value(__slhx_value)
.map_err(|_| ::slhx::FormError::new(format!("invalid form field `{}`", #control_name)))?
),
None => None,
}
}
} else {
quote! {
#ident: {
let Some(__slhx_value) = __slhx_fields
.iter()
.find_map(|(__slhx_name, __slhx_value)|
(__slhx_name == #control_name).then_some(__slhx_value.as_str())
)
else {
return Err(::slhx::FormError::new(format!("missing form field `{}`", #control_name)));
};
<#parser as ::slhx::FormValue>::parse_form_value(__slhx_value)
.map_err(|_| ::slhx::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"))
@@ -431,7 +523,11 @@ fn form_fields(path: &PathBuf, form_name: &str) -> Vec<GeneratedFormField> {
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),
Type::Path(path) => path
.path
.segments
.last()
.is_some_and(|segment| segment.ident == name),
_ => false,
}
}
@@ -497,19 +593,235 @@ fn handler_arg_names(function: &ItemFn) -> Vec<String> {
.collect()
}
fn missing_component_handlers(path: &PathBuf, component: Option<&str>, items: &[Item]) -> Vec<String> {
fn add_app_registry_helper(mut function: ItemFn, components: Vec<Path>) -> Result<ItemFn, String> {
if components.is_empty() {
return Err("#[slhx::app] requires component registry module(s), for example #[slhx::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(
"#[slhx::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 __slhx_registry = (|| #body)();
#(
let __slhx_registry = #components::register_with_state(
__slhx_registry,
::slhx_axum::State(#state.clone()),
);
)*
__slhx_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| {
let ident = &handler.ident;
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.is_async {
quote!(.on_async(super::#component_ident::#ident, #ident))
} else {
quote!(.on(super::#component_ident::#ident, #ident))
}
});
let register: Item = syn::parse2(quote! {
pub fn register(
registry: ::slhx_axum::StateHandlerRegistry<#state_ty>,
) -> ::slhx_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| {
let ident = &handler.ident;
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.is_async {
quote!(.on_async(super::#component_ident::#ident, #ident))
} else {
quote!(.on(super::#component_ident::#ident, #ident))
}
});
let register_with_state: Item = syn::parse2(quote! {
pub fn register_with_state(
registry: ::slhx_axum::HandlerRegistry,
state: #state_ty,
) -> ::slhx_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_contract_errors(
path: &PathBuf,
component: Option<&str>,
items: &[Item],
) -> Vec<String> {
let generated = syms_handles(path, component);
let implemented = component_handler_names(items);
syms_handles(path, component)
.into_iter()
let mut errors = Vec::new();
if component.is_some() && !implemented.is_empty() && generated.is_empty() {
errors.push(format!(
"#[slhx::component({:?})] does not match any generated handles; check the .heml file name or component name",
component.unwrap()
));
}
let ambiguous = duplicate_names(&generated);
if !ambiguous.is_empty() {
errors.push(format!(
"#[slhx::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!(
"#[slhx::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!(
"#[slhx::component] handler(s) not declared by this component's generated handles: {}; move them to the matching component module or add data-slhx-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("#[slhx::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_map(|(name, count)| (count > 1).then(|| 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(function.sig.ident.to_string()),
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()
@@ -560,7 +872,11 @@ fn compile_error(message: &str) -> TokenStream {
#[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 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};
@@ -586,9 +902,17 @@ mod tests {
#[test]
fn handler_shape_accepts_form_or_effect_return() {
// req: derive_handler/001
let with_form = parse_quote!(fn save(form: slhx::Form<String>) {});
let with_return = parse_quote!(fn ping() -> impl slhx::IntoEffect { slhx::EffectBatch::default() });
let empty = parse_quote!(fn noop() {});
let with_form = parse_quote!(
fn save(form: slhx::Form<String>) {}
);
let with_return = parse_quote!(
fn ping() -> impl slhx::IntoEffect {
slhx::advanced::EffectBatch::default()
}
);
let empty = parse_quote!(
fn noop() {}
);
assert!(has_form_param(&with_form));
assert!(has_non_unit_return(&with_return));
@@ -607,9 +931,18 @@ mod tests {
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());
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]
@@ -621,20 +954,31 @@ mod tests {
let nongeneric_impostor: Type = parse_quote!(Form);
let foreign_form: Type = parse_quote!(other::Form<CreateTodo>);
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));
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("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() });
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::advanced::EffectBatch::default()
}
);
let missing: ItemFn = parse_quote!(
fn show(todo_id: String) -> impl slhx::IntoEffect {
slhx::advanced::EffectBatch::default()
}
);
assert!(missing_handle_params(&path, "show", &complete).is_empty());
assert_eq!(missing_handle_params(&path, "show", &missing), vec!["mode"]);
@@ -653,15 +997,84 @@ mod tests {
let module: syn::ItemMod = parse_quote! {
mod component {
#[slhx::handler]
fn create() -> impl slhx::IntoEffect { slhx::EffectBatch::default() }
fn create() -> impl slhx::IntoEffect { slhx::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"]);
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>) -> slhx_axum::HandlerRegistry {
slhx_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("slhx_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 {
#[slhx::handler]
fn create(app: super::App, form: super::NewTodo) -> impl slhx::IntoEffect {
slhx::EventName::new("created").emit("")
}
#[slhx::handler]
async fn increment(app: super::App) -> impl slhx::IntoEffect {
slhx::EventName::new("incremented").emit("")
}
#[slhx::handler]
async fn save(app: super::App, form: super::NewTodo) -> Result<impl slhx::IntoEffect, super::Error> {
Ok(slhx::EventName::new("saved").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 :: create"),
"{generated}"
);
assert!(generated.contains(". on"), "{generated}");
assert!(generated.contains(". on_state_async"), "{generated}");
assert!(generated.contains(". on_async_result"), "{generated}");
}
}
+238 -173
View File
@@ -40,7 +40,7 @@ slhx = {{ path = {:?} }}
mod todos {
#[slhx::handler]
fn create() -> impl slhx::IntoEffect {
slhx::EffectBatch::default()
slhx::advanced::EffectBatch::default()
}
}
"#,
@@ -95,7 +95,7 @@ slhx = {{ path = {:?} }}
mod todos {
#[slhx::handler]
fn create() -> impl slhx::IntoEffect {
slhx::event("created", "")
slhx::EventName::new("created").emit("")
}
}
"#,
@@ -110,6 +110,176 @@ mod todos {
);
}
#[test]
fn component_macro_rejects_handlers_outside_scoped_component() {
// req: component/003 req: component/005 req: test/003
let fixture = Fixture::new("slhx-derive-component-extra-handler-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-component-extra-handler-fail"
version = "0.0.0"
edition = "2021"
[lib]
path = "src/lib.rs"
[dependencies]
slhx = {{ path = {:?} }}
"#,
repo_path("slhx")
),
);
fixture.write(
"build.rs",
r#"fn main() {
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
std::fs::write(
out.join("slhx.syms"),
"slhx-syms-v1\nhandle\ttemplates/todos.heml::create\tcreate\t1\nhandle\ttemplates/admin.heml::delete\tdelete\t2\n",
)
.unwrap();
}
"#,
);
fixture.write(
"src/lib.rs",
r#"#[slhx::component("todos")]
mod handlers {
#[slhx::handler]
fn create() -> impl slhx::IntoEffect {
slhx::EventName::new("created").emit("")
}
#[slhx::handler]
fn delete() -> impl slhx::IntoEffect {
slhx::EventName::new("deleted").emit("")
}
}
"#,
);
let output = check_fixture(&fixture);
assert!(!output.status.success(), "fixture unexpectedly compiled");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("handler(s) not declared by this component's generated handles: delete"),
"missing scoped extra-handler diagnostic in stderr:\n{stderr}"
);
}
#[test]
fn component_macro_rejects_unknown_scoped_component() {
// req: component/003 req: component/005 req: test/003
let fixture = Fixture::new("slhx-derive-component-unknown-scope-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-component-unknown-scope-fail"
version = "0.0.0"
edition = "2021"
[lib]
path = "src/lib.rs"
[dependencies]
slhx = {{ path = {:?} }}
"#,
repo_path("slhx")
),
);
fixture.write(
"build.rs",
r#"fn main() {
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
std::fs::write(
out.join("slhx.syms"),
"slhx-syms-v1\nhandle\ttemplates/admin.heml::create\tcreate\t1\n",
)
.unwrap();
}
"#,
);
fixture.write(
"src/lib.rs",
r#"#[slhx::component("todos")]
mod handlers {
#[slhx::handler]
fn create() -> impl slhx::IntoEffect {
slhx::EventName::new("created").emit("")
}
}
"#,
);
let output = check_fixture(&fixture);
assert!(!output.status.success(), "fixture unexpectedly compiled");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("#[slhx::component(\"todos\")] does not match any generated handles"),
"missing unknown component diagnostic in stderr:\n{stderr}"
);
}
#[test]
fn component_macro_rejects_ambiguous_generated_handles() {
// req: component/003 req: component/005 req: test/003
let fixture = Fixture::new("slhx-derive-component-ambiguous-handle-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-component-ambiguous-handle-fail"
version = "0.0.0"
edition = "2021"
[lib]
path = "src/lib.rs"
[dependencies]
slhx = {{ path = {:?} }}
"#,
repo_path("slhx")
),
);
fixture.write(
"build.rs",
r#"fn main() {
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
std::fs::write(
out.join("slhx.syms"),
"slhx-syms-v1\nhandle\ttemplates/todos.heml::save\tsave\t1\nhandle\ttemplates/todos.heml::section::save\tsave\t2\n",
)
.unwrap();
}
"#,
);
fixture.write(
"src/lib.rs",
r#"#[slhx::component("todos")]
mod handlers {
#[slhx::handler]
fn save() -> impl slhx::IntoEffect {
slhx::EventName::new("saved").emit("")
}
}
"#,
);
let output = check_fixture(&fixture);
assert!(!output.status.success(), "fixture unexpectedly compiled");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("ambiguous generated handle name(s): save"),
"missing ambiguous-handle diagnostic in stderr:\n{stderr}"
);
}
#[test]
fn handler_macro_reports_unknown_handle_and_bad_shape() {
// req: derive_handler/001 req: test/003
@@ -147,7 +317,7 @@ slhx = {{ path = {:?} }}
"src/lib.rs",
r#"#[slhx::handler]
fn missing() -> impl slhx::IntoEffect {
slhx::EffectBatch::default()
slhx::advanced::EffectBatch::default()
}
#[slhx::handler]
@@ -201,7 +371,7 @@ slhx = {{ path = {:?} }}
"src/lib.rs",
r#"#[slhx::handler]
fn create() -> impl slhx::IntoEffect {
slhx::EffectBatch::default()
slhx::advanced::EffectBatch::default()
}
"#,
);
@@ -261,7 +431,7 @@ slhx = {{ path = {:?} }}
"src/lib.rs",
r#"#[slhx::handler]
fn show(todo_id: String) -> impl slhx::IntoEffect {
slhx::EffectBatch::default()
slhx::advanced::EffectBatch::default()
}
"#,
);
@@ -479,7 +649,7 @@ struct CreateTodo {
#[slhx::handler]
fn create(_form: slhx::Form<CreateTodo>) -> impl slhx::IntoEffect {
slhx::event("created", "")
slhx::EventName::new("created").emit("")
}
fn smoke() {
@@ -497,62 +667,6 @@ fn smoke() {
);
}
#[test]
fn form_handle_requires_checked_form_model() {
// req: form/001 req: form/004 req: form/006 req: test/003
let fixture = Fixture::new("slhx-derive-form-handler-unchecked-model-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-form-handler-unchecked-model-fail"
version = "0.0.0"
edition = "2021"
[lib]
path = "src/lib.rs"
[dependencies]
slhx = {{ path = {:?} }}
"#,
repo_path("slhx")
),
);
fixture.write(
"build.rs",
r#"fn main() {
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
std::fs::write(
out.join("slhx.syms"),
"slhx-syms-v1\nhandle\ttemplates/app.heml::create\tcreate\t1\nhandle_form\tcreate\tnew_todo\n",
)
.unwrap();
}
"#,
);
fixture.write(
"src/lib.rs",
r#"struct CreateTodo {
title: String,
}
#[slhx::handler]
fn create(_form: slhx::Form<CreateTodo>) -> impl slhx::IntoEffect {
slhx::event("created", "")
}
"#,
);
let output = check_fixture(&fixture);
assert!(!output.status.success(), "fixture unexpectedly compiled");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("CreateTodo: FormModel") || stderr.contains("CreateTodo: slhx::FormModel"),
"missing checked form model diagnostic in stderr:\n{stderr}"
);
}
#[test]
fn form_handle_requires_form_parameter() {
// req: form/004 req: form/006 req: test/003
@@ -590,7 +704,7 @@ slhx = {{ path = {:?} }}
"src/lib.rs",
r#"#[slhx::handler]
fn create() -> impl slhx::IntoEffect {
slhx::EffectBatch::default()
slhx::advanced::EffectBatch::default()
}
"#,
);
@@ -600,11 +714,70 @@ fn create() -> impl slhx::IntoEffect {
assert!(!output.status.success(), "fixture unexpectedly compiled");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("slhx handler `create` handles a generated form and must accept slhx::Form<_>"),
stderr.contains(
"slhx handler `create` handles a generated form and must accept a typed form argument"
),
"missing form-handler diagnostic in stderr:\n{stderr}"
);
}
#[test]
fn form_handle_rejects_state_only_handler() {
// req: form/004 req: form/006 req: test/003
let fixture = Fixture::new("slhx-derive-form-handler-state-only-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-form-handler-state-only-fail"
version = "0.0.0"
edition = "2021"
[lib]
path = "src/lib.rs"
[dependencies]
slhx = {{ path = {:?} }}
"#,
repo_path("slhx")
),
);
fixture.write(
"build.rs",
r#"fn main() {
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
std::fs::write(
out.join("slhx.syms"),
"slhx-syms-v1\nhandle\ttemplates/app.heml::create\tcreate\t1\nhandle_form\tcreate\tnew_todo\n",
)
.unwrap();
}
"#,
);
fixture.write(
"src/lib.rs",
r#"struct App;
struct State<T>(T);
#[slhx::handler]
fn create(_state: State<App>) -> impl slhx::IntoEffect {
slhx::advanced::EffectBatch::default()
}
"#,
);
let output = check_fixture(&fixture);
assert!(!output.status.success(), "fixture unexpectedly compiled");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains(
"slhx handler `create` handles a generated form and must accept a typed form argument"
),
"missing state-only form-handler diagnostic in stderr:\n{stderr}"
);
}
#[test]
fn form_handle_still_requires_generated_param_arguments() {
// req: form/006 req: derive_handler/003 req: test/003
@@ -644,7 +817,7 @@ slhx = {{ path = {:?} }}
#[slhx::handler]
fn create(_form: slhx::Form<CreateTodo>) -> impl slhx::IntoEffect {
slhx::EffectBatch::default()
slhx::advanced::EffectBatch::default()
}
"#,
);
@@ -659,114 +832,6 @@ fn create(_form: slhx::Form<CreateTodo>) -> impl slhx::IntoEffect {
);
}
#[test]
fn form_handle_rejects_nongeneric_form_impostor() {
// req: form/004 req: form/006 req: test/003
let fixture = Fixture::new("slhx-derive-form-handler-nongeneric-impostor-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-form-handler-nongeneric-impostor-fail"
version = "0.0.0"
edition = "2021"
[lib]
path = "src/lib.rs"
[dependencies]
slhx = {{ path = {:?} }}
"#,
repo_path("slhx")
),
);
fixture.write(
"build.rs",
r#"fn main() {
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
std::fs::write(
out.join("slhx.syms"),
"slhx-syms-v1\nhandle\ttemplates/app.heml::create\tcreate\t1\nhandle_form\tcreate\tnew_todo\n",
)
.unwrap();
}
"#,
);
fixture.write(
"src/lib.rs",
r#"struct Form;
#[slhx::handler]
fn create(_form: Form) -> impl slhx::IntoEffect {
slhx::EffectBatch::default()
}
"#,
);
let output = check_fixture(&fixture);
assert!(!output.status.success(), "fixture unexpectedly compiled");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("slhx handler `create` handles a generated form and must accept slhx::Form<_>"),
"missing nongeneric form diagnostic in stderr:\n{stderr}"
);
}
#[test]
fn form_handle_rejects_form_name_suffix_impostor() {
// req: form/004 req: form/006 req: test/003
let fixture = Fixture::new("slhx-derive-form-impostor-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-form-impostor-fail"
version = "0.0.0"
edition = "2021"
[lib]
path = "src/lib.rs"
[dependencies]
slhx = {{ path = {:?} }}
"#,
repo_path("slhx")
),
);
fixture.write(
"build.rs",
r#"fn main() {
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
std::fs::write(
out.join("slhx.syms"),
"slhx-syms-v1\nhandle\ttemplates/app.heml::create\tcreate\t1\nhandle_form\tcreate\tnew_todo\n",
)
.unwrap();
}
"#,
);
fixture.write(
"src/lib.rs",
r#"struct CreateForm;
#[slhx::handler]
fn create(_form: CreateForm) -> impl slhx::IntoEffect {
slhx::EffectBatch::default()
}
"#,
);
let output = check_fixture(&fixture);
assert!(!output.status.success(), "fixture unexpectedly compiled");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("slhx handler `create` handles a generated form and must accept slhx::Form<_>"),
"missing form-impostor diagnostic in stderr:\n{stderr}"
);
}
#[test]
fn surface_macro_reports_missing_generated_include() {
// req: build/004 req: test/003