From dca26511cca98376398a3fa90a874cc83981f0bd Mon Sep 17 00:00:00 2001 From: slhx agent Date: Mon, 25 May 2026 21:23:37 +0200 Subject: [PATCH] feat(derive): require form parameter for form handles Emit handle-form facts from slhx-build and have #[slhx::handler] reject generated form handlers that do not accept slhx::Form<_>. Cover the boundary with compile-fail fixtures. req: form/004 req: form/006 req: test/003 --- slhx-build/src/lib.rs | 4 +++ slhx-derive/src/lib.rs | 27 ++++++++++++++-- slhx-derive/tests/compile_fail.rs | 52 +++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 2 deletions(-) diff --git a/slhx-build/src/lib.rs b/slhx-build/src/lib.rs index 1a4adda..45365c4 100644 --- a/slhx-build/src/lib.rs +++ b/slhx-build/src/lib.rs @@ -515,6 +515,9 @@ fn __slhx_attr(tag: &str, attr: &str) -> Option<::std::string::String> { let res = &form.resource; out.push_str(&format!("form\t{}\t{}\t{}\n", res.symbol, res.ident, res.id)); } + for (handle_ident, form_ident) in &self.handle_forms { + out.push_str(&format!("handle_form\t{handle_ident}\t{form_ident}\n")); + } for res in self.atoms.values() { out.push_str(&format!("atom\t{}\t{}\t{}\n", res.symbol, res.ident, res.id)); } @@ -914,6 +917,7 @@ mod tests { let syms = std::fs::read_to_string(out.join("slhx.syms")).unwrap(); assert!(syms.contains("atom\t")); assert!(syms.contains("\tfilter\t")); + assert!(syms.contains("handle_form\tcreate\tnew_todo\n")); let _ = std::fs::remove_dir_all(&base); } diff --git a/slhx-derive/src/lib.rs b/slhx-derive/src/lib.rs index 42de439..73226e3 100644 --- a/slhx-derive/src/lib.rs +++ b/slhx-derive/src/lib.rs @@ -30,6 +30,16 @@ pub fn handler(_attr: TokenStream, item: TokenStream) -> TokenStream { ) .into(); } + 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<_>" + ); + return quote!( + #function + compile_error!(#message); + ) + .into(); + } } } @@ -125,6 +135,17 @@ fn syms_contains_handle(path: &PathBuf, ident: &str) -> bool { }) } +fn handle_requires_form(path: &PathBuf, ident: &str) -> bool { + let Ok(syms) = std::fs::read_to_string(path) else { + return false; + }; + 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) + }) +} + fn compile_error(message: &str) -> TokenStream { format!("compile_error!({message:?});") .parse() @@ -133,7 +154,7 @@ fn compile_error(message: &str) -> TokenStream { #[cfg(test)] mod tests { - use super::{has_form_param, has_non_unit_return, syms_contains_handle}; + use super::{handle_requires_form, has_form_param, has_non_unit_return, syms_contains_handle}; use syn::parse_quote; #[test] @@ -141,12 +162,14 @@ mod tests { let path = std::env::temp_dir().join("slhx-derive-syms-test.syms"); std::fs::write( &path, - "slhx-syms-v1\nslot\ttemplates/a.heml::count\tcount\t1\nhandle\ttemplates/a.heml::create\tcreate\t2\n", + "slhx-syms-v1\nslot\ttemplates/a.heml::count\tcount\t1\nhandle\ttemplates/a.heml::create\tcreate\t2\nhandle_form\tcreate\tnew_todo\n", ) .unwrap(); assert!(syms_contains_handle(&path, "create")); assert!(!syms_contains_handle(&path, "missing")); + assert!(handle_requires_form(&path, "create")); + assert!(!handle_requires_form(&path, "missing")); let _ = std::fs::remove_file(path); } diff --git a/slhx-derive/tests/compile_fail.rs b/slhx-derive/tests/compile_fail.rs index c73a5f8..8cad1b7 100644 --- a/slhx-derive/tests/compile_fail.rs +++ b/slhx-derive/tests/compile_fail.rs @@ -60,6 +60,58 @@ fn known() {} ); } +#[test] +fn form_handle_requires_form_parameter() { + // req: form/004 req: form/006 req: test/003 + let fixture = Fixture::new("slhx-derive-form-handler-fail"); + fixture.write( + "Cargo.toml", + &format!( + r#"[package] +name = "slhx-derive-form-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/app.heml::create\tcreate\t1\nhandle_form\tcreate\tnew_todo\n", + ) + .unwrap(); +} +"#, + ); + fixture.write( + "src/lib.rs", + r#"#[slhx::handler] +fn create() -> 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-handler diagnostic in stderr:\n{stderr}" + ); +} + #[test] fn surface_macro_reports_missing_generated_include() { // req: build/004 req: test/003