feat(derive): check component handlers

Make #[slhx::component] compare generated handle symbols with inline #[slhx::handler] functions and fail when a required handler implementation is missing.

req: component/005

req: check/002

req: test/003
This commit is contained in:
slhx agent
2026-05-25 22:30:07 +02:00
parent abe39ec750
commit d223a1d215
2 changed files with 161 additions and 3 deletions
+106 -3
View File
@@ -1,7 +1,7 @@
use proc_macro::TokenStream; use proc_macro::TokenStream;
use quote::quote; use quote::quote;
use std::path::PathBuf; use std::path::PathBuf;
use syn::{parse_macro_input, FnArg, ItemFn, Pat, ReturnType, Type}; use syn::{parse_macro_input, FnArg, Item, ItemFn, ItemMod, Pat, ReturnType, Type};
#[proc_macro_attribute] #[proc_macro_attribute]
pub fn handler(_attr: TokenStream, item: TokenStream) -> TokenStream { pub fn handler(_attr: TokenStream, item: TokenStream) -> TokenStream {
@@ -80,7 +80,46 @@ pub fn surface(_attr: TokenStream, item: TokenStream) -> TokenStream {
#[proc_macro_attribute] #[proc_macro_attribute]
pub fn component(_attr: TokenStream, item: TokenStream) -> TokenStream { pub fn component(_attr: TokenStream, item: TokenStream) -> TokenStream {
item let module = parse_macro_input!(item as ItemMod);
let Some((_, items)) = &module.content else {
return quote!(
#module
compile_error!("#[slhx::component] must be used on an inline module");
)
.into();
};
let Some(syms_path) = syms_path() else {
return quote!(
#module
compile_error!("#[slhx::component] requires OUT_DIR; run inside a Cargo crate with slhx_build::app() in build.rs");
)
.into();
};
if !syms_path.exists() {
let message = format!(
"#[slhx::component] could not find {}; add slhx_build::app().run()? to build.rs or check template generation",
syms_path.display()
);
return quote!(
#module
compile_error!(#message);
)
.into();
}
let missing = missing_component_handlers(&syms_path, items);
if !missing.is_empty() {
let message = format!(
"#[slhx::component] missing handler implementation(s): {}",
missing.join(", ")
);
return quote!(
#module
compile_error!(#message);
)
.into();
}
quote!(#module).into()
} }
#[proc_macro_attribute] #[proc_macro_attribute]
@@ -218,6 +257,48 @@ fn handler_arg_names(function: &ItemFn) -> Vec<String> {
.collect() .collect()
} }
fn missing_component_handlers(path: &PathBuf, items: &[Item]) -> Vec<String> {
let implemented = component_handler_names(items);
syms_handles(path)
.into_iter()
.filter(|handle| !implemented.contains(handle))
.collect()
}
fn component_handler_names(items: &[Item]) -> Vec<String> {
items
.iter()
.filter_map(|item| match item {
Item::Fn(function) if has_handler_attr(function) => Some(function.sig.ident.to_string()),
_ => None,
})
.collect()
}
fn has_handler_attr(function: &ItemFn) -> bool {
function.attrs.iter().any(|attr| {
attr.path()
.segments
.last()
.is_some_and(|segment| segment.ident == "handler")
})
}
fn syms_handles(path: &PathBuf) -> 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")) {
return None;
}
fields.nth(1).map(ToOwned::to_owned)
})
.collect()
}
fn compile_error(message: &str) -> TokenStream { fn compile_error(message: &str) -> TokenStream {
format!("compile_error!({message:?});") format!("compile_error!({message:?});")
.parse() .parse()
@@ -226,7 +307,7 @@ fn compile_error(message: &str) -> TokenStream {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{handle_params, handle_requires_form, has_form_param, has_non_unit_return, is_form_type, missing_handle_params, syms_contains_handle}; use super::{component_handler_names, handle_params, handle_requires_form, has_form_param, has_non_unit_return, is_form_type, missing_component_handlers, missing_handle_params, syms_contains_handle};
use syn::{parse_quote, ItemFn, Type}; use syn::{parse_quote, ItemFn, Type};
#[test] #[test]
@@ -283,4 +364,26 @@ mod tests {
let _ = std::fs::remove_file(path); let _ = std::fs::remove_file(path);
} }
#[test]
fn component_handlers_match_generated_handles() {
let path = std::env::temp_dir().join("slhx-derive-component-test.syms");
std::fs::write(
&path,
"slhx-syms-v1\nhandle\ttemplates/a.heml::create\tcreate\t1\nhandle\ttemplates/a.heml::delete\tdelete\t2\n",
)
.unwrap();
let module: syn::ItemMod = parse_quote! {
mod component {
#[slhx::handler]
fn create() -> impl slhx::IntoEffect { slhx::EffectBatch::default() }
}
};
let (_, items) = module.content.expect("inline module");
assert_eq!(component_handler_names(&items), vec!["create"]);
assert_eq!(missing_component_handlers(&path, &items), vec!["delete"]);
let _ = std::fs::remove_file(path);
}
} }
+55
View File
@@ -1,6 +1,61 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
#[test]
fn component_macro_reports_missing_handler_implementation() {
// req: component/005 req: check/002 req: test/003
let fixture = Fixture::new("slhx-derive-component-missing-handler-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-component-missing-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\ttemplates/app.heml::delete\tdelete\t2\n",
)
.unwrap();
}
"#,
);
fixture.write(
"src/lib.rs",
r#"#[slhx::component]
mod todos {
#[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::component] missing handler implementation(s): delete"),
"missing component diagnostic in stderr:\n{stderr}"
);
}
#[test] #[test]
fn handler_macro_reports_unknown_handle_and_bad_shape() { fn handler_macro_reports_unknown_handle_and_bad_shape() {
// req: derive_handler/001 req: test/003 // req: derive_handler/001 req: test/003