Skip to main content

zeyn_macros/
lib.rs

1//! Procedural macros for Zeyn Framework.
2//!
3//! Generated code always refers to `::zeyn`. Use these macros through the `zeyn` crate, not directly.
4
5use proc_macro::TokenStream;
6use proc_macro2::TokenStream as Ts;
7use quote::{format_ident, quote, quote_spanned};
8use syn::spanned::Spanned;
9use syn::{
10    Fields, FnArg, Ident, Item, ItemEnum, ItemFn, ItemStruct, LitInt, LitStr, Path, ReturnType,
11    Type,
12};
13
14/// Holds and validates access options: `permission = "..."` or `public`.
15#[derive(Default)]
16struct AccessArgs {
17    permission: Option<LitStr>,
18    public: bool,
19}
20
21impl AccessArgs {
22    /// Turns the options into a `::zeyn::Access` expression. Missing access is a compile error.
23    fn tokens(&self, kind: &str, name: &Ident) -> syn::Result<Ts> {
24        match (&self.permission, self.public) {
25            (Some(p), false) => {
26                check_permission(p)?;
27                Ok(quote!(::zeyn::Access::Permission(#p)))
28            }
29            (None, true) => Ok(quote!(::zeyn::Access::Public)),
30            (Some(p), true) => Err(syn::Error::new(
31                p.span(),
32                format!("{kind} `{name}` cannot be both `public` and have a permission"),
33            )),
34            (None, false) => Err(syn::Error::new(
35                name.span(),
36                format!(
37                    "{kind} `{name}` has no permission\n  = help: add `permission = \"...\"`, or `public` if this {kind} is meant to be open to everyone"
38                ),
39            )),
40        }
41    }
42}
43
44/// Checks that a permission is a lowercase dotted name such as `stock.product.read`.
45fn check_permission(lit: &LitStr) -> syn::Result<()> {
46    let value = lit.value();
47    let parts: Vec<&str> = value.split('.').collect();
48    let valid = parts.len() >= 2
49        && parts.iter().all(|p| {
50            !p.is_empty()
51                && p.chars()
52                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
53        });
54    if valid {
55        Ok(())
56    } else {
57        Err(syn::Error::new(
58            lit.span(),
59            format!(
60                "invalid permission `{value}`\n  = help: use lowercase dotted names such as \"stock.product.read\""
61            ),
62        ))
63    }
64}
65
66/// Returns the type of the first argument (input or params) and the return type.
67fn fn_types(f: &ItemFn, kind: &str) -> syn::Result<(Ts, Ts)> {
68    if !f.sig.generics.params.is_empty() {
69        return Err(syn::Error::new(
70            f.sig.generics.span(),
71            format!("{kind}s cannot be generic"),
72        ));
73    }
74    let input = match f.sig.inputs.first() {
75        None => quote!(()),
76        Some(FnArg::Typed(pt)) => {
77            let ty = &pt.ty;
78            quote!(#ty)
79        }
80        Some(FnArg::Receiver(r)) => {
81            return Err(syn::Error::new(
82                r.span(),
83                format!("{kind}s cannot take `self`"),
84            ));
85        }
86    };
87    let output = match &f.sig.output {
88        ReturnType::Default => quote!(()),
89        ReturnType::Type(_, ty) => quote!(#ty),
90    };
91    Ok((input, output))
92}
93
94/// AI policy options shared by actions and screens: `ai = "guidance"`, `ai = false`, `confirm`.
95#[derive(Default)]
96struct AiArgs {
97    hidden: bool,
98    hint: Option<LitStr>,
99    confirm: Option<proc_macro2::Span>,
100}
101
102impl AiArgs {
103    /// Reads the value of `ai = ...`: a string with guidance for models, or `false`.
104    fn parse_ai(&mut self, meta: &syn::meta::ParseNestedMeta) -> syn::Result<()> {
105        if self.hidden || self.hint.is_some() {
106            return Err(meta.error("`ai` is given twice"));
107        }
108        let lit: syn::Lit = meta.value()?.parse()?;
109        match lit {
110            syn::Lit::Str(s) if s.value().trim().is_empty() => Err(syn::Error::new(
111                s.span(),
112                "`ai` guidance is empty\n  = help: write a sentence for the model, or use `ai = false` to hide it from models",
113            )),
114            syn::Lit::Str(s) => {
115                self.hint = Some(s);
116                Ok(())
117            }
118            syn::Lit::Bool(b) if !b.value => {
119                self.hidden = true;
120                Ok(())
121            }
122            syn::Lit::Bool(b) => Err(syn::Error::new(
123                b.span(),
124                "`ai = true` is the default\n  = help: remove it, write `ai = \"guidance for models\"`, or use `ai = false` to hide it from models",
125            )),
126            other => Err(syn::Error::new(
127                other.span(),
128                "`ai` takes a string with guidance for models, or `false`",
129            )),
130        }
131    }
132
133    /// Turns the options into a `::zeyn::Ai` expression.
134    fn tokens(&self, kind: &str) -> syn::Result<Ts> {
135        if let (true, Some(span)) = (self.hidden, self.confirm) {
136            return Err(syn::Error::new(
137                span,
138                format!(
139                    "a {kind} with `ai = false` is never offered to models, so `confirm` has no effect\n  = help: remove `confirm` or `ai = false`"
140                ),
141            ));
142        }
143        let offered = !self.hidden;
144        let hint = self
145            .hint
146            .as_ref()
147            .map(LitStr::value)
148            .unwrap_or_default();
149        let confirm = self.confirm.is_some();
150        Ok(quote!(::zeyn::Ai { offered: #offered, hint: #hint, confirm: #confirm }))
151    }
152}
153
154/// First paragraph of the item's `///` doc comment, on one line. Tools for AI models use it as
155/// their description.
156fn doc_text(attrs: &[syn::Attribute]) -> String {
157    let mut lines: Vec<String> = Vec::new();
158    for a in attrs
159        .iter()
160        .filter(|a| a.path().is_ident("doc"))
161    {
162        let syn::Meta::NameValue(nv) = &a.meta else {
163            continue;
164        };
165        let syn::Expr::Lit(syn::ExprLit {
166            lit: syn::Lit::Str(s),
167            ..
168        }) = &nv.value
169        else {
170            continue;
171        };
172        let value = s.value();
173        let line = value.trim();
174        if line.is_empty() {
175            if lines.is_empty() {
176                continue;
177            }
178            break;
179        }
180        lines.push(line.to_string());
181    }
182    lines.join(" ")
183}
184
185/// Turns a function into a unit struct of the same name with a `run` method.
186///
187/// `fn search_products(q: DataQuery)` becomes `struct search_products;` and `search_products::run(q)`.
188fn unit_and_run(f: &ItemFn) -> Ts {
189    let ItemFn {
190        attrs,
191        vis,
192        sig,
193        block,
194    } = f;
195    let name = &sig.ident;
196    let docs: Vec<_> = attrs
197        .iter()
198        .filter(|a| a.path().is_ident("doc"))
199        .collect();
200    let mut run_sig = sig.clone();
201    run_sig.ident = format_ident!("run");
202    quote! {
203        #(#docs)*
204        #[allow(non_camel_case_types)]
205        #[derive(Clone, Copy, Debug, Default)]
206        #vis struct #name;
207
208        impl #name {
209            #(#attrs)*
210            #vis #run_sig #block
211        }
212    }
213}
214
215/// Code that resolves every argument after the first through `Inject`, then calls `run`.
216///
217/// Returns the statements that bind the injected values, the call expression and the dependency list.
218fn injected_call(f: &ItemFn, kind: &str) -> syn::Result<(Ts, Ts, Ts)> {
219    let mut binds = Vec::new();
220    let mut args = Vec::new();
221    let mut deps = Vec::new();
222    let mut first = true;
223    for (i, arg) in f.sig.inputs.iter().enumerate() {
224        let FnArg::Typed(pt) = arg else { continue };
225        if first {
226            first = false;
227            args.push(quote!(input));
228            continue;
229        }
230        let ty = &pt.ty;
231        if matches!(**ty, Type::Reference(_)) {
232            return Err(syn::Error::new(
233                ty.span(),
234                format!(
235                    "injected {kind} arguments must be owned types\n  = help: take `Ctx` or the service by value; they are cheap to clone"
236                ),
237            ));
238        }
239        let var = format_ident!("__zeyn_arg{}", i);
240        binds.push(quote_spanned!(ty.span()=> let #var = <#ty as ::zeyn::Inject>::inject(&ctx)?;));
241        args.push(quote!(#var));
242        deps.push(quote_spanned!(ty.span()=> <#ty as ::zeyn::Inject>::dep()));
243    }
244    let await_ = f.sig.asyncness.map(|_| quote!(.await));
245    let ignore = if f.sig.inputs.is_empty() {
246        quote!(let _ = input;)
247    } else {
248        quote!()
249    };
250    let call = quote!({ #ignore Self::run(#(#args),*) #await_ });
251    let deps = quote!(::std::vec::Vec::<::core::option::Option<::zeyn::Dep>>::from([#(#deps),*]).into_iter().flatten().collect());
252    Ok((quote!(#(#binds)*), call, deps))
253}
254
255/// On error, emits the original item too, so one mistake does not cause a cascade of errors.
256fn or_error(result: syn::Result<Ts>, original: Ts) -> TokenStream {
257    match result {
258        Ok(ts) => ts.into(),
259        Err(e) => {
260            let err = e.to_compile_error();
261            quote!(#err #original).into()
262        }
263    }
264}
265
266/// Defines a server side action. Access must be declared.
267///
268/// The first argument is the input; every other argument is injected.
269///
270/// Options: `permission = "..."` or `public`, `audit`, `reauth`, and for AI models
271/// `ai = "guidance"`, `ai = false` (never offered to a model) and `confirm` (a model may run it
272/// only after the user confirms in the app). The first paragraph of the doc comment becomes the
273/// description of the action's AI tools.
274///
275/// ```ignore
276/// #[action(permission = "stock.product.write", audit)]
277/// pub async fn save_product(product: Valid<Product>) -> Result<Product> { ... }
278/// ```
279#[proc_macro_attribute]
280pub fn action(attr: TokenStream, item: TokenStream) -> TokenStream {
281    let f = syn::parse_macro_input!(item as ItemFn);
282    let mut access = AccessArgs::default();
283    let mut audit = false;
284    let mut reauth = false;
285    let mut ai = AiArgs::default();
286    let parser = syn::meta::parser(|meta| {
287        if meta.path.is_ident("permission") {
288            access.permission = Some(meta.value()?.parse()?);
289        } else if meta.path.is_ident("public") {
290            access.public = true;
291        } else if meta.path.is_ident("audit") {
292            audit = true;
293        } else if meta.path.is_ident("reauth") {
294            reauth = true;
295        } else if meta.path.is_ident("ai") {
296            ai.parse_ai(&meta)?;
297        } else if meta.path.is_ident("confirm") {
298            ai.confirm = Some(meta.path.span());
299        } else {
300            return Err(meta.error(
301                "unknown action option\n  = help: expected `permission = \"...\"`, `public`, `audit`, `reauth`, `ai = \"...\"`, `ai = false` or `confirm`",
302            ));
303        }
304        Ok(())
305    });
306    syn::parse_macro_input!(attr with parser);
307    let original = quote!(#f);
308    or_error(expand_action(&f, &access, (audit, reauth), &ai), original)
309}
310
311fn expand_action(
312    f: &ItemFn,
313    access: &AccessArgs,
314    flags: (bool, bool),
315    ai: &AiArgs,
316) -> syn::Result<Ts> {
317    let (audit, reauth) = flags;
318    let (input, output) = fn_types(f, "action")?;
319    let name = &f.sig.ident;
320    let access = access.tokens("action", name)?;
321    let ai = ai.tokens("action")?;
322    let doc = doc_text(&f.attrs);
323    let name_str = name.to_string();
324    let base = unit_and_run(f);
325    let (binds, call, deps) = injected_call(f, "action")?;
326    Ok(quote! {
327        #base
328
329        impl ::zeyn::Action for #name {
330            type Input = #input;
331            type Output = #output;
332            const META: ::zeyn::ActionMeta = ::zeyn::ActionMeta {
333                name: #name_str,
334                access: #access,
335                audit: #audit,
336                reauth: #reauth,
337                doc: #doc,
338                ai: #ai,
339            };
340
341            fn deps() -> ::std::vec::Vec<::zeyn::Dep> {
342                #deps
343            }
344
345            fn call(ctx: ::zeyn::Ctx, input: Self::Input) -> ::zeyn::BoxFuture<::zeyn::Result<Self::Output>> {
346                ::std::boxed::Box::pin(async move {
347                    #binds
348                    ::core::result::Result::Ok(#call)
349                })
350            }
351        }
352    })
353}
354
355/// Defines a screen. The first argument is its params; a screen without params takes none or `_: ()`.
356///
357/// ```ignore
358/// #[screen(route = "/products", permission = "stock.product.read")]
359/// pub fn product_list() -> Screen { ... }
360/// ```
361///
362/// `cache = N` (seconds, public screens only) lets browsers and shared caches keep the page of a
363/// request without a session. `no_prefetch` keeps the browser from loading the screen ahead of a
364/// click (see `App::prefetch`).
365#[proc_macro_attribute]
366pub fn screen(attr: TokenStream, item: TokenStream) -> TokenStream {
367    let f = syn::parse_macro_input!(item as ItemFn);
368    let mut access = AccessArgs::default();
369    let mut route: Option<LitStr> = None;
370    let mut sensitive = false;
371    let mut reason = false;
372    let mut ai = AiArgs::default();
373    let mut cache: Option<syn::LitInt> = None;
374    let mut no_prefetch = false;
375    let parser = syn::meta::parser(|meta| {
376        if meta.path.is_ident("permission") {
377            access.permission = Some(meta.value()?.parse()?);
378        } else if meta.path.is_ident("no_prefetch") {
379            no_prefetch = true;
380        } else if meta.path.is_ident("public") {
381            access.public = true;
382        } else if meta.path.is_ident("cache") {
383            cache = Some(meta.value()?.parse()?);
384        } else if meta.path.is_ident("route") {
385            route = Some(meta.value()?.parse()?);
386        } else if meta.path.is_ident("sensitive") {
387            sensitive = true;
388        } else if meta.path.is_ident("reason") {
389            reason = true;
390        } else if meta.path.is_ident("ai") {
391            ai.parse_ai(&meta)?;
392        } else if meta.path.is_ident("confirm") {
393            return Err(meta.error("`confirm` belongs on the action that writes\n  = help: put `confirm` on the `#[action]` that the screen's form or button runs"));
394        } else {
395            return Err(meta.error(
396                "unknown screen option\n  = help: expected `route = \"/...\"`, `permission = \"...\"`, `public`, `cache = 300`, `sensitive`, `reason`, `no_prefetch`, `ai = \"...\"` or `ai = false`",
397            ));
398        }
399        Ok(())
400    });
401    syn::parse_macro_input!(attr with parser);
402    let original = quote!(#f);
403    let cache = match screen_cache(cache.as_ref(), access.public) {
404        Ok(c) => c,
405        Err(e) => return or_error(Err(e), original),
406    };
407    or_error(
408        expand_screen(
409            &f,
410            &access,
411            route.as_ref(),
412            (sensitive || reason, reason),
413            &ai,
414            (cache, no_prefetch),
415        ),
416        original,
417    )
418}
419
420/// Checks `cache = N` of a screen: only on a public screen, 1 to 86400 seconds.
421fn screen_cache(cache: Option<&syn::LitInt>, public: bool) -> syn::Result<u32> {
422    let Some(lit) = cache else { return Ok(0) };
423    if !public {
424        return Err(syn::Error::new(
425            lit.span(),
426            "`cache` is only for public screens\n  = help: add `public`, or remove `cache`; pages of signed in users are never kept in a shared cache",
427        ));
428    }
429    match lit.base10_parse::<u32>() {
430        Ok(n @ 1..=86_400) => Ok(n),
431        _ => Err(syn::Error::new(
432            lit.span(),
433            "`cache` takes seconds from 1 to 86400\n  = help: for example `cache = 300` keeps the page for five minutes",
434        )),
435    }
436}
437
438fn expand_screen(
439    f: &ItemFn,
440    access: &AccessArgs,
441    route: Option<&LitStr>,
442    privacy: (bool, bool),
443    ai: &AiArgs,
444    (cache, no_prefetch): (u32, bool),
445) -> syn::Result<Ts> {
446    let ai = ai.tokens("screen")?;
447    let doc = doc_text(&f.attrs);
448    let (sensitive, reason) = privacy;
449    if reason && access.public {
450        return Err(syn::Error::new(
451            f.sig.ident.span(),
452            "a public screen cannot ask for a reason\n  = help: give the screen a permission",
453        ));
454    }
455    let (params, _) = fn_types(f, "screen")?;
456    let name = &f.sig.ident;
457    let access = access.tokens("screen", name)?;
458    let path_value = match route {
459        Some(r) => path_value_fn(
460            r,
461            &params,
462            params
463                .to_string()
464                .replace(char::is_whitespace, "")
465                == "()",
466        )?,
467        None => quote!(),
468    };
469    let route = match route {
470        Some(r) if !r.value().starts_with('/') => {
471            return Err(syn::Error::new(
472                r.span(),
473                "a screen route must start with `/`",
474            ));
475        }
476        Some(r) => quote!(::core::option::Option::Some(#r)),
477        None => quote!(::core::option::Option::None),
478    };
479    let name_str = name.to_string();
480    let base = unit_and_run(f);
481    let (binds, call, deps) = injected_call(f, "screen")?;
482    Ok(quote! {
483        #base
484
485        impl ::zeyn::ScreenDef for #name {
486            type Params = #params;
487            const META: ::zeyn::ScreenMeta = ::zeyn::ScreenMeta {
488                name: #name_str,
489                route: #route,
490                access: #access,
491                sensitive: #sensitive,
492                reason: #reason,
493                doc: #doc,
494                ai: #ai,
495                cache: #cache,
496                no_prefetch: #no_prefetch,
497            };
498
499            fn deps() -> ::std::vec::Vec<::zeyn::Dep> {
500                #deps
501            }
502
503            #path_value
504
505            fn build(ctx: ::zeyn::Ctx, input: Self::Params) -> ::zeyn::BoxFuture<::zeyn::Result<::zeyn::Screen>> {
506                ::std::boxed::Box::pin(async move {
507                    #binds
508                    ::zeyn::IntoScreen::into_screen(#call)
509                })
510            }
511        }
512    })
513}
514
515/// `ScreenDef::path_value` for a route with path segments (`/orders/{id}`): each segment names a
516/// params field and reads as that field's type. Nothing for a route without segments.
517fn path_value_fn(route: &LitStr, params: &Ts, no_params: bool) -> syn::Result<Ts> {
518    let value = route.value();
519    let mut names: Vec<String> = Vec::new();
520    for seg in value
521        .split('/')
522        .filter(|s| s.contains('{') || s.contains('}'))
523    {
524        let name = seg
525            .strip_prefix('{')
526            .and_then(|s| s.strip_suffix('}'))
527            .filter(|n| syn::parse_str::<Ident>(n).is_ok());
528        let Some(name) = name else {
529            return Err(syn::Error::new(
530                route.span(),
531                format!(
532                    "route segment `{seg}` must be a whole segment `{{field}}` that names a field of the screen's params
533  = help: write `/orders/{{id}}` with a field `id` in the `#[params]` struct; wildcards and partial segments such as `{{a}}-{{b}}` are not supported"
534                ),
535            ));
536        };
537        if names.iter().any(|n| n == name) {
538            return Err(syn::Error::new(
539                route.span(),
540                format!(
541                    "route names `{{{name}}}` twice
542  = help: each path segment binds a different params field"
543                ),
544            ));
545        }
546        names.push(name.to_string());
547    }
548    if names.is_empty() {
549        return Ok(quote!());
550    }
551    if no_params {
552        return Err(syn::Error::new(
553            route.span(),
554            format!(
555                "route `{value}` has path segments but the screen takes no params
556  = help: add a `#[params]` struct with a field `{}` and take it as the screen's first argument",
557                names[0]
558            ),
559        ));
560    }
561    let arms = names.iter().map(|n| {
562        let field = Ident::new(n, route.span());
563        quote_spanned! {route.span()=>
564            #n => ::zeyn::__path_value(raw, |p: &#params| &p.#field),
565        }
566    });
567    Ok(quote! {
568        fn path_value(field: &str, raw: &str) -> ::core::option::Option<::zeyn::__serde_json::Value> {
569            match field {
570                #(#arms)*
571                _ => ::core::option::Option::None,
572            }
573        }
574    })
575}
576
577/// Marks a struct or enum as screen params.
578#[proc_macro_attribute]
579pub fn params(attr: TokenStream, item: TokenStream) -> TokenStream {
580    if !attr.is_empty() {
581        return syn::Error::new(Ts::from(attr).span(), "`#[params]` takes no options")
582            .to_compile_error()
583            .into();
584    }
585    let item = syn::parse_macro_input!(item as Item);
586    let ident = match &item {
587        Item::Struct(s) => &s.ident,
588        Item::Enum(e) => &e.ident,
589        other => {
590            return syn::Error::new(
591                other.span(),
592                "`#[params]` can only be used on a struct or an enum",
593            )
594            .to_compile_error()
595            .into();
596        }
597    };
598    quote! {
599        #[derive(::zeyn::__serde::Serialize, ::zeyn::__serde::Deserialize, Clone, Debug)]
600        #[serde(crate = "::zeyn::__serde")]
601        #item
602
603        impl ::zeyn::Params for #ident {}
604
605        impl ::zeyn::ApiInput for #ident {
606            fn schema() -> ::zeyn::__serde_json::Value {
607                ::zeyn::__serde_json::json!({ "type": "object", "title": stringify!(#ident) })
608            }
609
610            fn from_json(value: ::zeyn::__serde_json::Value) -> ::zeyn::Result<Self> {
611                ::zeyn::__serde_json::from_value(value).map_err(|_| ::zeyn::Error::NotFound)
612            }
613        }
614    }
615    .into()
616}
617
618/// Defines a field type with a fixed set of options (a select list). Variants may take `#[label("...")]`.
619#[proc_macro_attribute]
620pub fn choice(attr: TokenStream, item: TokenStream) -> TokenStream {
621    if !attr.is_empty() {
622        return syn::Error::new(Ts::from(attr).span(), "`#[choice]` takes no options")
623            .to_compile_error()
624            .into();
625    }
626    let e = syn::parse_macro_input!(item as ItemEnum);
627    let original = quote!(#e);
628    or_error(expand_choice(e), original)
629}
630
631fn expand_choice(mut e: ItemEnum) -> syn::Result<Ts> {
632    if e.variants.is_empty() {
633        return Err(syn::Error::new(
634            e.ident.span(),
635            "a choice needs at least one variant",
636        ));
637    }
638    let enum_name = e.ident.to_string();
639    let has_default = e.variants.iter().any(|v| {
640        v.attrs
641            .iter()
642            .any(|a| a.path().is_ident("default"))
643    });
644    let mut options = Vec::new();
645    let mut arms = Vec::new();
646    for (i, v) in e.variants.iter_mut().enumerate() {
647        if !matches!(v.fields, Fields::Unit) {
648            return Err(syn::Error::new(
649                v.span(),
650                "choice variants cannot have fields",
651            ));
652        }
653        let mut label: Option<LitStr> = None;
654        let mut keep = Vec::new();
655        for a in v.attrs.drain(..) {
656            if a.path().is_ident("label") {
657                label = Some(a.parse_args()?);
658            } else {
659                keep.push(a);
660            }
661        }
662        if i == 0 && !has_default {
663            keep.push(syn::parse_quote!(#[default]));
664        }
665        v.attrs = keep;
666        let ident = &v.ident;
667        let value = ident.to_string();
668        let label = label
669            .map(|l| l.value())
670            .unwrap_or_else(|| value.clone());
671        let key = format!("{enum_name}.{value}");
672        options.push(quote!(::zeyn::ChoiceOption { value: #value, label: #label, key: #key }));
673        arms.push(quote!(Self::#ident => #value));
674    }
675    let ident = &e.ident;
676    Ok(quote! {
677        #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default, ::zeyn::__serde::Serialize, ::zeyn::__serde::Deserialize)]
678        #[serde(crate = "::zeyn::__serde")]
679        #e
680
681        impl ::zeyn::ChoiceType for #ident {}
682
683        impl #ident {
684            #[doc(hidden)]
685            pub const __ZEYN_OPTIONS: &'static [::zeyn::ChoiceOption] = &[#(#options),*];
686        }
687
688        impl ::zeyn::FieldType for #ident {
689            const KIND: ::zeyn::FieldKind = ::zeyn::FieldKind::Choice(Self::__ZEYN_OPTIONS);
690            fn to_value(&self) -> ::zeyn::Value {
691                ::zeyn::Value::Choice(match self { #(#arms),* })
692            }
693        }
694
695        impl ::zeyn::VecItem for #ident {
696            const VEC_KIND: ::zeyn::FieldKind = ::zeyn::FieldKind::Choices(Self::__ZEYN_OPTIONS);
697            fn __vec_value(items: &[Self]) -> ::zeyn::Value {
698                ::zeyn::__choices_value(items)
699            }
700        }
701    })
702}
703
704/// Options shared by `#[model]` and `#[entity]`.
705#[derive(Default)]
706struct ModelArgs {
707    table: Option<LitStr>,
708    validate: Option<Path>,
709    sensitive: bool,
710}
711
712fn parse_model_args(attr: TokenStream, allow_table: bool) -> syn::Result<ModelArgs> {
713    let mut args = ModelArgs::default();
714    let parser = syn::meta::parser(|meta| {
715        if allow_table && meta.path.is_ident("table") {
716            args.table = Some(meta.value()?.parse()?);
717        } else if meta.path.is_ident("validate") {
718            args.validate = Some(meta.value()?.parse()?);
719        } else if meta.path.is_ident("sensitive") {
720            args.sensitive = true;
721        } else if allow_table {
722            return Err(meta.error(
723                "unknown entity option\n  = help: expected `table = \"...\"`, `validate = path::to::fn` or `sensitive`",
724            ));
725        } else {
726            return Err(meta.error(
727                "unknown model option\n  = help: expected `validate = path::to::fn` or `sensitive`",
728            ));
729        }
730        Ok(())
731    });
732    syn::parse::Parser::parse(parser, attr)?;
733    Ok(args)
734}
735
736/// A `min` or `max` limit with the underscores of an integer literal taken out, so dates written
737/// `2026_01_01` (year, month, day) do not trip clippy's digit grouping lint; the literal takes
738/// the span of the macro call, which clippy leaves alone as generated code.
739fn without_digit_groups(expr: syn::Expr) -> syn::Expr {
740    match expr {
741        syn::Expr::Lit(syn::ExprLit {
742            lit: syn::Lit::Int(n),
743            attrs,
744        }) => {
745            let plain = format!("{}{}", n.base10_digits(), n.suffix());
746            syn::Expr::Lit(syn::ExprLit {
747                lit: syn::Lit::Int(LitInt::new(&plain, proc_macro2::Span::call_site())),
748                attrs,
749            })
750        }
751        syn::Expr::Unary(mut u) => {
752            *u.expr = without_digit_groups(*u.expr);
753            syn::Expr::Unary(u)
754        }
755        other => other,
756    }
757}
758
759/// Defines validated data that is not tied to a table: form input, filters, wizard steps, report rows.
760///
761/// Field rules: `required`, `min_len = N`, `max_len = N`, `email`, `min = N`, `max = N`,
762/// `scale = N` and `precision = N` (for `Decimal`), `mask = "..."` (for text).
763/// Rules that involve several fields go in a function given with `validate = ...`.
764///
765/// Text fields with personal data can be masked: `#[mask(last4)]`, `#[mask(email)]` or
766/// `#[mask]` (nothing shown). Users with `<model>.<field>.unmask` (or the permission given with
767/// `#[mask(last4, permission = "...")]`) may reveal the value; every reveal is audited.
768///
769/// ```ignore
770/// #[model(validate = check_dates)]
771/// pub struct LeaveRequest {
772///     #[rule(required)]
773///     pub reason: String,
774///     pub starts: i64,
775///     pub ends: i64,
776/// }
777/// ```
778#[proc_macro_attribute]
779pub fn model(attr: TokenStream, item: TokenStream) -> TokenStream {
780    let s = syn::parse_macro_input!(item as ItemStruct);
781    let original = quote!(#s);
782    let result = parse_model_args(attr, false).and_then(|args| expand_model(s, args, false));
783    or_error(result, original)
784}
785
786/// Defines a model stored in a database table. Needs `table = "..."` and exactly one `#[key]` field.
787///
788/// Generates typed field references (`Product::code`), a `ProductId` key type, validation and a schema.
789///
790/// ```ignore
791/// #[entity(table = "products")]
792/// pub struct Product {
793///     #[key]
794///     pub id: ProductId,
795///     #[rule(required, max_len = 20)]
796///     pub code: String,
797/// }
798/// ```
799#[proc_macro_attribute]
800pub fn entity(attr: TokenStream, item: TokenStream) -> TokenStream {
801    let s = syn::parse_macro_input!(item as ItemStruct);
802    let original = quote!(#s);
803    let result = parse_model_args(attr, true).and_then(|args| expand_model(s, args, true));
804    or_error(result, original)
805}
806
807/// `LeaveRequest` -> `leave_request`.
808fn snake_case(name: &str) -> String {
809    let mut out = String::with_capacity(name.len() + 4);
810    for (i, c) in name.chars().enumerate() {
811        if c.is_ascii_uppercase() {
812            if i > 0 {
813                out.push('_');
814            }
815            out.push(c.to_ascii_lowercase());
816        } else {
817            out.push(c);
818        }
819    }
820    out
821}
822
823/// `unit_price` -> `Unit price`.
824fn default_label(name: &str) -> String {
825    let text = name.replace('_', " ");
826    let mut chars = text.chars();
827    match chars.next() {
828        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
829        None => text,
830    }
831}
832
833fn expand_model(mut s: ItemStruct, args: ModelArgs, is_entity: bool) -> syn::Result<Ts> {
834    let name = s.ident.clone();
835    let kind = if is_entity { "entity" } else { "model" };
836    let before = s.attrs.len();
837    s.attrs
838        .retain(|a| !a.path().is_ident("sensitive"));
839    let sensitive = args.sensitive || s.attrs.len() != before;
840    if !s.generics.params.is_empty() {
841        return Err(syn::Error::new(
842            s.generics.span(),
843            format!("{kind}s cannot be generic"),
844        ));
845    }
846    let Fields::Named(named) = &mut s.fields else {
847        return Err(syn::Error::new(
848            name.span(),
849            format!("a {kind} must have named fields"),
850        ));
851    };
852
853    let id_name = format_ident!("{}Id", name);
854    let mut metas = Vec::new();
855    let mut getters = Vec::new();
856    let mut consts = Vec::new();
857    let mut validations = Vec::new();
858    let mut value_arms = Vec::new();
859    let mut checks = Vec::new();
860    let mut keys = 0usize;
861    let mut generate_id = false;
862
863    for (idx, field) in named.named.iter_mut().enumerate() {
864        let fname = field
865            .ident
866            .clone()
867            .expect("named field");
868        let fname_str = fname.to_string();
869        let ty: Type = field.ty.clone();
870        let fvis = field.vis.clone();
871        let mut is_key = false;
872        let mut readonly = false;
873        let mut version = false;
874        let mut label: Option<LitStr> = None;
875        let mut mask = quote!(::core::option::Option::None);
876        let mut masked = false;
877        let mut rules = Vec::new();
878        let mut keep = Vec::new();
879
880        for a in field.attrs.drain(..) {
881            if a.path().is_ident("key") {
882                is_key = true;
883            } else if a.path().is_ident("readonly") {
884                readonly = true;
885            } else if a.path().is_ident("version") {
886                version = true;
887                checks.push(quote_spanned!(ty.span()=> ::zeyn::__rules::version::<#ty>();));
888            } else if a.path().is_ident("label") {
889                label = Some(a.parse_args()?);
890            } else if a.path().is_ident("mask") {
891                let mut style = quote!(::zeyn::MaskStyle::Full);
892                let mut permission: Option<LitStr> = None;
893                if !matches!(a.meta, syn::Meta::Path(_)) {
894                    a.parse_nested_meta(|m| {
895                        if m.path.is_ident("last4") {
896                            style = quote!(::zeyn::MaskStyle::LastFour);
897                        } else if m.path.is_ident("email") {
898                            style = quote!(::zeyn::MaskStyle::Email);
899                        } else if m.path.is_ident("full") {
900                            style = quote!(::zeyn::MaskStyle::Full);
901                        } else if m.path.is_ident("permission") {
902                            let p: LitStr = m.value()?.parse()?;
903                            check_permission(&p)?;
904                            permission = Some(p);
905                        } else {
906                            return Err(m.error("unknown mask option\n  = help: expected `last4`, `email`, `full` or `permission = \"...\"`"));
907                        }
908                        Ok(())
909                    })?;
910                }
911                let permission = permission
912                    .map(|p| p.value())
913                    .unwrap_or_else(|| {
914                        format!("{}.{}.unmask", snake_case(&name.to_string()), fname_str)
915                    });
916                checks.push(quote_spanned!(ty.span()=> ::zeyn::__rules::text::<#ty>();));
917                mask = quote!(::core::option::Option::Some(::zeyn::Masking { style: #style, permission: #permission }));
918                masked = true;
919            } else if a.path().is_ident("rule") {
920                a.parse_nested_meta(|m| {
921                    let text_check = quote_spanned!(ty.span()=> ::zeyn::__rules::text::<#ty>(););
922                    if m.path.is_ident("required") {
923                        rules.push(quote!(::zeyn::Rule::Required));
924                        checks.push(quote_spanned!(ty.span()=> ::zeyn::__rules::required::<#ty>();));
925                    } else if m.path.is_ident("unique") {
926                        rules.push(quote!(::zeyn::Rule::Unique));
927                    } else if m.path.is_ident("email") {
928                        rules.push(quote!(::zeyn::Rule::Email));
929                        checks.push(text_check);
930                    } else if m.path.is_ident("embed") {
931                        rules.push(quote!(::zeyn::Rule::Embed));
932                        checks.push(text_check);
933                    } else if m.path.is_ident("max_len") || m.path.is_ident("min_len") {
934                        let n: LitInt = m.value()?.parse()?;
935                        if m.path.is_ident("max_len") {
936                            rules.push(quote!(::zeyn::Rule::MaxLen(#n)));
937                        } else {
938                            rules.push(quote!(::zeyn::Rule::MinLen(#n)));
939                        }
940                        checks.push(text_check);
941                    } else if m.path.is_ident("min") || m.path.is_ident("max") {
942                        let n = without_digit_groups(m.value()?.parse()?);
943                        if m.path.is_ident("min") {
944                            rules.push(quote!(::zeyn::Rule::Min(#n)));
945                        } else {
946                            rules.push(quote!(::zeyn::Rule::Max(#n)));
947                        }
948                        checks.push(quote_spanned!(ty.span()=> ::zeyn::__rules::min_max::<#ty>();));
949                    } else if m.path.is_ident("scale") || m.path.is_ident("precision") {
950                        let n: LitInt = m.value()?.parse()?;
951                        let v: u8 = n.base10_parse()?;
952                        if m.path.is_ident("scale") {
953                            if v > 28 {
954                                return Err(syn::Error::new(n.span(), "`scale` is at most 28"));
955                            }
956                            rules.push(quote!(::zeyn::Rule::Scale(#n)));
957                        } else {
958                            if v == 0 || v > 38 {
959                                return Err(syn::Error::new(n.span(), "`precision` is between 1 and 38"));
960                            }
961                            rules.push(quote!(::zeyn::Rule::Precision(#n)));
962                        }
963                        checks.push(quote_spanned!(ty.span()=> ::zeyn::__rules::scale::<#ty>();));
964                    } else if m.path.is_ident("mask") {
965                        let p: LitStr = m.value()?.parse()?;
966                        let mut slots = 0usize;
967                        let mut escaped = false;
968                        for c in p.value().chars() {
969                            if !escaped && matches!(c, '9' | 'a' | 'A' | '*') {
970                                slots += 1;
971                            }
972                            escaped = !escaped && c == '\\';
973                        }
974                        if slots == 0 {
975                            return Err(syn::Error::new(
976                                p.span(),
977                                "a mask needs at least one slot\n  = help: `9` is a digit, `a` a letter, `A` an upper case letter, `*` a letter or digit",
978                            ));
979                        }
980                        rules.push(quote!(::zeyn::Rule::Mask(#p)));
981                        checks.push(text_check);
982                    } else {
983                        return Err(m.error(
984                            "unknown rule\n  = help: expected `required`, `unique`, `email`, `embed`, `min_len = N`, `max_len = N`, `min = N`, `max = N`, `scale = N`, `precision = N` or `mask = \"...\"`",
985                        ));
986                    }
987                    Ok(())
988                })?;
989            } else {
990                keep.push(a);
991            }
992        }
993        let guard = if masked {
994            Some((
995                format_ident!("__zeyn_mask_{}", fname),
996                quote!(::zeyn::__privacy::masked_output),
997            ))
998        } else if is_key && sensitive {
999            Some((
1000                format_ident!("__zeyn_read_{}", fname),
1001                quote!(::zeyn::__privacy::read_output),
1002            ))
1003        } else {
1004            None
1005        };
1006        if let Some((with, call)) = guard {
1007            let path = LitStr::new(&format!("{name}::{with}"), fname.span());
1008            keep.push(syn::parse_quote!(#[serde(serialize_with = #path)]));
1009            getters.push(quote! {
1010                #[doc(hidden)]
1011                fn #with<S: ::zeyn::__serde::Serializer>(value: &#ty, s: S) -> ::core::result::Result<S::Ok, S::Error> {
1012                    #call(value, &Self::__ZEYN_FIELDS[#idx], s)
1013                }
1014            });
1015        }
1016        field.attrs = keep;
1017
1018        if is_key {
1019            keys += 1;
1020            if let Type::Path(p) = &ty
1021                && p.qself.is_none()
1022                && p.path.is_ident(&id_name)
1023            {
1024                generate_id = true;
1025            }
1026        }
1027        if is_key && !is_entity {
1028            return Err(syn::Error::new(
1029                fname.span(),
1030                "`#[key]` is only allowed on entities\n  = help: use `#[entity(table = \"...\")]` for table backed data",
1031            ));
1032        }
1033
1034        let label = label
1035            .map(|l| l.value())
1036            .unwrap_or_else(|| default_label(&fname_str));
1037        let getter = format_ident!("__zeyn_get_{}", fname);
1038        let model_str = name.to_string();
1039        metas.push(quote! {
1040            ::zeyn::FieldMeta {
1041                model: #model_str,
1042                name: #fname_str,
1043                label: #label,
1044                kind: <#ty as ::zeyn::FieldType>::KIND,
1045                rules: &[#(#rules),*],
1046                key: #is_key,
1047                readonly: #readonly,
1048                version: #version,
1049                optional: <#ty as ::zeyn::FieldType>::OPTIONAL,
1050                mask: #mask,
1051            }
1052        });
1053        getters.push(quote! {
1054            #[doc(hidden)]
1055            fn #getter(m: &#name) -> &#ty { &m.#fname }
1056        });
1057        consts.push(quote! {
1058            #fvis const #fname: ::zeyn::Field<#name, #ty> = ::zeyn::Field::new(&Self::__ZEYN_FIELDS[#idx], Self::#getter);
1059        });
1060        validations.push(quote! {
1061            ::zeyn::__rules::validate(&self.#fname, &Self::__ZEYN_FIELDS[#idx], &mut errors);
1062        });
1063        value_arms.push(
1064            quote!(#idx => ::core::option::Option::Some(::zeyn::FieldType::to_value(&self.#fname))),
1065        );
1066    }
1067
1068    let table_impl = if is_entity {
1069        let table = args.table.ok_or_else(|| {
1070            syn::Error::new(
1071                name.span(),
1072                format!("entity `{name}` has no table\n  = help: add `table = \"...\"`"),
1073            )
1074        })?;
1075        if keys != 1 {
1076            return Err(syn::Error::new(
1077                name.span(),
1078                format!("entity `{name}` needs exactly one `#[key]` field, found {keys}"),
1079            ));
1080        }
1081        Some(quote! {
1082            impl ::zeyn::Entity for #name {
1083                const TABLE: &'static str = #table;
1084            }
1085        })
1086    } else {
1087        None
1088    };
1089
1090    let custom = args
1091        .validate
1092        .map(|path| quote!(#path(self, &mut errors);));
1093    let vis = &s.vis;
1094    let id_struct = generate_id.then(|| {
1095        let doc = format!("Key of a `{name}` record.");
1096        quote! {
1097            #[doc = #doc]
1098            #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, ::zeyn::__serde::Serialize, ::zeyn::__serde::Deserialize)]
1099            #[serde(crate = "::zeyn::__serde", transparent)]
1100            #vis struct #id_name(pub i64);
1101
1102            impl ::zeyn::FieldType for #id_name {
1103                const KIND: ::zeyn::FieldKind = ::zeyn::FieldKind::Id;
1104                fn to_value(&self) -> ::zeyn::Value { ::zeyn::Value::Int(self.0) }
1105            }
1106
1107            impl ::zeyn::ui::LookupValue for #id_name {}
1108
1109            impl ::zeyn::PathValue for #id_name {
1110                fn from_segment(raw: &str) -> ::core::option::Option<Self> {
1111                    <i64 as ::zeyn::PathValue>::from_segment(raw).map(#id_name)
1112                }
1113            }
1114
1115            impl ::zeyn::ApiInput for #id_name {
1116                fn schema() -> ::zeyn::__serde_json::Value {
1117                    ::zeyn::__serde_json::json!({ "type": "integer", "title": stringify!(#id_name) })
1118                }
1119
1120                fn from_json(value: ::zeyn::__serde_json::Value) -> ::zeyn::Result<Self> {
1121                    ::zeyn::__serde_json::from_value(value).map_err(|_| ::zeyn::Error::NotFound)
1122                }
1123            }
1124        }
1125    });
1126    let name_str = name.to_string();
1127
1128    Ok(quote! {
1129        #[derive(Clone, Debug, Default, ::zeyn::__serde::Serialize, ::zeyn::__serde::Deserialize)]
1130        #[serde(crate = "::zeyn::__serde")]
1131        #s
1132
1133        #id_struct
1134
1135        #[allow(non_upper_case_globals)]
1136        impl #name {
1137            #[doc(hidden)]
1138            pub const __ZEYN_FIELDS: &'static [::zeyn::FieldMeta] = &[#(#metas),*];
1139            #(#getters)*
1140            #(#consts)*
1141        }
1142
1143        impl ::zeyn::Model for #name {
1144            const NAME: &'static str = #name_str;
1145            const SENSITIVE: bool = #sensitive;
1146            fn fields() -> &'static [::zeyn::FieldMeta] { Self::__ZEYN_FIELDS }
1147            fn value_at(&self, index: usize) -> ::core::option::Option<::zeyn::Value> {
1148                match index {
1149                    #(#value_arms,)*
1150                    _ => ::core::option::Option::None,
1151                }
1152            }
1153            fn validate(&self) -> ::core::result::Result<(), ::zeyn::ValidationErrors> {
1154                let mut errors = ::zeyn::ValidationErrors::default();
1155                #(#validations)*
1156                #custom
1157                errors.into_result()
1158            }
1159        }
1160
1161        #table_impl
1162
1163        const _: () = {
1164            #[allow(dead_code)]
1165            fn __zeyn_rule_checks() { #(#checks)* }
1166        };
1167    })
1168}
1169
1170/// Catalog text checked at compile time: `t!("products.title")` or `t!("orders.count", n = count)`.
1171///
1172/// Every JSON file in the crate's `locales/` folder must have the key, and the message's `{name}`
1173/// placeholders must match the arguments exactly.
1174#[proc_macro]
1175pub fn t(input: TokenStream) -> TokenStream {
1176    let args = syn::parse_macro_input!(input as TArgs);
1177    expand_t(args)
1178        .unwrap_or_else(|e| e.to_compile_error())
1179        .into()
1180}
1181
1182struct TArgs {
1183    key: LitStr,
1184    args: Vec<(Ident, syn::Expr)>,
1185}
1186
1187impl syn::parse::Parse for TArgs {
1188    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
1189        let key: LitStr = input.parse()?;
1190        let mut args = Vec::new();
1191        while input.parse::<syn::Token![,]>().is_ok() {
1192            if input.is_empty() {
1193                break;
1194            }
1195            let name: Ident = input.parse()?;
1196            input.parse::<syn::Token![=]>()?;
1197            let value: syn::Expr = input.parse()?;
1198            args.push((name, value));
1199        }
1200        Ok(TArgs { key, args })
1201    }
1202}
1203
1204fn lookup_key<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a str> {
1205    key.split('.')
1206        .try_fold(value, |v, part| v.get(part))?
1207        .as_str()
1208}
1209
1210fn placeholders(message: &str) -> std::collections::BTreeSet<String> {
1211    let mut found = std::collections::BTreeSet::new();
1212    let mut rest = message;
1213    while let Some(start) = rest.find('{') {
1214        let Some(len) = rest[start..].find('}') else {
1215            break;
1216        };
1217        let name = &rest[start + 1..start + len];
1218        if !name.is_empty()
1219            && name
1220                .chars()
1221                .all(|c| c.is_ascii_alphanumeric() || c == '_')
1222        {
1223            found.insert(name.to_string());
1224        }
1225        rest = &rest[start + len..];
1226    }
1227    found
1228}
1229
1230fn expand_t(t: TArgs) -> syn::Result<Ts> {
1231    let key = t.key.value();
1232    let span = t.key.span();
1233    let dir = std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default())
1234        .join("locales");
1235    let mut files: Vec<_> = std::fs::read_dir(&dir)
1236        .map_err(|_| {
1237            syn::Error::new(
1238                span,
1239                format!(
1240                    "t!: no `locales` folder at {}\n  = help: create locales/en.json",
1241                    dir.display()
1242                ),
1243            )
1244        })?
1245        .filter_map(|e| e.ok().map(|e| e.path()))
1246        .filter(|p| {
1247            p.extension()
1248                .is_some_and(|x| x == "json")
1249        })
1250        .collect();
1251    files.sort();
1252    if files.is_empty() {
1253        return Err(syn::Error::new(
1254            span,
1255            "t!: the `locales` folder has no .json files",
1256        ));
1257    }
1258    let given: std::collections::BTreeSet<String> = t
1259        .args
1260        .iter()
1261        .map(|(n, _)| n.to_string())
1262        .collect();
1263    let mut tracked = Vec::new();
1264    for path in &files {
1265        let text = std::fs::read_to_string(path)
1266            .map_err(|e| syn::Error::new(span, format!("t!: {}: {e}", path.display())))?;
1267        let json: serde_json::Value = serde_json::from_str(&text).map_err(|e| {
1268            syn::Error::new(
1269                span,
1270                format!("t!: {} is not valid JSON: {e}", path.display()),
1271            )
1272        })?;
1273        let file = path
1274            .file_name()
1275            .map(|f| f.to_string_lossy().to_string())
1276            .unwrap_or_default();
1277        let Some(message) = lookup_key(&json, &key) else {
1278            return Err(syn::Error::new(
1279                span,
1280                format!(
1281                    "t!: key `{key}` is missing in locales/{file}\n  = help: add it to every file in locales/"
1282                ),
1283            ));
1284        };
1285        let wanted = placeholders(message);
1286        if let Some(missing) = wanted.difference(&given).next() {
1287            return Err(syn::Error::new(
1288                span,
1289                format!(
1290                    "t!: `{key}` in locales/{file} uses {{{missing}}} but no `{missing} = ...` argument was given"
1291                ),
1292            ));
1293        }
1294        if let Some(extra) = given.difference(&wanted).next() {
1295            return Err(syn::Error::new(
1296                span,
1297                format!("t!: argument `{extra}` is not used by `{key}` in locales/{file}"),
1298            ));
1299        }
1300        let p = path.to_string_lossy().to_string();
1301        tracked.push(quote!(
1302            const _: &[u8] = ::core::include_bytes!(#p);
1303        ));
1304    }
1305    let args = t.args.iter().map(|(n, v)| {
1306        let n = n.to_string();
1307        quote!(.arg(#n, ::zeyn::Text::from(::std::string::ToString::to_string(&#v))))
1308    });
1309    Ok(quote!({
1310        #(#tracked)*
1311        ::zeyn::Text::key(#key) #(#args)*
1312    }))
1313}