diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 8009df0..2c802e3 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -322,7 +322,7 @@ resources. Concrete runtime targets are addressed through `ResourceRef` ## form ### req: form/001 -001 Forms are source of truth in HTML. hemplate Surface exports form shape (controls, names, required, types). slhx checks compatibility with the Rust handler's `Form` type. No auto-generated structs; domain types (e.g. `Email`) are first-class. The Surface describes; Rust owns; slhx checks. +001 Forms are source of truth in HTML. hemplate Surface exports form shape (controls, names, required, types). slhx checks compatibility with Rust `Form` types through user-authored `#[slhx::form("...")]` domain structs and generated form metadata. No auto-generated structs; domain types (e.g. `Email`) are first-class. The Surface describes; Rust owns; slhx checks. ### req: form/002 002 The handle id is carried as `__h` in POST `application/x-www-form-urlencoded`. A JSON body is allowed at the integration boundary (`application/json`) only if the handler accepts it; core uses form encoding. diff --git a/slhx-build/src/lib.rs b/slhx-build/src/lib.rs index f785ae6..79676e5 100644 --- a/slhx-build/src/lib.rs +++ b/slhx-build/src/lib.rs @@ -591,6 +591,17 @@ fn __slhx_attr(tag: &str, attr: &str) -> Option<::std::string::String> { for (handle_ident, form_ident) in &self.handle_forms { out.push_str(&format!("handle_form\t{handle_ident}\t{form_ident}\n")); } + for form in self.forms.values() { + for control in &form.controls { + out.push_str(&format!( + "form_field\t{}\t{}\t{}\t{}\n", + form.resource.ident, + control.name, + control.required, + form_control_is_multiple(&control.kind) + )); + } + } for (handle_ident, params) in &self.handle_params { for param in params { out.push_str(&format!("handle_param\t{handle_ident}\t{param}\n")); @@ -939,6 +950,10 @@ fn stable_id(kind: &str, symbol: &str) -> u32 { hash } +fn form_control_is_multiple(kind: &ControlKind) -> bool { + matches!(kind, ControlKind::Select { multiple: true }) +} + fn form_control_kind_expr(kind: &ControlKind) -> String { match kind { ControlKind::Text => "::slhx::FormControlKind::Text".to_string(), @@ -1075,6 +1090,8 @@ mod tests { let syms = std::fs::read_to_string(out.join("slhx.syms")).unwrap(); assert!(syms.contains("handle_form\tsave\tprofile\n")); + assert!(syms.contains("form_field\tprofile\ttitle\ttrue\tfalse\n")); + assert!(syms.contains("form_field\tprofile\tlabels\tfalse\ttrue\n")); let _ = std::fs::remove_dir_all(&base); } diff --git a/slhx-core/src/lib.rs b/slhx-core/src/lib.rs index 860c037..bfbb7b7 100644 --- a/slhx-core/src/lib.rs +++ b/slhx-core/src/lib.rs @@ -849,6 +849,8 @@ impl Handle { } } +pub trait FormModel {} + #[derive(Debug, Eq, PartialEq, Hash)] pub struct Form { id: ResourceId, diff --git a/slhx-derive/src/lib.rs b/slhx-derive/src/lib.rs index 6bb5161..381ed14 100644 --- a/slhx-derive/src/lib.rs +++ b/slhx-derive/src/lib.rs @@ -2,13 +2,13 @@ use proc_macro::TokenStream; use quote::quote; use std::path::PathBuf; use syn::{ - parse_macro_input, FnArg, GenericArgument, Item, ItemFn, ItemMod, Pat, PathArguments, - ReturnType, Type, + 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 function = parse_macro_input!(item as ItemFn); + let mut function = parse_macro_input!(item as ItemFn); let name = function.sig.ident.to_string(); let Some(syms_path) = syms_path() else { @@ -60,6 +60,9 @@ pub fn handler(_attr: TokenStream, item: TokenStream) -> TokenStream { ) .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!( @@ -81,6 +84,29 @@ 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 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(); + quote!( + #form_struct + impl #impl_generics ::slhx::FormModel for #ident #ty_generics #where_clause {} + ) + .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 module = parse_macro_input!(item as ItemMod); @@ -168,13 +194,15 @@ fn has_form_param(function: &ItemFn) -> bool { } fn is_form_type(ty: &Type) -> bool { + form_model_type(ty).is_some() +} + +fn form_model_type(ty: &Type) -> Option { let Type::Path(path) = ty else { - return false; + return None; }; let mut segments = path.path.segments.iter(); - let Some(first) = segments.next() else { - return false; - }; + 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" @@ -182,13 +210,34 @@ fn is_form_type(ty: &Type) -> bool { first.ident == "slhx" && last.ident == "Form" }; if !path_is_form { - return false; + 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 { + 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)); } - matches!( - &last.arguments, - PathArguments::AngleBracketed(args) - if args.args.iter().any(|arg| matches!(arg, GenericArgument::Type(_))) - ) } fn has_non_unit_return(function: &ItemFn) -> bool { @@ -220,6 +269,126 @@ fn handle_requires_form(path: &PathBuf, ident: &str) -> bool { }) } +#[derive(Debug, Eq, PartialEq)] +struct GeneratedFormField { + name: String, + ident: String, + required: bool, + multiple: bool, +} + +fn form_contract_errors(form_name: &str, form_struct: &ItemStruct) -> Vec { + let Some(syms_path) = syms_path() else { + return vec!["#[slhx::form] requires OUT_DIR; run inside a Cargo crate with slhx_build::app() in build.rs".to_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::>(); + 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_fields(path: &PathBuf, form_name: &str) -> Vec { + 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 { + 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 { let required = handle_params(path, ident); if required.is_empty() { diff --git a/slhx-derive/tests/compile_fail.rs b/slhx-derive/tests/compile_fail.rs index ba73a42..bfc7262 100644 --- a/slhx-derive/tests/compile_fail.rs +++ b/slhx-derive/tests/compile_fail.rs @@ -218,6 +218,225 @@ fn show(todo_id: String) -> impl slhx::IntoEffect { ); } +#[test] +fn form_struct_rejects_missing_generated_field() { + // req: form/001 req: form/004 req: form/006 req: test/003 + let fixture = Fixture::new("slhx-derive-form-struct-missing-field-fail"); + fixture.write( + "Cargo.toml", + &format!( + r#"[package] +name = "slhx-derive-form-struct-missing-field-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::new_todo\tnew_todo\t1\nform_field\tnew_todo\ttitle\ttrue\tfalse\n", + ) + .unwrap(); +} +"#, + ); + fixture.write( + "src/lib.rs", + r#"#[slhx::form("new_todo")] +struct CreateTodo {} +"#, + ); + + let output = check_fixture(&fixture); + + assert!(!output.status.success(), "fixture unexpectedly compiled"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("slhx form `new_todo` is missing field `title` for form control `title`"), + "missing form field diagnostic in stderr:\n{stderr}" + ); +} + +#[test] +fn form_struct_rejects_wrong_optionality_and_multiplicity() { + // req: form/004 req: form/006 req: test/003 + let fixture = Fixture::new("slhx-derive-form-struct-shape-fail"); + fixture.write( + "Cargo.toml", + &format!( + r#"[package] +name = "slhx-derive-form-struct-shape-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\ttitle\ttrue\tfalse\nform_field\tprofile\tlabels\tfalse\ttrue\n", + ) + .unwrap(); +} +"#, + ); + fixture.write( + "src/lib.rs", + r#"#[slhx::form("profile")] +struct Profile { + title: Option, + labels: Option, +} +"#, + ); + + let output = check_fixture(&fixture); + + assert!(!output.status.success(), "fixture unexpectedly compiled"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("field `title` is required in HTML and must not be Option<_>"), + "missing required-field diagnostic in stderr:\n{stderr}" + ); + assert!( + stderr.contains("field `labels` accepts multiple values and must be Vec<_>"), + "missing multiplicity diagnostic in stderr:\n{stderr}" + ); +} + +#[test] +fn form_handle_accepts_checked_form_model() { + // req: form/001 req: form/004 req: form/006 + let fixture = Fixture::new("slhx-derive-form-handler-checked-model-pass"); + fixture.write( + "Cargo.toml", + &format!( + r#"[package] +name = "slhx-derive-form-handler-checked-model-pass" +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\nhandle\ttemplates/app.heml::create\tcreate\t1\nhandle_form\tcreate\tnew_todo\nform\ttemplates/app.heml::new_todo\tnew_todo\t1\nform_field\tnew_todo\ttitle\ttrue\tfalse\n", + ) + .unwrap(); +} +"#, + ); + fixture.write( + "src/lib.rs", + r#"#[slhx::form("new_todo")] +struct CreateTodo { + title: String, +} + +#[slhx::handler] +fn create(_form: slhx::Form) -> impl slhx::IntoEffect { + slhx::event("created", "") +} +"#, + ); + + let output = check_fixture(&fixture); + + assert!( + output.status.success(), + "fixture failed to compile:\n{}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn form_handle_requires_checked_form_model() { + // req: form/001 req: form/004 req: form/006 req: test/003 + let fixture = Fixture::new("slhx-derive-form-handler-unchecked-model-fail"); + fixture.write( + "Cargo.toml", + &format!( + r#"[package] +name = "slhx-derive-form-handler-unchecked-model-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\nhandle\ttemplates/app.heml::create\tcreate\t1\nhandle_form\tcreate\tnew_todo\n", + ) + .unwrap(); +} +"#, + ); + fixture.write( + "src/lib.rs", + r#"struct CreateTodo { + title: String, +} + +#[slhx::handler] +fn create(_form: slhx::Form) -> impl slhx::IntoEffect { + slhx::event("created", "") +} +"#, + ); + + let output = check_fixture(&fixture); + + assert!(!output.status.success(), "fixture unexpectedly compiled"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("CreateTodo: FormModel") || stderr.contains("CreateTodo: slhx::FormModel"), + "missing checked form model diagnostic in stderr:\n{stderr}" + ); +} + #[test] fn form_handle_requires_form_parameter() { // req: form/004 req: form/006 req: test/003 diff --git a/slhx/src/lib.rs b/slhx/src/lib.rs index 1fa897b..1fc9fb1 100644 --- a/slhx/src/lib.rs +++ b/slhx/src/lib.rs @@ -4,12 +4,12 @@ //! here, and import generated resources through `#[slhx::surface]`. pub use slhx_core::*; -pub use slhx_derive::{app, component, handler, surface}; +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, }; - pub use slhx_derive::{app, component, handler, surface}; + pub use slhx_derive::{app, component, form, handler, surface}; }