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:
@@ -8,5 +8,6 @@ proc-macro = true
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
proc-macro2 = "1"
|
||||
quote = "1"
|
||||
syn = { version = "2", features = ["full"] }
|
||||
|
||||
+491
-78
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user