From 93c68b6d87c5d77faa37e350e6bbaf620bfd993e Mon Sep 17 00:00:00 2001 From: slhx agent Date: Tue, 26 May 2026 00:18:17 +0200 Subject: [PATCH] feat(derive): scope component handler checks Allow #[slhx::component("name")] to validate only the generated handles for that hemplate component, so component modules can be checked independently without implementing unrelated handles. req: component/003 req: component/005 --- slhx-derive/src/lib.rs | 36 ++++++++++++++++----- slhx-derive/tests/compile_fail.rs | 54 +++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 8 deletions(-) diff --git a/slhx-derive/src/lib.rs b/slhx-derive/src/lib.rs index 30dc50d..91192e6 100644 --- a/slhx-derive/src/lib.rs +++ b/slhx-derive/src/lib.rs @@ -124,7 +124,12 @@ pub fn form(attr: TokenStream, item: TokenStream) -> TokenStream { } #[proc_macro_attribute] -pub fn component(_attr: TokenStream, item: TokenStream) -> TokenStream { +pub fn component(attr: TokenStream, item: TokenStream) -> TokenStream { + let component_name = if attr.is_empty() { + None + } else { + Some(parse_macro_input!(attr as LitStr).value()) + }; let module = parse_macro_input!(item as ItemMod); let Some((_, items)) = &module.content else { return quote!( @@ -139,7 +144,8 @@ pub fn component(_attr: TokenStream, item: TokenStream) -> TokenStream { if !syms_path.exists() { return quote!(#module).into(); } - let missing = missing_component_handlers(&syms_path, items); + let component_filter = component_name.as_deref(); + let missing = missing_component_handlers(&syms_path, component_filter, items); if !missing.is_empty() { let message = format!( "#[slhx::component] missing handler implementation(s): {}", @@ -495,9 +501,9 @@ fn handler_arg_names(function: &ItemFn) -> Vec { .collect() } -fn missing_component_handlers(path: &PathBuf, items: &[Item]) -> Vec { +fn missing_component_handlers(path: &PathBuf, component: Option<&str>, items: &[Item]) -> Vec { let implemented = component_handler_names(items); - syms_handles(path) + syms_handles(path, component) .into_iter() .filter(|handle| !implemented.contains(handle)) .collect() @@ -522,7 +528,7 @@ fn has_handler_attr(function: &ItemFn) -> bool { }) } -fn syms_handles(path: &PathBuf) -> Vec { +fn syms_handles(path: &PathBuf, component: Option<&str>) -> Vec { let Ok(syms) = std::fs::read_to_string(path) else { return Vec::new(); }; @@ -532,11 +538,24 @@ fn syms_handles(path: &PathBuf) -> Vec { if !matches!(fields.next(), Some("handle")) { return None; } - fields.nth(1).map(ToOwned::to_owned) + let symbol = fields.next()?; + if let Some(component) = component { + if symbol_component(symbol) != Some(component) { + return None; + } + } + fields.next().map(ToOwned::to_owned) }) .collect() } +fn symbol_component(symbol: &str) -> Option<&str> { + let path = symbol.split_once("::")?.0; + path.rsplit_once('/') + .map_or(path, |(_, stem)| stem) + .strip_suffix(".heml") +} + fn compile_error(message: &str) -> TokenStream { format!("compile_error!({message:?});") .parse() @@ -632,7 +651,7 @@ mod tests { 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", + "slhx-syms-v1\nhandle\ttemplates/a.heml::create\tcreate\t1\nhandle\ttemplates/a.heml::delete\tdelete\t2\nhandle\ttemplates/other.heml::archive\tarchive\t3\n", ) .unwrap(); let module: syn::ItemMod = parse_quote! { @@ -644,7 +663,8 @@ mod tests { let (_, items) = module.content.expect("inline module"); assert_eq!(component_handler_names(&items), vec!["create"]); - assert_eq!(missing_component_handlers(&path, &items), vec!["delete"]); + assert_eq!(missing_component_handlers(&path, None, &items), vec!["delete", "archive"]); + assert_eq!(missing_component_handlers(&path, Some("a"), &items), vec!["delete"]); let _ = std::fs::remove_file(path); } diff --git a/slhx-derive/tests/compile_fail.rs b/slhx-derive/tests/compile_fail.rs index 670f53f..4281954 100644 --- a/slhx-derive/tests/compile_fail.rs +++ b/slhx-derive/tests/compile_fail.rs @@ -56,6 +56,60 @@ mod todos { ); } +#[test] +fn component_macro_can_validate_one_generated_component() { + // req: component/003 req: component/005 req: test/003 + let fixture = Fixture::new("slhx-derive-component-scoped-pass"); + fixture.write( + "Cargo.toml", + &format!( + r#"[package] +name = "slhx-derive-component-scoped-pass" +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/todos.heml::create\tcreate\t1\nhandle\ttemplates/admin.heml::delete\tdelete\t2\n", + ) + .unwrap(); +} +"#, + ); + fixture.write( + "src/lib.rs", + r#"#[slhx::component("todos")] +mod todos { + #[slhx::handler] + fn create() -> impl slhx::IntoEffect { + slhx::event("created", "") + } +} +"#, + ); + + let output = check_fixture(&fixture); + + assert!( + output.status.success(), + "fixture failed to compile:\n{}", + String::from_utf8_lossy(&output.stderr) + ); +} + #[test] fn handler_macro_reports_unknown_handle_and_bad_shape() { // req: derive_handler/001 req: test/003