feat(derive): check form model structure

Add #[slhx::form] for user-authored form models, emit form field metadata into slhx.syms, and require form handlers to accept models that passed generated field compatibility checks.

req: form/001

req: form/004

req: form/006

req: codegen/004
This commit is contained in:
slhx agent
2026-05-25 23:44:43 +02:00
parent 34d6757ccd
commit 274b8e9e55
6 changed files with 423 additions and 16 deletions
+1 -1
View File
@@ -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<T>` 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<T>` 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.
+17
View File
@@ -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);
}
+2
View File
@@ -849,6 +849,8 @@ impl<I> Handle<I> {
}
}
pub trait FormModel {}
#[derive(Debug, Eq, PartialEq, Hash)]
pub struct Form<T> {
id: ResourceId,
+182 -13
View File
@@ -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<Type> {
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<Type> {
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<String> {
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::<Vec<_>>();
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<GeneratedFormField> {
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<String> {
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<String> {
let required = handle_params(path, ident);
if required.is_empty() {
+219
View File
@@ -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<String>,
labels: Option<String>,
}
"#,
);
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<CreateTodo>) -> 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<CreateTodo>) -> 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
+2 -2
View File
@@ -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};
}