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:
+1
-1
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user