formars_derive/lib.rs
1//! `formars-derive`: opt-in `#[derive(FormSchema)]` macro generating
2//! `formars-core` companion schemas.
3//!
4//! Snapshot regen protocol: trybuild `.stderr` snapshots regenerate ONLY on
5//! the pinned gate toolchain 1.98.0 (see `.github/workflows/ci.yml`) via
6//! `TRYBUILD=overwrite cargo test -p formars-derive --test ui`, diffs
7//! hand-reviewed; never per-toolchain.
8//!
9//! `formars-core` is builder-first and macro-free; this crate is the optional
10//! ergonomic layer that removes the hand-composition step. Deriving
11//! `FormSchema` on a plain named-field struct emits a companion schema type
12//! whose single composed representation is a real `::formars_core::ObjectSchema`
13//! assembled through the public builders — the walk kernel, joined paths,
14//! fail-fast descent and dual-view parity are all inherited from core, never
15//! reimplemented.
16//!
17//! # Example
18//!
19//! Typed parse, erased validation and nesting, all from one derive:
20//!
21//! ```
22//! use formars_core::prelude::*;
23//! use formars_core::value::{Object, Value};
24//! use formars_derive::FormSchema;
25//!
26//! #[derive(FormSchema)]
27//! struct Signup {
28//! #[form(label = "Full name")]
29//! name: String,
30//! age: u32, // HTML-form strings coerce into numeric fields
31//! address: Address,
32//! }
33//!
34//! #[derive(FormSchema)]
35//! struct Address {
36//! city: String,
37//! }
38//!
39//! // ONE composed representation serves both views (SC-9).
40//! let schema = <Signup as FormSchema>::form_schema();
41//!
42//! // Typed parse: struct in, validated struct out.
43//! let parsed = schema.parse(&Signup {
44//! name: "Ada".into(),
45//! age: 36,
46//! address: Address { city: "London".into() },
47//! });
48//! assert!(parsed.is_ok());
49//!
50//! // Erased view over a raw Value tree (what formars-signals/formars-ui consume).
51//! let mut addr = Object::new();
52//! addr.insert("city", Value::from("London"));
53//! let mut input = Object::new();
54//! input.insert("name", Value::from("Ada"));
55//! input.insert("age", Value::from("abc")); // not coercible into u32
56//! input.insert("address", Value::Object(addr));
57//!
58//! let issues = schema.validate_value(&Value::Object(input));
59//! assert_eq!(issues.len(), 1);
60//! assert_eq!(issues[0].code, IssueCode::Coerce);
61//! assert_eq!(issues[0].path.to_string(), "age");
62//! ```
63//!
64//! # Attribute reference (v0 closed grammar)
65//!
66//! Exactly six keys are recognized inside `#[form(..)]`; anything else is a
67//! hard compile error listing the valid keys.
68//!
69//! | Attribute | Example | Effect |
70//! |-----------|---------|--------|
71//! | `schema = <expr>` | `#[form(schema = string().trim().email())]` | Full override: `expr` replaces the default mapping at this field's position |
72//! | `rename = "wire"` | `#[form(rename = "user_email")]` | Remaps the object key everywhere (lookup, reconstruction, `shape()`, `field_meta`); the Rust identifier is untouched |
73//! | `skip` | `#[form(skip)]` | Field excluded from schema and bridging entirely; passed through untouched on typed parse |
74//! | `label = ".."` | `#[form(label = "Email")]` | Populates `FieldMeta::label`, reachable via `metadata()` / `field_meta` |
75//! | `description = ".."` | `#[form(description = "..")]` | Populates `FieldMeta::description` |
76//! | `placeholder = ".."` | `#[form(placeholder = "..")]` | Populates `FieldMeta::placeholder` |
77//!
78//! Rules: one attribute list per field; duplicate keys error; `skip` cannot be
79//! combined with any other key; container-level attributes do not exist in v0
80//! and are rejected as unrecognized.
81//!
82//! # Supported field types (default mapping)
83//!
84//! | Type(s) | Mapping |
85//! |---------|---------|
86//! | `String` | `string()` |
87//! | `bool` | `bool()` |
88//! | `i8`, `i16`, `i32`, `i64`, `u8`, `u16`, `u32`, `f32`, `f64` | `coerced::<T>()` (HTML forms deliver strings) |
89//! | Any type implementing `FormSchema` | Nested composition via `Nested::new(<T as FormSchema>::form_schema())` |
90//! | Anything else (aliases included) | Compile error naming the field, suggesting `#[form(schema = ..)]` |
91//!
92//! Mapping is strictly NAME-based: only a single-segment path whose final
93//! segment is exactly an identifier from the table above resolves to its
94//! builder. Type aliases (`type Age = u32;`) are NOT resolved by trait
95//! bounds: they fall through to nested composition and compile only if the
96//! aliased type independently implements `FormSchema` + `FormBridge`.
97//! Fully-qualified primitive paths (`::std::string::String`,
98//! `::core::primitive::u32`) are not mapped by name either; the derive
99//! rejects them with a targeted compile error suggesting the bare name or
100//! `#[form(schema = ..)]`.
101//!
102//! # Generated contract
103//!
104//! For `struct X { .. }` the derive emits, in the same module:
105//!
106//! - `struct XSchema` taking the STRUCT'S visibility (a private struct yields
107//! a private companion; `pub` yields `pub`) owning its composed
108//! `::formars_core::ObjectSchema`, with an `XSchema::new()` constructor;
109//! - `impl Schema for XSchema` (`Input = Output = X`) — bridges `X` to an
110//! object via `FormBridge`, validates, reconstructs;
111//! - `impl DynSchema for XSchema` — pure pass-through to the composed object;
112//! - `impl AsRef<::formars_core::ObjectSchema> for XSchema`;
113//! - `impl FormSchema for X` and `impl FormBridge for X`.
114//!
115//! # Companion naming (reserved suffix)
116//!
117//! `struct Foo` generates its companion `FooSchema` **in the same module**.
118//! If a pre-existing item named `FooSchema` is already in scope there, the
119//! generated definition collides with it and rustc reports E0428 ("the name
120//! `FooSchema` is defined multiple times") pointing INTO generated code,
121//! with no hint about the derive as the cause. Convention: never name items
122//! `{Struct}Schema` — that suffix is reserved for generated companions.
123//!
124//! Nested fields carry a **dual bound**: their type must implement BOTH
125//! `FormSchema` (composition) AND `FormBridge` (typed-parse bridging). A
126//! hand-written `FormSchema` impl without `FormBridge` fails compilation.
127//!
128//! Skipped fields must implement `Clone`: typed parse copies them verbatim
129//! from the input. Because such a struct declines erased reconstruction in
130//! v0, it must NOT be used as a nested child — the parent compiles but typed
131//! parse fails at runtime with `TypeMismatch` ("validated output is missing
132//! this field").
133//!
134//! Deviation from the spec's soft zero-sized-companion preference: `XSchema`
135//! stores its composed `ObjectSchema` instead of being a ZST. This buys the
136//! natural `AsRef` used by nested composition and keeps `shape()`
137//! memoization; construction cost equals hand-building once.
138//!
139//! # Package-name coupling
140//!
141//! All generated code references core items via absolute `::formars_core::..`
142//! paths (proc-macros have no `$crate` escape hatch). Renaming or shadowing
143//! the `formars-core` package is therefore a breaking change for derive users.
144
145use proc_macro::TokenStream;
146use syn::parse_macro_input;
147
148mod attrs;
149mod expansion;
150
151/// Derives a `formars` form schema companion for the annotated struct.
152///
153/// See the crate-level docs for the supported shapes and attribute grammar.
154#[proc_macro_derive(FormSchema, attributes(form))]
155pub fn derive_form_schema(input: TokenStream) -> TokenStream {
156 let input = parse_macro_input!(input as syn::DeriveInput);
157 match expansion::expand(&input) {
158 Ok(tokens) => tokens.into(),
159 Err(e) => e.to_compile_error().into(),
160 }
161}