feat(derive): check form parser availability

Introduce FormValue as the parser-availability bound for #[slhx::form] models, blanket it for FromStr types, and add compile-fail coverage for a form field whose domain type lacks a parser.

req: form/004

req: form/006
This commit is contained in:
slhx agent
2026-05-25 23:48:56 +02:00
parent 274b8e9e55
commit 21716882ab
5 changed files with 117 additions and 4 deletions
+1 -1
View File
@@ -331,7 +331,7 @@ resources. Concrete runtime targets are addressed through `ResourceRef`
003 Handler receives `form: Form<CreateTodo>`. Validation errors target `(FormId, field_name)` or generated control ids. The runtime maps them to originating form controls via control ids derived from Surface `NodeId`, not via slot ids.
### req: form/004
004 Form compatibility checks validate field presence, optionality, multiplicity, and parser availability. Domain validation remains Rust logic (`TryFrom`, custom validators, or handler code).
004 Form compatibility checks validate field presence, optionality, multiplicity, and parser availability. Parser availability means the submitted value type implements `slhx::FormValue` (blanket-provided for `FromStr`, or explicitly implemented for custom parsers). Domain validation remains Rust logic (`TryFrom`, custom validators, or handler code).
### req: form/005
005 HTML control facts are lower bounds, not complete domain semantics.
+4
View File
@@ -849,6 +849,10 @@ impl<I> Handle<I> {
}
}
pub trait FormValue {}
impl<T> FormValue for T where T: std::str::FromStr {}
pub trait FormModel {}
#[derive(Debug, Eq, PartialEq, Hash)]
+57 -2
View File
@@ -91,7 +91,11 @@ pub fn form(attr: TokenStream, item: TokenStream) -> TokenStream {
let errors = form_contract_errors(&form_name, &form_struct);
if errors.is_empty() {
let ident = &form_struct.ident;
let (impl_generics, ty_generics, where_clause) = form_struct.generics.split_for_impl();
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 {}
@@ -342,6 +346,40 @@ fn form_contract_errors(form_name: &str, form_struct: &ItemStruct) -> Vec<String
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_fields(path: &PathBuf, form_name: &str) -> Vec<GeneratedFormField> {
let Ok(syms) = std::fs::read_to_string(path) else {
return Vec::new();
@@ -484,7 +522,8 @@ fn compile_error(message: &str) -> TokenStream {
#[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, 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, parser_type, syms_contains_handle};
use quote::quote;
use syn::{parse_quote, ItemFn, Type};
#[test]
@@ -519,6 +558,22 @@ mod tests {
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
+54
View File
@@ -325,6 +325,60 @@ struct Profile {
);
}
#[test]
fn form_struct_requires_field_parser() {
// req: form/004 req: form/006 req: test/003
let fixture = Fixture::new("slhx-derive-form-struct-parser-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-form-struct-parser-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\nform\ttemplates/app.heml::profile\tprofile\t1\nform_field\tprofile\temail\ttrue\tfalse\n",
)
.unwrap();
}
"#,
);
fixture.write(
"src/lib.rs",
r#"struct Email;
#[slhx::form("profile")]
struct Profile {
email: Email,
}
"#,
);
let output = check_fixture(&fixture);
assert!(!output.status.success(), "fixture unexpectedly compiled");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Email: FormValue") || stderr.contains("Email: slhx::FormValue"),
"missing parser availability diagnostic in stderr:\n{stderr}"
);
}
#[test]
fn form_handle_accepts_checked_form_model() {
// req: form/001 req: form/004 req: form/006
+1 -1
View File
@@ -9,7 +9,7 @@ pub use slhx_derive::{app, component, form, handler, surface};
pub mod prelude {
pub use slhx_core::{
navigate, push, redirect, replace, Atom, BuildFingerprint, CssClass, CssClasses, Effect,
Form, Handle, IntoEffect, KeyedSlot, Slot,
Form, FormModel, FormValue, Handle, IntoEffect, KeyedSlot, Slot,
};
pub use slhx_derive::{app, component, form, handler, surface};
}