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.
|
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
|
### 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
|
### req: form/005
|
||||||
005 HTML control facts are lower bounds, not complete domain semantics.
|
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 {}
|
pub trait FormModel {}
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq, Hash)]
|
#[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);
|
let errors = form_contract_errors(&form_name, &form_struct);
|
||||||
if errors.is_empty() {
|
if errors.is_empty() {
|
||||||
let ident = &form_struct.ident;
|
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!(
|
quote!(
|
||||||
#form_struct
|
#form_struct
|
||||||
impl #impl_generics ::slhx::FormModel for #ident #ty_generics #where_clause {}
|
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
|
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> {
|
fn form_fields(path: &PathBuf, form_name: &str) -> Vec<GeneratedFormField> {
|
||||||
let Ok(syms) = std::fs::read_to_string(path) else {
|
let Ok(syms) = std::fs::read_to_string(path) else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
@@ -484,7 +522,8 @@ fn compile_error(message: &str) -> TokenStream {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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};
|
use syn::{parse_quote, ItemFn, Type};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -519,6 +558,22 @@ mod tests {
|
|||||||
assert!(!has_non_unit_return(&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]
|
#[test]
|
||||||
fn form_param_matches_form_type_not_name_suffix() {
|
fn form_param_matches_form_type_not_name_suffix() {
|
||||||
// req: form/004 req: form/006
|
// 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]
|
#[test]
|
||||||
fn form_handle_accepts_checked_form_model() {
|
fn form_handle_accepts_checked_form_model() {
|
||||||
// req: form/001 req: form/004 req: form/006
|
// 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 mod prelude {
|
||||||
pub use slhx_core::{
|
pub use slhx_core::{
|
||||||
navigate, push, redirect, replace, Atom, BuildFingerprint, CssClass, CssClasses, Effect,
|
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};
|
pub use slhx_derive::{app, component, form, handler, surface};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user