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
+39 -1
View File
@@ -72,6 +72,7 @@ struct Resources {
slots: BTreeMap<String, Resource>,
handles: BTreeMap<String, Resource>,
handle_forms: BTreeMap<String, String>,
handle_params: BTreeMap<String, BTreeSet<String>>,
forms: BTreeMap<String, FormResource>,
atoms: BTreeMap<String, Resource>,
classes: BTreeMap<String, ClassToken>,
@@ -139,6 +140,7 @@ impl Resources {
reject_unkeyed_loop(surface, node.scope, path, "handle", &name)?;
let canonical = canonical_symbol(root, path, &name);
self.insert_handle(canonical, name.clone(), component.clone())?;
self.insert_handle_params(&name, &node.attrs)?;
if tag == "form" {
let form_name = static_attr(&node.attrs, "data-slhx-form").unwrap_or_else(|| name.clone());
@@ -211,6 +213,25 @@ impl Resources {
}
}
fn insert_handle_params(&mut self, handle: &str, attrs: &[SurfaceAttribute]) -> io::Result<()> {
let Some(handle_ident) = rust_ident(handle) else {
return Ok(());
};
for attr in attrs {
if !is_handle_param_attr(attr) {
continue;
}
let Some(param) = data_param_ident(&attr.name) else {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("invalid handler param attribute `{}`; expected data-* name usable from Rust", attr.name),
));
};
self.handle_params.entry(handle_ident.clone()).or_default().insert(param);
}
Ok(())
}
fn insert_form(
&mut self,
symbol: String,
@@ -518,6 +539,11 @@ fn __slhx_attr(tag: &str, attr: &str) -> Option<::std::string::String> {
for (handle_ident, form_ident) in &self.handle_forms {
out.push_str(&format!("handle_form\t{handle_ident}\t{form_ident}\n"));
}
for (handle_ident, params) in &self.handle_params {
for param in params {
out.push_str(&format!("handle_param\t{handle_ident}\t{param}\n"));
}
}
for res in self.atoms.values() {
out.push_str(&format!("atom\t{}\t{}\t{}\n", res.symbol, res.ident, res.id));
}
@@ -750,6 +776,17 @@ fn class_ident(token: &str) -> Option<String> {
rust_ident(&ident)
}
fn is_handle_param_attr(attr: &SurfaceAttribute) -> bool {
matches!(attr.origin, AttributeOrigin::Static | AttributeOrigin::Dynamic)
&& attr.name.starts_with("data-")
&& !attr.name.starts_with("data-slhx-")
}
fn data_param_ident(name: &str) -> Option<String> {
let data_name = name.strip_prefix("data-")?;
rust_ident(&data_name.replace('-', "_"))
}
fn is_inside_keyed_for(surface: &SurfaceDocument, mut scope: ScopeId) -> bool {
loop {
let Some(current) = surface.scopes.get(scope.0 as usize) else {
@@ -890,7 +927,7 @@ mod tests {
std::fs::create_dir_all(&templates).unwrap();
std::fs::write(
templates.join("todo.heml"),
r#"<form data-slhx-handle="create" data-slhx-form="new_todo"><input name="title"></form><ul data-slhx-slot="todos"></ul><section data-slhx-atom="filter"></section>"#,
r#"<form data-slhx-handle="create" data-slhx-form="new_todo"><input name="title"></form><button data-slhx-handle="delete" data-todo-id="7">Delete</button><ul data-slhx-slot="todos"></ul><section data-slhx-atom="filter"></section>"#,
)
.unwrap();
@@ -918,6 +955,7 @@ mod tests {
assert!(syms.contains("atom\t"));
assert!(syms.contains("\tfilter\t"));
assert!(syms.contains("handle_form\tcreate\tnew_todo\n"));
assert!(syms.contains("handle_param\tdelete\ttodo_id\n"));
let _ = std::fs::remove_dir_all(&base);
}