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
|
## form
|
||||||
|
|
||||||
### req: form/001
|
### 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
|
### 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.
|
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 {
|
for (handle_ident, form_ident) in &self.handle_forms {
|
||||||
out.push_str(&format!("handle_form\t{handle_ident}\t{form_ident}\n"));
|
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 (handle_ident, params) in &self.handle_params {
|
||||||
for param in params {
|
for param in params {
|
||||||
out.push_str(&format!("handle_param\t{handle_ident}\t{param}\n"));
|
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
|
hash
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn form_control_is_multiple(kind: &ControlKind) -> bool {
|
||||||
|
matches!(kind, ControlKind::Select { multiple: true })
|
||||||
|
}
|
||||||
|
|
||||||
fn form_control_kind_expr(kind: &ControlKind) -> String {
|
fn form_control_kind_expr(kind: &ControlKind) -> String {
|
||||||
match kind {
|
match kind {
|
||||||
ControlKind::Text => "::slhx::FormControlKind::Text".to_string(),
|
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();
|
let syms = std::fs::read_to_string(out.join("slhx.syms")).unwrap();
|
||||||
assert!(syms.contains("handle_form\tsave\tprofile\n"));
|
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);
|
let _ = std::fs::remove_dir_all(&base);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -849,6 +849,8 @@ impl<I> Handle<I> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub trait FormModel {}
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq, Hash)]
|
#[derive(Debug, Eq, PartialEq, Hash)]
|
||||||
pub struct Form<T> {
|
pub struct Form<T> {
|
||||||
id: ResourceId,
|
id: ResourceId,
|
||||||
|
|||||||
+182
-13
@@ -2,13 +2,13 @@ use proc_macro::TokenStream;
|
|||||||
use quote::quote;
|
use quote::quote;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use syn::{
|
use syn::{
|
||||||
parse_macro_input, FnArg, GenericArgument, Item, ItemFn, ItemMod, Pat, PathArguments,
|
parse_macro_input, parse_quote, Fields, FnArg, GenericArgument, Item, ItemFn, ItemMod,
|
||||||
ReturnType, Type,
|
ItemStruct, LitStr, Pat, PathArguments, ReturnType, Type,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[proc_macro_attribute]
|
#[proc_macro_attribute]
|
||||||
pub fn handler(_attr: TokenStream, item: TokenStream) -> TokenStream {
|
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 name = function.sig.ident.to_string();
|
||||||
|
|
||||||
let Some(syms_path) = syms_path() else {
|
let Some(syms_path) = syms_path() else {
|
||||||
@@ -60,6 +60,9 @@ pub fn handler(_attr: TokenStream, item: TokenStream) -> TokenStream {
|
|||||||
)
|
)
|
||||||
.into();
|
.into();
|
||||||
}
|
}
|
||||||
|
if handle_requires_form(&syms_path, &name) {
|
||||||
|
add_form_model_bounds(&mut function);
|
||||||
|
}
|
||||||
let missing_params = missing_handle_params(&syms_path, &name, &function);
|
let missing_params = missing_handle_params(&syms_path, &name, &function);
|
||||||
if !missing_params.is_empty() {
|
if !missing_params.is_empty() {
|
||||||
let message = format!(
|
let message = format!(
|
||||||
@@ -81,6 +84,29 @@ pub fn surface(_attr: TokenStream, item: TokenStream) -> TokenStream {
|
|||||||
inject_surface_include(item)
|
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]
|
#[proc_macro_attribute]
|
||||||
pub fn component(_attr: TokenStream, item: TokenStream) -> TokenStream {
|
pub fn component(_attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||||
let module = parse_macro_input!(item as ItemMod);
|
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 {
|
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 {
|
let Type::Path(path) = ty else {
|
||||||
return false;
|
return None;
|
||||||
};
|
};
|
||||||
let mut segments = path.path.segments.iter();
|
let mut segments = path.path.segments.iter();
|
||||||
let Some(first) = segments.next() else {
|
let first = segments.next()?;
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let last = path.path.segments.last().expect("path has at least one segment");
|
let last = path.path.segments.last().expect("path has at least one segment");
|
||||||
let path_is_form = if path.path.segments.len() == 1 {
|
let path_is_form = if path.path.segments.len() == 1 {
|
||||||
first.ident == "Form"
|
first.ident == "Form"
|
||||||
@@ -182,13 +210,34 @@ fn is_form_type(ty: &Type) -> bool {
|
|||||||
first.ident == "slhx" && last.ident == "Form"
|
first.ident == "slhx" && last.ident == "Form"
|
||||||
};
|
};
|
||||||
if !path_is_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 {
|
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> {
|
fn missing_handle_params(path: &PathBuf, ident: &str, function: &ItemFn) -> Vec<String> {
|
||||||
let required = handle_params(path, ident);
|
let required = handle_params(path, ident);
|
||||||
if required.is_empty() {
|
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]
|
#[test]
|
||||||
fn form_handle_requires_form_parameter() {
|
fn form_handle_requires_form_parameter() {
|
||||||
// req: form/004 req: form/006 req: test/003
|
// req: form/004 req: form/006 req: test/003
|
||||||
|
|||||||
+2
-2
@@ -4,12 +4,12 @@
|
|||||||
//! here, and import generated resources through `#[slhx::surface]`.
|
//! here, and import generated resources through `#[slhx::surface]`.
|
||||||
|
|
||||||
pub use slhx_core::*;
|
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 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, 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