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::<T>() noise.

req: form/001

req: form/004

req: form/006

req: codegen/004
This commit is contained in:
slhx agent
2026-05-26 00:03:47 +02:00
parent 1b255e593d
commit 0b6ad64d98
5 changed files with 38 additions and 13 deletions
+1 -1
View File
@@ -64,7 +64,7 @@ mod tests {
ui::board::slots::notice.text("queued")
}
let effect = inspect(create_card(ui::board::forms::create_card.typed::<CreateCard>()));
let effect = inspect(create_card(CreateCard::FORM));
assert!(effect.has_slot(ui::board::slots::notice));
assert!(matches!(effect.ops(), [Effect::Put { payload: Payload::Text(_), .. }]));
+1 -3
View File
@@ -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::<LaunchWork>(),
));
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(_), .. }]));
+3 -3
View File
@@ -66,7 +66,7 @@ mod tests {
ui::slots::todo_list.text("queued")
}
let effect = inspect(add_todo(ui::forms::new_todo.typed::<TodoInput>()));
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::<WizardInput>()));
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::<Credentials>()));
let effect = inspect(login(Credentials::FORM));
assert!(effect.has_slot(ui::slots::login_status));
assert!(matches!(effect.ops(), [Effect::Put { payload: Payload::Text(_), .. }]));
+29 -6
View File
@@ -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<Self> = ::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<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()];
};
fn form_contract_errors(syms_path: &PathBuf, form_name: &str, form_struct: &ItemStruct) -> Vec<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);
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<u32> {
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<GeneratedFormField> {
let Ok(syms) = std::fs::read_to_string(path) else {
return Vec::new();
+4
View File
@@ -423,6 +423,10 @@ struct CreateTodo {
fn create(_form: slhx::Form<CreateTodo>) -> impl slhx::IntoEffect {
slhx::event("created", "")
}
fn smoke() {
let _ = create(CreateTodo::FORM);
}
"#,
);