1use 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#[derive(Default)]
16struct AccessArgs {
17 permission: Option<LitStr>,
18 public: bool,
19}
20
21impl AccessArgs {
22 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
44fn 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
66fn 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#[derive(Default)]
96struct AiArgs {
97 hidden: bool,
98 hint: Option<LitStr>,
99 confirm: Option<proc_macro2::Span>,
100}
101
102impl AiArgs {
103 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 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
154fn 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
185fn 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
215fn 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
255fn 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#[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#[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
420fn 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 ¶ms,
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
515fn 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#[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#[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#[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
736fn 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#[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#[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
807fn 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
823fn 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#[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}