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:
+1
-1
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -849,6 +849,8 @@ impl<I> Handle<I> {
|
||||
}
|
||||
}
|
||||
|
||||
pub trait FormModel {}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Hash)]
|
||||
pub struct Form<T> {
|
||||
id: ResourceId,
|
||||
|
||||
+182
-13
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user