From 0b6ad64d9819bc230e183a045e8e4f5f5c2562da Mon Sep 17 00:00:00 2001 From: slhx agent Date: Tue, 26 May 2026 00:03:47 +0200 Subject: [PATCH] feat(derive): expose checked form resource constants Have #[slhx::form] emit an associated FORM resource so checked form handlers can be called with TodoInput::FORM-style values instead of generated_resource.typed::() noise. req: form/001 req: form/004 req: form/006 req: codegen/004 --- examples/kanban/src/lib.rs | 2 +- examples/techdemo/src/lib.rs | 4 +--- examples/v0/src/lib.rs | 6 +++--- slhx-derive/src/lib.rs | 35 +++++++++++++++++++++++++------ slhx-derive/tests/compile_fail.rs | 4 ++++ 5 files changed, 38 insertions(+), 13 deletions(-) diff --git a/examples/kanban/src/lib.rs b/examples/kanban/src/lib.rs index 8a9923e..75e4570 100644 --- a/examples/kanban/src/lib.rs +++ b/examples/kanban/src/lib.rs @@ -64,7 +64,7 @@ mod tests { ui::board::slots::notice.text("queued") } - let effect = inspect(create_card(ui::board::forms::create_card.typed::())); + let effect = inspect(create_card(CreateCard::FORM)); assert!(effect.has_slot(ui::board::slots::notice)); assert!(matches!(effect.ops(), [Effect::Put { payload: Payload::Text(_), .. }])); diff --git a/examples/techdemo/src/lib.rs b/examples/techdemo/src/lib.rs index ff11ac7..f9dd153 100644 --- a/examples/techdemo/src/lib.rs +++ b/examples/techdemo/src/lib.rs @@ -55,9 +55,7 @@ mod tests { ui::control_center::slots::notice.text("queued") } - let batch = inspect(launch_work( - ui::control_center::forms::launch_work.typed::(), - )); + let batch = inspect(launch_work(LaunchWork::FORM)); assert!(batch.has_slot(ui::control_center::slots::notice)); assert!(matches!(batch.ops(), [Effect::Put { payload: Payload::Text(_), .. }])); diff --git a/examples/v0/src/lib.rs b/examples/v0/src/lib.rs index e172083..96471f2 100644 --- a/examples/v0/src/lib.rs +++ b/examples/v0/src/lib.rs @@ -66,7 +66,7 @@ mod tests { ui::slots::todo_list.text("queued") } - let effect = inspect(add_todo(ui::forms::new_todo.typed::())); + let effect = inspect(add_todo(TodoInput::FORM)); assert!(effect.has_slot(ui::slots::todo_list)); assert!(matches!(effect.ops(), [Effect::Put { payload: Payload::Text(_), .. }])); @@ -103,7 +103,7 @@ mod tests { ui::slots::wizard_step.text("queued") } - let effect = inspect(next_step(ui::forms::wizard_input.typed::())); + let effect = inspect(next_step(WizardInput::FORM)); assert!(effect.has_slot(ui::slots::wizard_step)); assert!(matches!(effect.ops(), [Effect::Put { payload: Payload::Text(_), .. }])); @@ -158,7 +158,7 @@ mod tests { ui::slots::login_status.text("queued") } - let effect = inspect(login(ui::forms::credentials.typed::())); + let effect = inspect(login(Credentials::FORM)); assert!(effect.has_slot(ui::slots::login_status)); assert!(matches!(effect.ops(), [Effect::Put { payload: Payload::Text(_), .. }])); diff --git a/slhx-derive/src/lib.rs b/slhx-derive/src/lib.rs index 478fbe4..30dc50d 100644 --- a/slhx-derive/src/lib.rs +++ b/slhx-derive/src/lib.rs @@ -88,9 +88,18 @@ pub fn surface(_attr: TokenStream, item: TokenStream) -> TokenStream { 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); + let Some(syms_path) = syms_path() else { + let message = "#[slhx::form] requires OUT_DIR; run inside a Cargo crate with slhx_build::app() in build.rs"; + return quote!( + #form_struct + compile_error!(#message); + ) + .into(); + }; + let errors = form_contract_errors(&syms_path, &form_name, &form_struct); if errors.is_empty() { let ident = &form_struct.ident; + let resource_id = form_resource_id(&syms_path, &form_name).expect("checked form exists in slhx.syms"); 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)); @@ -99,6 +108,9 @@ pub fn form(attr: TokenStream, item: TokenStream) -> TokenStream { quote!( #form_struct impl #impl_generics ::slhx::FormModel for #ident #ty_generics #where_clause {} + impl #impl_generics #ident #ty_generics #where_clause { + pub const FORM: ::slhx::Form = ::slhx::Form::new(#resource_id); + } ) .into() } else { @@ -281,17 +293,14 @@ struct GeneratedFormField { 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()]; - }; +fn form_contract_errors(syms_path: &PathBuf, form_name: &str, form_struct: &ItemStruct) -> Vec { 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); + 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" @@ -380,6 +389,20 @@ fn generic_inner_type<'a>(ty: &'a Type, name: &str) -> Option<&'a Type> { }) } +fn form_resource_id(path: &PathBuf, form_name: &str) -> Option { + let syms = std::fs::read_to_string(path).ok()?; + syms.lines().find_map(|line| { + let mut fields = line.split('\t'); + if !matches!(fields.next(), Some("form")) { + return None; + } + if fields.nth(1)? != form_name { + return None; + } + fields.next()?.parse().ok() + }) +} + fn form_fields(path: &PathBuf, form_name: &str) -> Vec { let Ok(syms) = std::fs::read_to_string(path) else { return Vec::new(); diff --git a/slhx-derive/tests/compile_fail.rs b/slhx-derive/tests/compile_fail.rs index 96e43e9..670f53f 100644 --- a/slhx-derive/tests/compile_fail.rs +++ b/slhx-derive/tests/compile_fail.rs @@ -423,6 +423,10 @@ struct CreateTodo { fn create(_form: slhx::Form) -> impl slhx::IntoEffect { slhx::event("created", "") } + +fn smoke() { + let _ = create(CreateTodo::FORM); +} "#, );