Implement closed typed effect path

This commit is contained in:
tmk241
2026-09-01 00:58:29 +02:00
parent 353174604e
commit 31a0f02211
58 changed files with 4247 additions and 5933 deletions
+25 -214
View File
@@ -12,10 +12,14 @@ use syn::{
#[proc_macro_attribute]
pub fn handler(attr: TokenStream, item: TokenStream) -> TokenStream {
let placement = attr.to_string();
let placement = match handler_placement(&placement) {
Ok(placement) => placement,
Err(error) => return error.into_compile_error().into(),
};
if !placement.is_empty() {
return syn::Error::new(
proc_macro2::Span::call_site(),
"unsupported hemx handler placement; expected #[hemx::handler]",
)
.into_compile_error()
.into();
}
let function = parse_macro_input!(item as ItemFn);
let name = function.sig.ident.to_string();
@@ -72,70 +76,7 @@ pub fn handler(attr: TokenStream, item: TokenStream) -> TokenStream {
.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 has_inputs {
quote!(super::#function_name(event, state))
} else {
quote!(super::#function_name())
};
quote!(
#function
#[cfg(target_arch = "wasm32")]
mod #export_module {
use ::hemx::wasm as wasm_bindgen;
#[::hemx::wasm::wasm_bindgen(js_name = #export_name)]
#[allow(clippy::too_many_arguments)]
pub fn invoke(
event_version: u32,
event_kind: ::std::string::String,
event_value: ::std::option::Option<::std::string::String>,
event_checked: ::std::option::Option<bool>,
event_key: ::std::option::Option<::std::string::String>,
state_version: u32,
encoded_state: ::std::string::String,
) -> ::std::result::Result<::std::vec::Vec<u8>, ::hemx::wasm::JsValue> {
let (event, state) = ::hemx::wasm::decode_client_inputs(
event_version,
event_kind,
event_value,
event_checked,
event_key,
state_version,
encoded_state,
)
.map_err(|error| ::hemx::wasm::JsValue::from_str(&error))?;
Ok(::hemx::wasm::encode_handler_effect(
#invoke_handler,
crate::ui::BUILD_FINGERPRINT,
))
}
}
)
quote!(#function).into()
}
#[proc_macro_attribute]
@@ -266,9 +207,7 @@ fn surface_include() -> String {
} else {
format!(
" compile_error!({:?}); ",
format!(
"#[hemx::surface] could not find generated hemx module; add hemx_build::app().run()? to build.rs or check template generation"
)
"#[hemx::surface] could not find generated hemx module; add hemx_build::app().run()? to build.rs or check template generation"
)
}
}
@@ -337,43 +276,6 @@ fn form_impl_generics(form_struct: &ItemStruct) -> syn::Generics {
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),
@@ -1013,92 +915,19 @@ fn compile_error(message: &str) -> TokenStream {
#[cfg(test)]
mod tests {
use super::{
add_app_registry_helper, add_component_register_helper, client_handler_has_inputs,
component_contract_errors, component_handler_names, component_registration_call,
expand_handler_function, form_contract_errors, form_decode_fields, form_fields,
form_impl_generics, form_model_type, form_resource_id, generic_inner_type, handle_params,
handle_requires_form, handler_arg_names, 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, rust_ident, symbol_component, syms_components, syms_contains_handle,
syms_handles, ComponentHandler, HandlerPlacement,
add_app_registry_helper, add_component_register_helper, component_contract_errors,
component_handler_names, component_registration_call, form_contract_errors,
form_decode_fields, form_fields, form_impl_generics, form_model_type, form_resource_id,
generic_inner_type, handle_params, handle_requires_form, handler_arg_names,
handler_form_model_type, 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, rust_ident,
symbol_component, syms_components, syms_contains_handle, syms_handles, ComponentHandler,
};
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!(
@@ -1140,7 +969,6 @@ mod tests {
.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]
@@ -1179,7 +1007,6 @@ mod tests {
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]
@@ -1209,18 +1036,12 @@ mod tests {
unimplemented!()
}
);
assert_eq!(
quote!(#function)
.to_string()
.contains("last : Form < crate :: Last >"),
true
);
assert_eq!(
quote!(#function)
.to_string()
.contains("first : hemx :: Form < crate :: First >"),
true
);
assert!(quote!(#function)
.to_string()
.contains("last : Form < crate :: Last >"));
assert!(quote!(#function)
.to_string()
.contains("first : hemx :: Form < crate :: First >"));
assert_eq!(
handler_form_model_type(&function)
.map(|ty| quote!(#ty).to_string())
@@ -1239,7 +1060,6 @@ mod tests {
);
assert!(!returns_result(&no_result.sig.output));
assert!(!returns_result(&no_return.sig.output));
// test req: derive_handler/001 req: derive_handler/005
}
#[test]
@@ -1336,7 +1156,6 @@ mod tests {
});
assert!(generic_inner_type(&empty_path, "Vec").is_none());
std::fs::remove_file(path).unwrap();
// test req: form/004 req: diagnostics/003
}
#[test]
@@ -1378,7 +1197,6 @@ mod tests {
#[test]
fn handler_shape_accepts_form_or_effect_return() {
// req: derive_handler/001
let with_form = parse_quote!(
fn save(form: hemx::Form<String>) {}
);
@@ -1399,7 +1217,6 @@ mod tests {
#[test]
fn form_parser_type_uses_option_and_vec_inner_types() {
// req: form/004
let required: Type = parse_quote!(Email);
let optional: Type = parse_quote!(Option<Email>);
let multiple: Type = parse_quote!(Vec<Email>);
@@ -1424,7 +1241,6 @@ mod tests {
#[test]
fn form_param_matches_form_type_not_name_suffix() {
// req: form/004 req: form/006
let qualified_form: Type = parse_quote!(hemx::Form<CreateTodo>);
let imported_form: Type = parse_quote!(Form<CreateTodo>);
let name_suffix_impostor: Type = parse_quote!(CreateTodoForm);
@@ -1445,7 +1261,6 @@ mod tests {
for invalid in ["", "2fast", "with space", "naïve"] {
assert_eq!(rust_ident(invalid), None, "{invalid:?} must fail closed");
}
// test req: form/004 req: diagnostics/003
}
#[test]
@@ -1528,7 +1343,6 @@ mod tests {
"args={typed_arg_count} async={is_async} result={returns_result}"
);
}
// test req: component/003 req: derive_handler/003
}
#[test]
@@ -1571,7 +1385,6 @@ mod tests {
#[test]
fn app_macro_generates_single_registry_entry_point() {
// req: derive_app/001 req: component/003
let function = parse_quote! {
fn registry(state: std::sync::Arc<App>) -> hemx_axum::HandlerRegistry {
hemx_axum::interactions(ui::BUILD_FINGERPRINT)
@@ -1667,12 +1480,10 @@ mod tests {
.len(),
2
);
// test req: component/003 req: derive_handler/003
}
#[test]
fn component_macro_generates_registration_helpers() {
// req: component/003 req: derive_handler/003
let module = parse_quote! {
mod handlers {
const COMPONENT_KIND: &str = "todos";