From 61da7a7b24c433bed5688ebf877e56b72d236d8a Mon Sep 17 00:00:00 2001 From: slhx agent Date: Sun, 10 May 2026 23:38:29 +0200 Subject: [PATCH] feat(derive): reject empty handlers req: derive_handler/001 --- slhx-derive/src/lib.rs | 69 +++++++++++++++++++++++++++++++++++------- 1 file changed, 58 insertions(+), 11 deletions(-) diff --git a/slhx-derive/src/lib.rs b/slhx-derive/src/lib.rs index ced2b15..acfc806 100644 --- a/slhx-derive/src/lib.rs +++ b/slhx-derive/src/lib.rs @@ -1,7 +1,7 @@ use proc_macro::TokenStream; use quote::quote; use std::path::PathBuf; -use syn::{parse_macro_input, ItemFn}; +use syn::{parse_macro_input, FnArg, ItemFn, ReturnType, Type}; #[proc_macro_attribute] pub fn handler(_attr: TokenStream, item: TokenStream) -> TokenStream { @@ -9,15 +9,27 @@ pub fn handler(_attr: TokenStream, item: TokenStream) -> TokenStream { let name = function.sig.ident.to_string(); if let Some(syms_path) = syms_path() { - if syms_path.exists() && !syms_contains_handle(&syms_path, &name) { - let message = format!( - "unknown slhx handle `{name}`; add `data-slhx-handle=\"{name}\"` to a template or rename this handler" - ); - return quote!( - #function - compile_error!(#message); - ) - .into(); + if syms_path.exists() { + if !syms_contains_handle(&syms_path, &name) { + let message = format!( + "unknown slhx handle `{name}`; add `data-slhx-handle=\"{name}\"` to a template or rename this handler" + ); + return quote!( + #function + compile_error!(#message); + ) + .into(); + } + if !has_form_param(&function) && !has_non_unit_return(&function) { + let message = format!( + "slhx handler `{name}` must accept a form/context parameter or return a value implementing IntoEffect" + ); + return quote!( + #function + compile_error!(#message); + ) + .into(); + } } } @@ -72,6 +84,27 @@ fn generated_path(file: &str) -> Option { std::env::var_os("OUT_DIR").map(|out_dir| PathBuf::from(out_dir).join(file)) } +fn has_form_param(function: &ItemFn) -> bool { + function.sig.inputs.iter().any(|arg| match arg { + FnArg::Typed(arg) => type_ends_with(&arg.ty, "Form"), + FnArg::Receiver(_) => false, + }) +} + +fn has_non_unit_return(function: &ItemFn) -> bool { + match &function.sig.output { + ReturnType::Default => false, + ReturnType::Type(_, ty) => !matches!(ty.as_ref(), Type::Tuple(tuple) if tuple.elems.is_empty()), + } +} + +fn type_ends_with(ty: &Type, ident: &str) -> bool { + match ty { + Type::Path(path) => path.path.segments.last().is_some_and(|segment| segment.ident == ident), + _ => false, + } +} + fn syms_contains_handle(path: &PathBuf, ident: &str) -> bool { let Ok(syms) = std::fs::read_to_string(path) else { return true; @@ -91,7 +124,8 @@ fn compile_error(message: &str) -> TokenStream { #[cfg(test)] mod tests { - use super::syms_contains_handle; + use super::{has_form_param, has_non_unit_return, syms_contains_handle}; + use syn::parse_quote; #[test] fn syms_lookup_matches_handle_ident() { @@ -107,4 +141,17 @@ mod tests { let _ = std::fs::remove_file(path); } + + #[test] + fn handler_shape_accepts_form_or_effect_return() { + // req: derive_handler/001 + let with_form = parse_quote!(fn save(form: slhx::Form) {}); + let with_return = parse_quote!(fn ping() -> impl slhx::IntoEffect { slhx::EffectBatch::default() }); + let empty = parse_quote!(fn noop() {}); + + assert!(has_form_param(&with_form)); + assert!(has_non_unit_return(&with_return)); + assert!(!has_form_param(&empty)); + assert!(!has_non_unit_return(&empty)); + } }