diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 2c802e3..adfd9f4 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -331,7 +331,7 @@ resources. Concrete runtime targets are addressed through `ResourceRef` 003 Handler receives `form: Form`. 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. diff --git a/slhx-core/src/lib.rs b/slhx-core/src/lib.rs index bfbb7b7..7fac023 100644 --- a/slhx-core/src/lib.rs +++ b/slhx-core/src/lib.rs @@ -849,6 +849,10 @@ impl Handle { } } +pub trait FormValue {} + +impl FormValue for T where T: std::str::FromStr {} + pub trait FormModel {} #[derive(Debug, Eq, PartialEq, Hash)] diff --git a/slhx-derive/src/lib.rs b/slhx-derive/src/lib.rs index 381ed14..478fbe4 100644 --- a/slhx-derive/src/lib.rs +++ b/slhx-derive/src/lib.rs @@ -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 Vec { + 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 { 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); + let multiple: Type = parse_quote!(Vec); + + 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 diff --git a/slhx-derive/tests/compile_fail.rs b/slhx-derive/tests/compile_fail.rs index bfc7262..96e43e9 100644 --- a/slhx-derive/tests/compile_fail.rs +++ b/slhx-derive/tests/compile_fail.rs @@ -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 diff --git a/slhx/src/lib.rs b/slhx/src/lib.rs index 1fc9fb1..7a8738f 100644 --- a/slhx/src/lib.rs +++ b/slhx/src/lib.rs @@ -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}; }