feat(derive): check generated handler params

Emit handle_param facts for handled data-* attributes and reject handlers that omit generated param arguments. Cover the generated param boundary with unit and compile-fail tests.

req: derive_handler/001

req: test/003
This commit is contained in:
slhx agent
2026-05-25 22:17:08 +02:00
parent 6d9d5e4c4e
commit d25361d34d
3 changed files with 168 additions and 5 deletions
+77 -4
View File
@@ -1,7 +1,7 @@
use proc_macro::TokenStream;
use quote::quote;
use std::path::PathBuf;
use syn::{parse_macro_input, FnArg, ItemFn, ReturnType, Type};
use syn::{parse_macro_input, FnArg, ItemFn, Pat, ReturnType, Type};
#[proc_macro_attribute]
pub fn handler(_attr: TokenStream, item: TokenStream) -> TokenStream {
@@ -57,6 +57,18 @@ pub fn handler(_attr: TokenStream, item: TokenStream) -> TokenStream {
)
.into();
}
let missing_params = missing_handle_params(&syms_path, &name, &function);
if !missing_params.is_empty() {
let message = format!(
"slhx handler `{name}` is missing generated param argument(s): {}",
missing_params.join(", ")
);
return quote!(
#function
compile_error!(#message);
)
.into();
}
quote!(#function).into()
}
@@ -161,6 +173,51 @@ fn handle_requires_form(path: &PathBuf, ident: &str) -> bool {
})
}
fn missing_handle_params(path: &PathBuf, ident: &str, function: &ItemFn) -> Vec<String> {
let required = handle_params(path, ident);
if required.is_empty() {
return Vec::new();
}
let args = handler_arg_names(function);
required
.into_iter()
.filter(|param| !args.contains(param))
.collect()
}
fn handle_params(path: &PathBuf, ident: &str) -> Vec<String> {
let Ok(syms) = std::fs::read_to_string(path) else {
return Vec::new();
};
syms.lines()
.filter_map(|line| {
let mut fields = line.split('\t');
if !matches!(fields.next(), Some("handle_param")) {
return None;
}
if fields.next()? != ident {
return None;
}
fields.next().map(ToOwned::to_owned)
})
.collect()
}
fn handler_arg_names(function: &ItemFn) -> Vec<String> {
function
.sig
.inputs
.iter()
.filter_map(|arg| match arg {
FnArg::Typed(arg) => match arg.pat.as_ref() {
Pat::Ident(ident) => Some(ident.ident.to_string()),
_ => None,
},
FnArg::Receiver(_) => None,
})
.collect()
}
fn compile_error(message: &str) -> TokenStream {
format!("compile_error!({message:?});")
.parse()
@@ -169,15 +226,15 @@ fn compile_error(message: &str) -> TokenStream {
#[cfg(test)]
mod tests {
use super::{handle_requires_form, has_form_param, has_non_unit_return, is_form_type, syms_contains_handle};
use syn::{parse_quote, Type};
use super::{handle_params, handle_requires_form, has_form_param, has_non_unit_return, is_form_type, missing_handle_params, syms_contains_handle};
use syn::{parse_quote, ItemFn, Type};
#[test]
fn syms_lookup_matches_handle_ident() {
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\nhandle_form\tcreate\tnew_todo\n",
"slhx-syms-v1\nslot\ttemplates/a.heml::count\tcount\t1\nhandle\ttemplates/a.heml::create\tcreate\t2\nhandle_form\tcreate\tnew_todo\nhandle_param\tcreate\ttodo_id\n",
)
.unwrap();
@@ -185,6 +242,8 @@ mod tests {
assert!(!syms_contains_handle(&path, "missing"));
assert!(handle_requires_form(&path, "create"));
assert!(!handle_requires_form(&path, "missing"));
assert_eq!(handle_params(&path, "create"), vec!["todo_id"]);
assert!(handle_params(&path, "missing").is_empty());
let _ = std::fs::remove_file(path);
}
@@ -210,4 +269,18 @@ mod tests {
assert!(is_form_type(&real_form));
assert!(!is_form_type(&impostor));
}
#[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() });
assert!(missing_handle_params(&path, "show", &complete).is_empty());
assert_eq!(missing_handle_params(&path, "show", &missing), vec!["mode"]);
let _ = std::fs::remove_file(path);
}
}