test(derive): close first mutation shard

Create shard output parents, exercise exact handler placement/signature/codegen contracts, preserve generated-file and form-bound diagnostics, and fail closed on malformed form symbol manifests. Public shard 1/8 passes through both 16-way partitions.

req: derive_handler/001

req: client_local/001

req: form/004

req: diagnostics/003

req: test/022

req: test/023
This commit is contained in:
slhx agent
2026-07-17 14:02:19 +02:00
parent 8c79ea26bf
commit 62b0787a8e
3 changed files with 281 additions and 66 deletions
+271 -65
View File
@@ -12,37 +12,23 @@ use syn::{
#[proc_macro_attribute]
pub fn handler(attr: TokenStream, item: TokenStream) -> TokenStream {
let placement = attr.to_string();
let is_client = match placement.as_str() {
"" => false,
"client" => true,
_ => {
return syn::Error::new(
proc_macro2::Span::call_site(),
"unsupported hemx handler placement; expected #[hemx::handler] or #[hemx::handler(client)]",
)
.into_compile_error()
.into();
}
let placement = match handler_placement(&placement) {
Ok(placement) => placement,
Err(error) => return error.into_compile_error().into(),
};
let function = parse_macro_input!(item as ItemFn);
let name = function.sig.ident.to_string();
let Some(syms_path) = syms_path() else {
let message = "#[hemx::handler] requires generated hemx files; add hemx_build::app().run()? to build.rs or run inside a Cargo crate";
return quote!(
#function
compile_error!(#message);
)
.into();
let syms_path = match handler_syms_path(syms_path()) {
Ok(path) => path,
Err(message) => {
return quote!(
#function
compile_error!(#message);
)
.into();
}
};
if !syms_path.exists() {
let message = "#[hemx::handler] could not find generated hemx symbols; add hemx_build::app().run()? to build.rs or check template generation";
return quote!(
#function
compile_error!(#message);
)
.into();
}
if !syms_contains_handle(&syms_path, &name) {
let message = format!(
"unknown hemx handle `{name}`; add `data-hemx-handle=\"{name}\"` to a template or rename this handler"
@@ -86,33 +72,34 @@ pub fn handler(attr: TokenStream, item: TokenStream) -> TokenStream {
.into();
}
if !is_client {
return quote!(#function).into();
}
let input_count = function.sig.inputs.len();
if !matches!(input_count, 0 | 2)
|| function.sig.asyncness.is_some()
|| function.sig.unsafety.is_some()
|| function.sig.constness.is_some()
|| !function.sig.generics.params.is_empty()
{
let message = format!(
"client-local hemx handler `{name}` must be safe, synchronous, non-generic, and accept either no parameters or `(hemx::wasm::ClientEvent, hemx::wasm::ClientState)`"
);
return quote!(
#function
compile_error!(#message);
)
.into();
expand_handler_function(function, placement).into()
}
fn expand_handler_function(
function: ItemFn,
placement: HandlerPlacement,
) -> proc_macro2::TokenStream {
if placement == HandlerPlacement::Server {
return quote!(#function);
}
let has_inputs = match client_handler_has_inputs(&function) {
Ok(has_inputs) => has_inputs,
Err(error) => {
let message = error.to_string();
return quote!(
#function
compile_error!(#message);
);
}
};
let function_name = &function.sig.ident;
let export_name = format_ident!("__hemx_client_{function_name}");
let export_module = format_ident!("__hemx_client_export_{function_name}");
let invoke_handler = if input_count == 0 {
quote!(super::#function_name())
} else {
let invoke_handler = if has_inputs {
quote!(super::#function_name(event, state))
} else {
quote!(super::#function_name())
};
quote!(
#function
@@ -149,7 +136,6 @@ pub fn handler(attr: TokenStream, item: TokenStream) -> TokenStream {
}
}
)
.into()
}
#[proc_macro_attribute]
@@ -162,7 +148,7 @@ pub fn form(attr: TokenStream, item: TokenStream) -> TokenStream {
let form_name = parse_macro_input!(attr as LitStr).value();
let form_struct = parse_macro_input!(item as ItemStruct);
let Some(syms_path) = syms_path() else {
let message = "#[hemx::form] requires generated hemx files; add hemx_build::app().run()? to build.rs or run inside a Cargo crate";
let message = missing_form_generated_files_message();
return quote!(
#form_struct
compile_error!(#message);
@@ -174,13 +160,7 @@ pub fn form(attr: TokenStream, item: TokenStream) -> TokenStream {
let ident = &form_struct.ident;
let resource_id =
form_resource_id(&syms_path, &form_name).expect("checked form exists in hemx.syms");
let mut generics = form_struct.generics.clone();
for ty in form_parser_types(&form_struct) {
generics
.make_where_clause()
.predicates
.push(parse_quote!(#ty: ::hemx::FormValue));
}
let generics = form_impl_generics(&form_struct);
let decode_fields = form_decode_fields(&syms_path, &form_name, &form_struct);
let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
quote!(
@@ -201,7 +181,7 @@ pub fn form(attr: TokenStream, item: TokenStream) -> TokenStream {
)
.into()
} else {
let message = errors.join("; ");
let message = join_contract_errors(&errors);
quote!(
#form_struct
compile_error!(#message);
@@ -228,9 +208,6 @@ pub fn component(attr: TokenStream, item: TokenStream) -> TokenStream {
let Some(syms_path) = syms_path() else {
return quote!(#module).into();
};
if !syms_path.exists() {
return quote!(#module).into();
}
let component_filter = component_name.as_deref();
let errors = component_contract_errors(&syms_path, component_filter, items);
if !errors.is_empty() {
@@ -336,6 +313,76 @@ fn form_model_type(ty: &Type) -> Option<Type> {
})
}
fn missing_handler_generated_files_message() -> &'static str {
"#[hemx::handler] requires generated hemx files; add hemx_build::app().run()? to build.rs or run inside a Cargo crate"
}
fn missing_form_generated_files_message() -> &'static str {
"#[hemx::form] requires generated hemx files; add hemx_build::app().run()? to build.rs or run inside a Cargo crate"
}
fn handler_syms_path(syms_path: Option<PathBuf>) -> Result<PathBuf, &'static str> {
match syms_path {
None => Err(missing_handler_generated_files_message()),
Some(path) if !path.exists() => Err(
"#[hemx::handler] could not find generated hemx symbols; add hemx_build::app().run()? to build.rs or check template generation",
),
Some(path) => Ok(path),
}
}
fn join_contract_errors(errors: &[String]) -> String {
errors.join("; ")
}
fn form_impl_generics(form_struct: &ItemStruct) -> syn::Generics {
let mut generics = form_struct.generics.clone();
for ty in form_parser_types(form_struct) {
generics
.make_where_clause()
.predicates
.push(parse_quote!(#ty: ::hemx::FormValue));
}
generics
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum HandlerPlacement {
Server,
Client,
}
fn handler_placement(placement: &str) -> syn::Result<HandlerPlacement> {
match placement {
"" => Ok(HandlerPlacement::Server),
"client" => Ok(HandlerPlacement::Client),
_ => Err(syn::Error::new(
proc_macro2::Span::call_site(),
"unsupported hemx handler placement; expected #[hemx::handler] or #[hemx::handler(client)]",
)),
}
}
fn client_handler_has_inputs(function: &ItemFn) -> syn::Result<bool> {
let input_count = function.sig.inputs.len();
if matches!(input_count, 0 | 2)
&& function.sig.asyncness.is_none()
&& function.sig.unsafety.is_none()
&& function.sig.constness.is_none()
&& function.sig.generics.params.is_empty()
{
Ok(input_count == 2)
} else {
Err(syn::Error::new_spanned(
&function.sig,
format!(
"client-local hemx handler `{}` must be safe, synchronous, non-generic, and accept either no parameters or `(hemx::wasm::ClientEvent, hemx::wasm::ClientState)`",
function.sig.ident
),
))
}
}
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),
@@ -984,14 +1031,173 @@ fn compile_error(message: &str) -> TokenStream {
#[cfg(test)]
mod tests {
use super::{
add_app_registry_helper, add_component_register_helper, component_handler_names,
form_model_type, handle_params, handle_requires_form, handler_form_model_type,
has_form_param, has_non_unit_return, missing_component_handlers, missing_handle_params,
parser_type, returns_result, syms_contains_handle,
add_app_registry_helper, add_component_register_helper, client_handler_has_inputs,
component_handler_names, expand_handler_function, form_fields, form_impl_generics,
form_model_type, form_resource_id, handle_params, handle_requires_form,
handler_form_model_type, handler_placement, handler_syms_path, has_form_param,
has_non_unit_return, is_type_named, join_contract_errors, missing_component_handlers,
missing_form_generated_files_message, missing_handle_params,
missing_handler_generated_files_message, parser_type, returns_result, syms_contains_handle,
HandlerPlacement,
};
use quote::quote;
use quote::{quote, ToTokens};
use syn::{parse_quote, ItemFn, Type};
#[test]
fn handler_attribute_parses_server_and_client_modes_exactly() {
assert_eq!(handler_placement("").unwrap(), HandlerPlacement::Server);
assert_eq!(
handler_placement("client").unwrap(),
HandlerPlacement::Client
);
for invalid in ["server", " client", "client ", "CLIENT"] {
assert_eq!(
handler_placement(invalid).unwrap_err().to_string(),
"unsupported hemx handler placement; expected #[hemx::handler] or #[hemx::handler(client)]"
);
}
let no_inputs: ItemFn = parse_quote!(
fn save() {}
);
let two_inputs: ItemFn = parse_quote!(
fn save(event: Event, state: State) {}
);
assert!(!client_handler_has_inputs(&no_inputs).unwrap());
assert!(client_handler_has_inputs(&two_inputs).unwrap());
let server =
expand_handler_function(no_inputs.clone(), HandlerPlacement::Server).to_string();
assert_eq!(
server,
quote!(
fn save() {}
)
.to_string()
);
let no_input_client =
expand_handler_function(no_inputs.clone(), HandlerPlacement::Client).to_string();
assert!(no_input_client.contains("super :: save ()"));
assert!(!no_input_client.contains("super :: save (event , state)"));
let input_client =
expand_handler_function(two_inputs.clone(), HandlerPlacement::Client).to_string();
assert!(input_client.contains("super :: save (event , state)"));
assert!(!input_client.contains("super :: save ()"));
for invalid in [
parse_quote!(
fn save(event: Event) {}
),
parse_quote!(
fn save(a: A, b: B, c: C) {}
),
parse_quote!(
async fn save() {}
),
parse_quote!(
unsafe fn save() {}
),
parse_quote!(
const fn save() {}
),
parse_quote!(
fn save<T>() {}
),
] {
assert_eq!(
client_handler_has_inputs(&invalid).unwrap_err().to_string(),
"client-local hemx handler `save` must be safe, synchronous, non-generic, and accept either no parameters or `(hemx::wasm::ClientEvent, hemx::wasm::ClientState)`"
);
let expanded = expand_handler_function(invalid, HandlerPlacement::Client).to_string();
assert!(expanded.contains("compile_error !"));
assert!(expanded.contains("client-local hemx handler"));
}
// test req: derive_handler/001 req: client_local/001
}
#[test]
fn generated_file_and_form_helpers_preserve_exact_contracts() {
assert_eq!(
missing_handler_generated_files_message(),
"#[hemx::handler] requires generated hemx files; add hemx_build::app().run()? to build.rs or run inside a Cargo crate"
);
assert_eq!(
missing_form_generated_files_message(),
"#[hemx::form] requires generated hemx files; add hemx_build::app().run()? to build.rs or run inside a Cargo crate"
);
assert_eq!(
handler_syms_path(None).unwrap_err(),
missing_handler_generated_files_message()
);
let missing = std::env::temp_dir().join("hemx-derive-missing-symbols");
assert_eq!(
handler_syms_path(Some(missing)).unwrap_err(),
"#[hemx::handler] could not find generated hemx symbols; add hemx_build::app().run()? to build.rs or check template generation"
);
let existing = std::env::current_exe().unwrap();
assert_eq!(handler_syms_path(Some(existing.clone())).unwrap(), existing);
assert_eq!(
join_contract_errors(&["first".into(), "second".into()]),
"first; second"
);
let form: syn::ItemStruct = parse_quote!(
struct Profile<T> {
name: String,
tags: Vec<T>,
}
);
let generics = form_impl_generics(&form);
let where_clause = generics
.where_clause
.as_ref()
.unwrap()
.to_token_stream()
.to_string();
assert!(where_clause.contains("String : :: hemx :: FormValue"));
assert!(where_clause.contains("T : :: hemx :: FormValue"));
// test req: derive_handler/001 req: form/004
}
#[test]
fn generated_form_symbol_lookup_is_exact_and_fail_closed() {
let path =
std::env::temp_dir().join(format!("hemx-derive-form-symbols-{}", std::process::id()));
std::fs::write(
&path,
"hemx-syms-v1\nform\tprofile.heml::profile\tprofile\t42\nform\nform\tbroken\nform\tmissing-id\tmissing-id\nform\tother.heml::other\tother\tbad\nform_field\tprofile\tname\ttrue\tfalse\nform_field\nform_field\tprofile\nform_field\tprofile\tbad name\tfalse\tfalse\nform_field\tprofile\ttags\tfalse\ttrue\nform_field\tprofile\tbad-name\tfalse\tfalse\nform_field\tother\tignored\tfalse\tfalse\n",
)
.unwrap();
assert_eq!(form_resource_id(&path, "profile"), Some(42));
assert_eq!(form_resource_id(&path, "missing"), None);
assert_eq!(form_resource_id(&path, "broken"), None);
assert_eq!(form_resource_id(&path, "missing-id"), None);
assert_eq!(form_resource_id(&path, "other"), None);
let fields = form_fields(&path, "profile");
assert_eq!(fields.len(), 3);
assert_eq!(fields[0].ident, "name");
assert!(fields[0].required);
assert!(!fields[0].multiple);
assert_eq!(fields[1].ident, "tags");
assert!(!fields[1].required);
assert!(fields[1].multiple);
assert_eq!(fields[2].ident, "bad_name");
assert!(form_fields(&path, "missing").is_empty());
std::fs::remove_file(&path).unwrap();
assert_eq!(form_resource_id(&path, "profile"), None);
assert!(form_fields(&path, "profile").is_empty());
let string: Type = parse_quote!(String);
let qualified: Type = parse_quote!(std::string::String);
let reference: Type = parse_quote!(&String);
assert!(is_type_named(&string, "String"));
assert!(is_type_named(&qualified, "String"));
assert!(!is_type_named(&string, "Vec"));
assert!(!is_type_named(&reference, "String"));
// test req: form/004 req: diagnostics/003
}
#[test]
fn handler_type_helpers_recognize_only_the_public_form_and_result_shapes() {
let bare: Type = parse_quote!(Form<String>);