From d223a1d21529eb1cbb31e112933b536dcae746f8 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Mon, 25 May 2026 22:30:07 +0200 Subject: [PATCH] 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 --- slhx-derive/src/lib.rs | 109 +++++++++++++++++++++++++++++- slhx-derive/tests/compile_fail.rs | 55 +++++++++++++++ 2 files changed, 161 insertions(+), 3 deletions(-) diff --git a/slhx-derive/src/lib.rs b/slhx-derive/src/lib.rs index ddded58..90cbae0 100644 --- a/slhx-derive/src/lib.rs +++ b/slhx-derive/src/lib.rs @@ -1,7 +1,7 @@ use proc_macro::TokenStream; use quote::quote; 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] pub fn handler(_attr: TokenStream, item: TokenStream) -> TokenStream { @@ -80,7 +80,46 @@ pub fn surface(_attr: TokenStream, item: TokenStream) -> TokenStream { #[proc_macro_attribute] 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] @@ -218,6 +257,48 @@ fn handler_arg_names(function: &ItemFn) -> Vec { .collect() } +fn missing_component_handlers(path: &PathBuf, items: &[Item]) -> Vec { + let implemented = component_handler_names(items); + syms_handles(path) + .into_iter() + .filter(|handle| !implemented.contains(handle)) + .collect() +} + +fn component_handler_names(items: &[Item]) -> Vec { + 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 { + 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 { format!("compile_error!({message:?});") .parse() @@ -226,7 +307,7 @@ fn compile_error(message: &str) -> TokenStream { #[cfg(test)] 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}; #[test] @@ -283,4 +364,26 @@ mod tests { 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); + } } diff --git a/slhx-derive/tests/compile_fail.rs b/slhx-derive/tests/compile_fail.rs index 88b5d25..d0a65be 100644 --- a/slhx-derive/tests/compile_fail.rs +++ b/slhx-derive/tests/compile_fail.rs @@ -1,6 +1,61 @@ use std::path::{Path, PathBuf}; 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] fn handler_macro_reports_unknown_handle_and_bad_shape() { // req: derive_handler/001 req: test/003