93c68b6d87
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
672 lines
22 KiB
Rust
672 lines
22 KiB
Rust
use proc_macro::TokenStream;
|
|
use quote::quote;
|
|
use std::path::PathBuf;
|
|
use syn::{
|
|
parse_macro_input, parse_quote, Fields, FnArg, GenericArgument, Item, ItemFn, ItemMod,
|
|
ItemStruct, LitStr, Pat, PathArguments, ReturnType, Type,
|
|
};
|
|
|
|
#[proc_macro_attribute]
|
|
pub fn handler(_attr: TokenStream, item: TokenStream) -> TokenStream {
|
|
let mut function = parse_macro_input!(item as ItemFn);
|
|
let name = function.sig.ident.to_string();
|
|
|
|
let Some(syms_path) = syms_path() else {
|
|
let message = "#[slhx::handler] requires OUT_DIR; run inside a Cargo crate with slhx_build::app() in build.rs";
|
|
return quote!(
|
|
#function
|
|
compile_error!(#message);
|
|
)
|
|
.into();
|
|
};
|
|
if !syms_path.exists() {
|
|
let message = format!(
|
|
"#[slhx::handler] could not find {}; add slhx_build::app().run()? to build.rs or check template generation",
|
|
syms_path.display()
|
|
);
|
|
return quote!(
|
|
#function
|
|
compile_error!(#message);
|
|
)
|
|
.into();
|
|
}
|
|
if !syms_contains_handle(&syms_path, &name) {
|
|
let message = format!(
|
|
"unknown slhx handle `{name}`; add `data-slhx-handle=\"{name}\"` to a template or rename this handler"
|
|
);
|
|
return quote!(
|
|
#function
|
|
compile_error!(#message);
|
|
)
|
|
.into();
|
|
}
|
|
if !has_form_param(&function) && !has_non_unit_return(&function) {
|
|
let message = format!(
|
|
"slhx handler `{name}` must accept a form/context parameter or return a value implementing IntoEffect"
|
|
);
|
|
return quote!(
|
|
#function
|
|
compile_error!(#message);
|
|
)
|
|
.into();
|
|
}
|
|
if handle_requires_form(&syms_path, &name) && !has_form_param(&function) {
|
|
let message = format!(
|
|
"slhx handler `{name}` handles a generated form and must accept slhx::Form<_>"
|
|
);
|
|
return quote!(
|
|
#function
|
|
compile_error!(#message);
|
|
)
|
|
.into();
|
|
}
|
|
if handle_requires_form(&syms_path, &name) {
|
|
add_form_model_bounds(&mut function);
|
|
}
|
|
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()
|
|
}
|
|
|
|
#[proc_macro_attribute]
|
|
pub fn surface(_attr: TokenStream, item: TokenStream) -> TokenStream {
|
|
inject_surface_include(item)
|
|
}
|
|
|
|
#[proc_macro_attribute]
|
|
pub fn form(attr: TokenStream, item: TokenStream) -> TokenStream {
|
|
let form_name = parse_macro_input!(attr as LitStr).value();
|
|
let form_struct = parse_macro_input!(item as ItemStruct);
|
|
let Some(syms_path) = syms_path() else {
|
|
let message = "#[slhx::form] requires OUT_DIR; run inside a Cargo crate with slhx_build::app() in build.rs";
|
|
return quote!(
|
|
#form_struct
|
|
compile_error!(#message);
|
|
)
|
|
.into();
|
|
};
|
|
let errors = form_contract_errors(&syms_path, &form_name, &form_struct);
|
|
if errors.is_empty() {
|
|
let ident = &form_struct.ident;
|
|
let resource_id = form_resource_id(&syms_path, &form_name).expect("checked form exists in slhx.syms");
|
|
let mut generics = form_struct.generics.clone();
|
|
for ty in form_parser_types(&form_struct) {
|
|
generics.make_where_clause().predicates.push(parse_quote!(#ty: ::slhx::FormValue));
|
|
}
|
|
let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
|
|
quote!(
|
|
#form_struct
|
|
impl #impl_generics ::slhx::FormModel for #ident #ty_generics #where_clause {}
|
|
impl #impl_generics #ident #ty_generics #where_clause {
|
|
pub const FORM: ::slhx::Form<Self> = ::slhx::Form::new(#resource_id);
|
|
}
|
|
)
|
|
.into()
|
|
} else {
|
|
let message = errors.join("; ");
|
|
quote!(
|
|
#form_struct
|
|
compile_error!(#message);
|
|
)
|
|
.into()
|
|
}
|
|
}
|
|
|
|
#[proc_macro_attribute]
|
|
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!(
|
|
#module
|
|
compile_error!("#[slhx::component] must be used on an inline module");
|
|
)
|
|
.into();
|
|
};
|
|
let Some(syms_path) = syms_path() else {
|
|
return quote!(#module).into();
|
|
};
|
|
if !syms_path.exists() {
|
|
return quote!(#module).into();
|
|
}
|
|
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): {}",
|
|
missing.join(", ")
|
|
);
|
|
return quote!(
|
|
#module
|
|
compile_error!(#message);
|
|
)
|
|
.into();
|
|
}
|
|
|
|
quote!(#module).into()
|
|
}
|
|
|
|
#[proc_macro_attribute]
|
|
pub fn app(_attr: TokenStream, item: TokenStream) -> TokenStream {
|
|
item
|
|
}
|
|
|
|
fn inject_surface_include(item: TokenStream) -> TokenStream {
|
|
let item = item.to_string();
|
|
let Some(insert_at) = item.rfind('}') else {
|
|
return compile_error("#[slhx::surface] must be used on an inline module");
|
|
};
|
|
|
|
let include = surface_include();
|
|
let expanded = format!("{}{}{}", &item[..insert_at], include, &item[insert_at..]);
|
|
expanded
|
|
.parse()
|
|
.unwrap_or_else(|_| compile_error("#[slhx::surface] could not expand this module"))
|
|
}
|
|
|
|
fn surface_include() -> String {
|
|
let Some(path) = generated_path("slhx.generated.rs") else {
|
|
return format!(
|
|
" compile_error!({:?}); ",
|
|
"#[slhx::surface] requires OUT_DIR; run inside a Cargo crate with slhx_build::app() in build.rs"
|
|
);
|
|
};
|
|
|
|
if path.exists() {
|
|
format!(" include!({:?}); ", path.display().to_string())
|
|
} else {
|
|
format!(
|
|
" compile_error!({:?}); ",
|
|
format!(
|
|
"#[slhx::surface] could not find {}; add slhx_build::app().run()? to build.rs or check template generation",
|
|
path.display()
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
fn syms_path() -> Option<PathBuf> {
|
|
generated_path("slhx.syms")
|
|
}
|
|
|
|
fn generated_path(file: &str) -> Option<PathBuf> {
|
|
std::env::var_os("OUT_DIR").map(|out_dir| PathBuf::from(out_dir).join(file))
|
|
}
|
|
|
|
fn has_form_param(function: &ItemFn) -> bool {
|
|
function.sig.inputs.iter().any(|arg| match arg {
|
|
FnArg::Typed(arg) => is_form_type(&arg.ty),
|
|
FnArg::Receiver(_) => false,
|
|
})
|
|
}
|
|
|
|
fn is_form_type(ty: &Type) -> bool {
|
|
form_model_type(ty).is_some()
|
|
}
|
|
|
|
fn form_model_type(ty: &Type) -> Option<Type> {
|
|
let Type::Path(path) = ty else {
|
|
return None;
|
|
};
|
|
let mut segments = path.path.segments.iter();
|
|
let first = segments.next()?;
|
|
let last = path.path.segments.last().expect("path has at least one segment");
|
|
let path_is_form = if path.path.segments.len() == 1 {
|
|
first.ident == "Form"
|
|
} else {
|
|
first.ident == "slhx" && last.ident == "Form"
|
|
};
|
|
if !path_is_form {
|
|
return None;
|
|
}
|
|
let PathArguments::AngleBracketed(args) = &last.arguments else {
|
|
return None;
|
|
};
|
|
args.args.iter().find_map(|arg| match arg {
|
|
GenericArgument::Type(ty) => Some(ty.clone()),
|
|
_ => None,
|
|
})
|
|
}
|
|
|
|
fn form_model_types(function: &ItemFn) -> Vec<Type> {
|
|
function
|
|
.sig
|
|
.inputs
|
|
.iter()
|
|
.filter_map(|arg| match arg {
|
|
FnArg::Typed(arg) => form_model_type(&arg.ty),
|
|
FnArg::Receiver(_) => None,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn add_form_model_bounds(function: &mut ItemFn) {
|
|
for ty in form_model_types(function) {
|
|
let where_clause = function.sig.generics.make_where_clause();
|
|
where_clause.predicates.push(parse_quote!(#ty: ::slhx::FormModel));
|
|
}
|
|
}
|
|
|
|
fn has_non_unit_return(function: &ItemFn) -> bool {
|
|
match &function.sig.output {
|
|
ReturnType::Default => false,
|
|
ReturnType::Type(_, ty) => !matches!(ty.as_ref(), Type::Tuple(tuple) if tuple.elems.is_empty()),
|
|
}
|
|
}
|
|
|
|
fn syms_contains_handle(path: &PathBuf, ident: &str) -> bool {
|
|
let Ok(syms) = std::fs::read_to_string(path) else {
|
|
return true;
|
|
};
|
|
syms.lines().any(|line| {
|
|
let mut fields = line.split('\t');
|
|
matches!(fields.next(), Some("handle"))
|
|
&& fields.nth(1).is_some_and(|handle_ident| handle_ident == ident)
|
|
})
|
|
}
|
|
|
|
fn handle_requires_form(path: &PathBuf, ident: &str) -> bool {
|
|
let Ok(syms) = std::fs::read_to_string(path) else {
|
|
return false;
|
|
};
|
|
syms.lines().any(|line| {
|
|
let mut fields = line.split('\t');
|
|
matches!(fields.next(), Some("handle_form"))
|
|
&& fields.next().is_some_and(|handle_ident| handle_ident == ident)
|
|
})
|
|
}
|
|
|
|
#[derive(Debug, Eq, PartialEq)]
|
|
struct GeneratedFormField {
|
|
name: String,
|
|
ident: String,
|
|
required: bool,
|
|
multiple: bool,
|
|
}
|
|
|
|
fn form_contract_errors(syms_path: &PathBuf, form_name: &str, form_struct: &ItemStruct) -> Vec<String> {
|
|
if !syms_path.exists() {
|
|
return vec![format!(
|
|
"#[slhx::form] could not find {}; add slhx_build::app().run()? to build.rs or check template generation",
|
|
syms_path.display()
|
|
)];
|
|
}
|
|
let expected = form_fields(syms_path, form_name);
|
|
if expected.is_empty() {
|
|
return vec![format!(
|
|
"unknown slhx form `{form_name}`; add data-slhx-form=\"{form_name}\" to a template or rename this form binding"
|
|
)];
|
|
}
|
|
let Fields::Named(fields) = &form_struct.fields else {
|
|
return vec![format!(
|
|
"slhx form `{form_name}` must be a struct with named fields"
|
|
)];
|
|
};
|
|
let actual = fields
|
|
.named
|
|
.iter()
|
|
.filter_map(|field| field.ident.as_ref().map(|ident| (ident.to_string(), &field.ty)))
|
|
.collect::<Vec<_>>();
|
|
let mut errors = Vec::new();
|
|
for field in expected {
|
|
let Some((_, ty)) = actual.iter().find(|(ident, _)| ident == &field.ident) else {
|
|
errors.push(format!(
|
|
"slhx form `{form_name}` is missing field `{}` for form control `{}`",
|
|
field.ident, field.name
|
|
));
|
|
continue;
|
|
};
|
|
let optional = is_type_named(ty, "Option");
|
|
let multiple = is_type_named(ty, "Vec");
|
|
if field.required && optional {
|
|
errors.push(format!(
|
|
"slhx form `{form_name}` field `{}` is required in HTML and must not be Option<_>",
|
|
field.ident
|
|
));
|
|
}
|
|
if !field.required && !optional && !multiple {
|
|
errors.push(format!(
|
|
"slhx form `{form_name}` field `{}` is optional in HTML and must be Option<_>",
|
|
field.ident
|
|
));
|
|
}
|
|
if field.multiple && !multiple {
|
|
errors.push(format!(
|
|
"slhx form `{form_name}` field `{}` accepts multiple values and must be Vec<_>",
|
|
field.ident
|
|
));
|
|
}
|
|
if !field.multiple && multiple {
|
|
errors.push(format!(
|
|
"slhx form `{form_name}` field `{}` accepts one value and must not be Vec<_>",
|
|
field.ident
|
|
));
|
|
}
|
|
}
|
|
errors
|
|
}
|
|
|
|
fn form_parser_types(form_struct: &ItemStruct) -> Vec<Type> {
|
|
let Fields::Named(fields) = &form_struct.fields else {
|
|
return Vec::new();
|
|
};
|
|
fields
|
|
.named
|
|
.iter()
|
|
.map(|field| parser_type(&field.ty).clone())
|
|
.collect()
|
|
}
|
|
|
|
fn parser_type(ty: &Type) -> &Type {
|
|
generic_inner_type(ty, "Option")
|
|
.or_else(|| generic_inner_type(ty, "Vec"))
|
|
.unwrap_or(ty)
|
|
}
|
|
|
|
fn generic_inner_type<'a>(ty: &'a Type, name: &str) -> Option<&'a Type> {
|
|
let Type::Path(path) = ty else {
|
|
return None;
|
|
};
|
|
let segment = path.path.segments.last()?;
|
|
if segment.ident != name {
|
|
return None;
|
|
}
|
|
let PathArguments::AngleBracketed(args) = &segment.arguments else {
|
|
return None;
|
|
};
|
|
args.args.iter().find_map(|arg| match arg {
|
|
GenericArgument::Type(ty) => Some(ty),
|
|
_ => None,
|
|
})
|
|
}
|
|
|
|
fn form_resource_id(path: &PathBuf, form_name: &str) -> Option<u32> {
|
|
let syms = std::fs::read_to_string(path).ok()?;
|
|
syms.lines().find_map(|line| {
|
|
let mut fields = line.split('\t');
|
|
if !matches!(fields.next(), Some("form")) {
|
|
return None;
|
|
}
|
|
if fields.nth(1)? != form_name {
|
|
return None;
|
|
}
|
|
fields.next()?.parse().ok()
|
|
})
|
|
}
|
|
|
|
fn form_fields(path: &PathBuf, form_name: &str) -> Vec<GeneratedFormField> {
|
|
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("form_field")) {
|
|
return None;
|
|
}
|
|
if fields.next()? != form_name {
|
|
return None;
|
|
}
|
|
let name = fields.next()?.to_string();
|
|
Some(GeneratedFormField {
|
|
ident: rust_ident(&name)?,
|
|
name,
|
|
required: fields.next() == Some("true"),
|
|
multiple: fields.next() == Some("true"),
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn is_type_named(ty: &Type, name: &str) -> bool {
|
|
match ty {
|
|
Type::Path(path) => path.path.segments.last().is_some_and(|segment| segment.ident == name),
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
fn rust_ident(name: &str) -> Option<String> {
|
|
let mut out = String::new();
|
|
for ch in name.chars() {
|
|
if ch == '-' || ch == '_' || ch.is_ascii_alphanumeric() {
|
|
out.push(if ch == '-' { '_' } else { ch });
|
|
} else {
|
|
return None;
|
|
}
|
|
}
|
|
let first = out.chars().next()?;
|
|
if !(first == '_' || first.is_ascii_alphabetic()) {
|
|
return None;
|
|
}
|
|
Some(out)
|
|
}
|
|
|
|
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 missing_component_handlers(path: &PathBuf, component: Option<&str>, items: &[Item]) -> Vec<String> {
|
|
let implemented = component_handler_names(items);
|
|
syms_handles(path, component)
|
|
.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, component: Option<&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")) {
|
|
return None;
|
|
}
|
|
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()
|
|
.expect("compile_error expansion is valid")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
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, parser_type, syms_contains_handle};
|
|
use quote::quote;
|
|
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\nhandle_param\tcreate\ttodo_id\n",
|
|
)
|
|
.unwrap();
|
|
|
|
assert!(syms_contains_handle(&path, "create"));
|
|
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);
|
|
}
|
|
|
|
#[test]
|
|
fn handler_shape_accepts_form_or_effect_return() {
|
|
// req: derive_handler/001
|
|
let with_form = parse_quote!(fn save(form: slhx::Form<String>) {});
|
|
let with_return = parse_quote!(fn ping() -> impl slhx::IntoEffect { slhx::EffectBatch::default() });
|
|
let empty = parse_quote!(fn noop() {});
|
|
|
|
assert!(has_form_param(&with_form));
|
|
assert!(has_non_unit_return(&with_return));
|
|
assert!(!has_form_param(&empty));
|
|
assert!(!has_non_unit_return(&empty));
|
|
}
|
|
|
|
#[test]
|
|
fn form_parser_type_uses_option_and_vec_inner_types() {
|
|
// req: form/004
|
|
let required: Type = parse_quote!(Email);
|
|
let optional: Type = parse_quote!(Option<Email>);
|
|
let multiple: Type = parse_quote!(Vec<Email>);
|
|
|
|
let required_parser = parser_type(&required);
|
|
let optional_parser = parser_type(&optional);
|
|
let multiple_parser = parser_type(&multiple);
|
|
|
|
assert_eq!(quote!(#required_parser).to_string(), quote!(#required).to_string());
|
|
assert_eq!(quote!(#optional_parser).to_string(), quote!(#required).to_string());
|
|
assert_eq!(quote!(#multiple_parser).to_string(), quote!(#required).to_string());
|
|
}
|
|
|
|
#[test]
|
|
fn form_param_matches_form_type_not_name_suffix() {
|
|
// req: form/004 req: form/006
|
|
let qualified_form: Type = parse_quote!(slhx::Form<CreateTodo>);
|
|
let imported_form: Type = parse_quote!(Form<CreateTodo>);
|
|
let name_suffix_impostor: Type = parse_quote!(CreateTodoForm);
|
|
let nongeneric_impostor: Type = parse_quote!(Form);
|
|
let foreign_form: Type = parse_quote!(other::Form<CreateTodo>);
|
|
|
|
assert!(is_form_type(&qualified_form));
|
|
assert!(is_form_type(&imported_form));
|
|
assert!(!is_form_type(&name_suffix_impostor));
|
|
assert!(!is_form_type(&nongeneric_impostor));
|
|
assert!(!is_form_type(&foreign_form));
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
|
|
#[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\nhandle\ttemplates/other.heml::archive\tarchive\t3\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, None, &items), vec!["delete", "archive"]);
|
|
assert_eq!(missing_component_handlers(&path, Some("a"), &items), vec!["delete"]);
|
|
|
|
let _ = std::fs::remove_file(path);
|
|
}
|
|
}
|