1#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
2use heck::ToSnakeCase;
3use proc_macro::TokenStream;
4use proc_macro2::Span;
5use quote::{format_ident, quote};
6use syn::{
7 DeriveInput, Expr, Ident, ImplItem, ItemImpl, Lit, LitBool, LitStr, Meta, MetaList,
8 MetaNameValue, Path, Token, TypePath, parse::Parse, parse::ParseStream, parse_macro_input,
9 punctuated::Punctuated,
10};
11
12mod api_dto;
14mod domain_model;
15mod expand_vars;
16mod grpc_client;
17mod temporary;
18mod utils;
20
21struct GearConfig {
23 name: String,
24 deps: Vec<Ident>,
25 caps: Vec<Capability>,
26 ctor: Option<Expr>, client: Option<Path>, lifecycle: Option<LcGearCfg>, }
30
31#[derive(Debug, PartialEq, Clone)]
32enum Capability {
33 Db,
34 Rest,
35 RestHost,
36 Stateful,
37 System,
38 GrpcHub,
39 Grpc,
40}
41
42impl Capability {
43 const VALID_CAPABILITIES: &'static [&'static str] = &[
44 "db",
45 "rest",
46 "rest_host",
47 "stateful",
48 "system",
49 "grpc_hub",
50 "grpc",
51 ];
52
53 fn suggest_similar(input: &str) -> Vec<&'static str> {
54 let mut suggestions: Vec<(&str, f64)> = Self::VALID_CAPABILITIES
55 .iter()
56 .map(|&cap| (cap, strsim::jaro_winkler(input, cap)))
57 .filter(|(_, score)| *score > 0.6) .collect();
59
60 suggestions.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
61 suggestions
62 .into_iter()
63 .take(2)
64 .map(|(cap, _)| cap)
65 .collect()
66 }
67
68 fn from_ident(ident: &Ident) -> syn::Result<Self> {
69 let input = ident.to_string();
70 match input.as_str() {
71 "db" => Ok(Capability::Db),
72 "rest" => Ok(Capability::Rest),
73 "rest_host" => Ok(Capability::RestHost),
74 "stateful" => Ok(Capability::Stateful),
75 "system" => Ok(Capability::System),
76 "grpc_hub" => Ok(Capability::GrpcHub),
77 "grpc" => Ok(Capability::Grpc),
78 other => {
79 let suggestions = Self::suggest_similar(other);
80 let error_msg = if suggestions.is_empty() {
81 format!(
82 "unknown capability '{other}', expected one of: db, rest, rest_host, stateful, system, grpc_hub, grpc"
83 )
84 } else {
85 format!(
86 "unknown capability '{other}'\n = help: did you mean one of: {}?",
87 suggestions.join(", ")
88 )
89 };
90 Err(syn::Error::new_spanned(ident, error_msg))
91 }
92 }
93 }
94
95 fn from_str_lit(lit: &LitStr) -> syn::Result<Self> {
96 let input = lit.value();
97 match input.as_str() {
98 "db" => Ok(Capability::Db),
99 "rest" => Ok(Capability::Rest),
100 "rest_host" => Ok(Capability::RestHost),
101 "stateful" => Ok(Capability::Stateful),
102 "system" => Ok(Capability::System),
103 "grpc_hub" => Ok(Capability::GrpcHub),
104 "grpc" => Ok(Capability::Grpc),
105 other => {
106 let suggestions = Self::suggest_similar(other);
107 let error_msg = if suggestions.is_empty() {
108 format!(
109 "unknown capability '{other}', expected one of: db, rest, rest_host, stateful, system, grpc_hub, grpc"
110 )
111 } else {
112 format!(
113 "unknown capability '{other}'\n = help: did you mean one of: {}?",
114 suggestions.join(", ")
115 )
116 };
117 Err(syn::Error::new_spanned(lit, error_msg))
118 }
119 }
120 }
121}
122
123fn validate_kebab_case(name: &str) -> Result<(), String> {
136 if name.is_empty() {
137 return Err("gear name cannot be empty".to_owned());
138 }
139
140 if name.contains('_') {
142 let suggested = name.replace('_', "-");
143 return Err(format!(
144 "gear name must use kebab-case, not snake_case\n = help: use '{suggested}' instead of '{name}'"
145 ));
146 }
147
148 if let Some(first_char) = name.chars().next() {
150 if !first_char.is_ascii_lowercase() {
151 return Err(format!(
152 "gear name must start with a lowercase letter, found '{first_char}'"
153 ));
154 }
155 } else {
156 return Err("gear name cannot be empty".to_owned());
158 }
159
160 if name.ends_with('-') {
162 return Err("gear name must not end with a hyphen".to_owned());
163 }
164
165 let mut prev_was_hyphen = false;
167 for ch in name.chars() {
168 if ch == '-' {
169 if prev_was_hyphen {
170 return Err("gear name must not contain consecutive hyphens".to_owned());
171 }
172 prev_was_hyphen = true;
173 } else if ch.is_ascii_lowercase() || ch.is_ascii_digit() {
174 prev_was_hyphen = false;
175 } else {
176 return Err(format!(
177 "gear name must contain only lowercase letters, digits, and hyphens, found '{ch}'"
178 ));
179 }
180 }
181
182 Ok(())
183}
184
185#[derive(Debug, Clone)]
186struct LcGearCfg {
187 entry: String, stop_timeout: Option<String>, stop_timeout_fn: Option<Ident>,
190 await_ready: bool, }
192
193impl Default for LcGearCfg {
194 fn default() -> Self {
195 Self {
196 entry: "serve".to_owned(),
197 stop_timeout: None,
198 stop_timeout_fn: None,
199 await_ready: false,
200 }
201 }
202}
203
204impl Parse for GearConfig {
205 #[allow(clippy::too_many_lines)]
206 fn parse(input: ParseStream) -> syn::Result<Self> {
207 let mut name: Option<String> = None;
208 let mut deps: Vec<Ident> = Vec::new();
209 let mut caps: Vec<Capability> = Vec::new();
210 let mut ctor: Option<Expr> = None;
211 let mut client: Option<Path> = None;
212 let mut lifecycle: Option<LcGearCfg> = None;
213
214 let mut seen_name = false;
215 let mut seen_deps = false;
216 let mut seen_caps = false;
217 let mut seen_ctor = false;
218 let mut seen_client = false;
219 let mut seen_lifecycle = false;
220 let mut seen_one_per_installation = false;
221
222 let punctuated: Punctuated<Meta, Token![,]> =
223 input.parse_terminated(Meta::parse, Token![,])?;
224
225 for meta in punctuated {
226 match meta {
227 Meta::NameValue(nv) if nv.path.is_ident("name") => {
228 if seen_name {
229 return Err(syn::Error::new_spanned(
230 nv.path,
231 "duplicate `name` parameter",
232 ));
233 }
234 seen_name = true;
235 match nv.value {
236 Expr::Lit(syn::ExprLit {
237 lit: Lit::Str(s), ..
238 }) => {
239 let gear_name = s.value();
240 if let Err(err) = validate_kebab_case(&gear_name) {
242 return Err(syn::Error::new_spanned(s, err));
243 }
244 name = Some(gear_name);
245 }
246 other => {
247 return Err(syn::Error::new_spanned(
248 other,
249 "name must be a string literal, e.g. name = \"my-gear\"",
250 ));
251 }
252 }
253 }
254 Meta::NameValue(nv) if nv.path.is_ident("ctor") => {
255 if seen_ctor {
256 return Err(syn::Error::new_spanned(
257 nv.path,
258 "duplicate `ctor` parameter",
259 ));
260 }
261 seen_ctor = true;
262
263 match &nv.value {
265 Expr::Lit(syn::ExprLit {
266 lit: Lit::Str(s), ..
267 }) => {
268 return Err(syn::Error::new_spanned(
269 s,
270 "ctor must be a Rust expression, not a string literal. \
271 Use: ctor = MyType::new() (with parentheses), \
272 or: ctor = Default::default()",
273 ));
274 }
275 _ => {
276 ctor = Some(nv.value.clone());
277 }
278 }
279 }
280 Meta::NameValue(nv) if nv.path.is_ident("client") => {
281 if seen_client {
282 return Err(syn::Error::new_spanned(
283 nv.path,
284 "duplicate `client` parameter",
285 ));
286 }
287 seen_client = true;
288 let value = nv.value.clone();
289 match value {
290 Expr::Path(ep) => {
291 client = Some(ep.path);
292 }
293 other => {
294 return Err(syn::Error::new_spanned(
295 other,
296 "client must be a trait path, e.g. client = crate::api::MyClient",
297 ));
298 }
299 }
300 }
301 Meta::NameValue(nv) if nv.path.is_ident("one_per_installation") => {
315 if seen_one_per_installation {
316 return Err(syn::Error::new_spanned(
317 nv.path,
318 "duplicate `one_per_installation` parameter",
319 ));
320 }
321 seen_one_per_installation = true;
322 if !matches!(
323 nv.value,
324 Expr::Lit(syn::ExprLit {
325 lit: Lit::Bool(_),
326 ..
327 })
328 ) {
329 return Err(syn::Error::new_spanned(
330 nv.value,
331 "one_per_installation must be a bool, e.g. one_per_installation = true",
332 ));
333 }
334 }
335 Meta::NameValue(nv) if nv.path.is_ident("deps") => {
336 if seen_deps {
337 return Err(syn::Error::new_spanned(
338 nv.path,
339 "duplicate `deps` parameter",
340 ));
341 }
342 seen_deps = true;
343 let value = nv.value.clone();
344 match value {
345 Expr::Array(arr) => {
346 for elem in arr.elems {
347 match elem {
348 Expr::Path(ref path) => {
349 if let Some(ident) = path.path.get_ident() {
350 deps.push(ident.clone());
351 } else {
352 return Err(syn::Error::new_spanned(
353 path,
354 "deps must be crate identifiers, e.g. deps = [authn_resolver, types_registry]",
355 ));
356 }
357 }
358 other => {
359 return Err(syn::Error::new_spanned(
360 other,
361 "deps must be crate identifiers, e.g. deps = [authn_resolver, types_registry]",
362 ));
363 }
364 }
365 }
366 }
367 other => {
368 return Err(syn::Error::new_spanned(
369 other,
370 "deps must be an array, e.g. deps = [authn_resolver, types_registry]",
371 ));
372 }
373 }
374 }
375 Meta::NameValue(nv) if nv.path.is_ident("capabilities") => {
376 if seen_caps {
377 return Err(syn::Error::new_spanned(
378 nv.path,
379 "duplicate `capabilities` parameter",
380 ));
381 }
382 seen_caps = true;
383 let value = nv.value.clone();
384 match value {
385 Expr::Array(arr) => {
386 for elem in arr.elems {
387 match elem {
388 Expr::Path(ref path) => {
389 if let Some(ident) = path.path.get_ident() {
390 caps.push(Capability::from_ident(ident)?);
391 } else {
392 return Err(syn::Error::new_spanned(
393 path,
394 "capability must be a simple identifier (db, rest, rest_host, stateful)",
395 ));
396 }
397 }
398 Expr::Lit(syn::ExprLit {
399 lit: Lit::Str(s), ..
400 }) => {
401 caps.push(Capability::from_str_lit(&s)?);
402 }
403 other => {
404 return Err(syn::Error::new_spanned(
405 other,
406 "capability must be an identifier or string literal (\"db\", \"rest\", \"rest_host\", \"stateful\")",
407 ));
408 }
409 }
410 }
411 }
412 other => {
413 return Err(syn::Error::new_spanned(
414 other,
415 "capabilities must be an array, e.g. capabilities = [db, rest]",
416 ));
417 }
418 }
419 }
420 Meta::List(list) if path_last_is(&list.path, "lifecycle") => {
422 if seen_lifecycle {
423 return Err(syn::Error::new_spanned(
424 list.path,
425 "duplicate `lifecycle(...)` parameter",
426 ));
427 }
428 seen_lifecycle = true;
429 lifecycle = Some(parse_lifecycle_list(&list)?);
430 }
431 other => {
432 return Err(syn::Error::new_spanned(
433 other,
434 "unknown attribute parameter",
435 ));
436 }
437 }
438 }
439
440 let name = name.ok_or_else(|| {
441 syn::Error::new(
442 Span::call_site(),
443 "name parameter is required, e.g. #[gear(name = \"my-gear\", ...)]",
444 )
445 })?;
446
447 Ok(GearConfig {
448 name,
449 deps,
450 caps,
451 ctor,
452 client,
453 lifecycle,
454 })
455 }
456}
457
458fn parse_lifecycle_list(list: &MetaList) -> syn::Result<LcGearCfg> {
459 let mut cfg = LcGearCfg::default();
460
461 let inner: Punctuated<Meta, Token![,]> =
462 list.parse_args_with(Punctuated::<Meta, Token![,]>::parse_terminated)?;
463
464 for m in inner {
465 match m {
466 Meta::NameValue(MetaNameValue { path, value, .. }) if path.is_ident("entry") => {
467 if let Expr::Lit(syn::ExprLit {
468 lit: Lit::Str(s), ..
469 }) = value
470 {
471 cfg.entry = s.value();
472 } else {
473 return Err(syn::Error::new_spanned(
474 value,
475 "entry must be a string literal, e.g. entry = \"serve\"",
476 ));
477 }
478 }
479 Meta::NameValue(MetaNameValue { path, value, .. }) if path.is_ident("stop_timeout") => {
480 if let Expr::Lit(syn::ExprLit {
481 lit: Lit::Str(s), ..
482 }) = value
483 {
484 cfg.stop_timeout = Some(s.value());
485 } else {
486 return Err(syn::Error::new_spanned(
487 value,
488 "stop_timeout must be a string literal like \"45s\"",
489 ));
490 }
491 }
492 Meta::NameValue(MetaNameValue { path, value, .. })
493 if path.is_ident("stop_timeout_fn") =>
494 {
495 if let Expr::Lit(syn::ExprLit {
496 lit: Lit::Str(s), ..
497 }) = value
498 {
499 cfg.stop_timeout_fn = Some(s.parse()?);
500 } else {
501 return Err(syn::Error::new_spanned(
502 value,
503 "stop_timeout_fn must name a method in a string literal",
504 ));
505 }
506 }
507 Meta::Path(p) if p.is_ident("await_ready") => {
508 cfg.await_ready = true;
509 }
510 Meta::NameValue(MetaNameValue { path, value, .. }) if path.is_ident("await_ready") => {
511 if let Expr::Lit(syn::ExprLit {
512 lit: Lit::Bool(LitBool { value: b, .. }),
513 ..
514 }) = value
515 {
516 cfg.await_ready = b;
517 } else {
518 return Err(syn::Error::new_spanned(
519 value,
520 "await_ready must be a bool literal (true/false) or a bare flag",
521 ));
522 }
523 }
524 other => {
525 return Err(syn::Error::new_spanned(
526 other,
527 "expected lifecycle args: entry=\"...\", stop_timeout=\"...\" or stop_timeout_fn=\"...\", await_ready[=true|false]",
528 ));
529 }
530 }
531 }
532
533 lifecycle_timeout_tokens(cfg.stop_timeout.as_deref(), cfg.stop_timeout_fn.as_ref())?;
534 Ok(cfg)
535}
536
537#[proc_macro_attribute]
558#[allow(clippy::too_many_lines)]
559pub fn gear(attr: TokenStream, item: TokenStream) -> TokenStream {
560 let config = parse_macro_input!(attr as GearConfig);
561 let input = parse_macro_input!(item as DeriveInput);
562
563 let struct_ident = input.ident.clone();
565 let generics_clone = input.generics.clone();
566 let (impl_generics, ty_generics, where_clause) = generics_clone.split_for_impl();
567
568 let name_owned: String = config.name.clone();
569 let deps_idents: Vec<Ident> = config.deps.clone();
570 let caps_for_asserts: Vec<Capability> = config.caps.clone();
571 let caps_for_regs: Vec<Capability> = config.caps.clone();
572 let ctor_expr_opt: Option<Expr> = config.ctor.clone();
573 let client_trait_opt: Option<Path> = config.client.clone();
574 let lifecycle_cfg_opt: Option<LcGearCfg> = config.lifecycle;
575
576 let name_lit = LitStr::new(&name_owned, Span::call_site());
578 let deps_lits: Vec<LitStr> = deps_idents
580 .iter()
581 .map(|ident| LitStr::new(&ident.to_string().replace('_', "-"), Span::call_site()))
582 .collect();
583
584 let constructor = if let Some(expr) = &ctor_expr_opt {
586 quote! { #expr }
587 } else {
588 quote! { <#struct_ident #ty_generics as ::core::default::Default>::default() }
590 };
591
592 let mut cap_asserts = Vec::new();
594
595 cap_asserts.push(quote! {
597 const _: () = {
598 #[allow(dead_code)]
599 fn __toolkit_require_Gear_impl()
600 where
601 #struct_ident #ty_generics: ::toolkit::contracts::Gear,
602 {}
603 };
604 });
605
606 for cap in &caps_for_asserts {
607 let q = match cap {
608 Capability::Db => quote! {
609 const _: () = {
610 #[allow(dead_code)]
611 fn __toolkit_require_DatabaseCapability_impl()
612 where
613 #struct_ident #ty_generics: ::toolkit::contracts::DatabaseCapability,
614 {}
615 };
616 },
617 Capability::Rest => quote! {
618 const _: () = {
619 #[allow(dead_code)]
620 fn __toolkit_require_RestApiCapability_impl()
621 where
622 #struct_ident #ty_generics: ::toolkit::contracts::RestApiCapability,
623 {}
624 };
625 },
626 Capability::RestHost => quote! {
627 const _: () = {
628 #[allow(dead_code)]
629 fn __toolkit_require_ApiGatewayCapability_impl()
630 where
631 #struct_ident #ty_generics: ::toolkit::contracts::ApiGatewayCapability,
632 {}
633 };
634 },
635 Capability::Stateful => {
636 if lifecycle_cfg_opt.is_none() {
637 quote! {
639 const _: () = {
640 #[allow(dead_code)]
641 fn __toolkit_require_RunnableCapability_impl()
642 where
643 #struct_ident #ty_generics: ::toolkit::contracts::RunnableCapability,
644 {}
645 };
646 }
647 } else {
648 quote! {}
649 }
650 }
651 Capability::System => {
652 quote! {}
654 }
655 Capability::GrpcHub => quote! {
656 const _: () = {
657 #[allow(dead_code)]
658 fn __toolkit_require_GrpcHubCapability_impl()
659 where
660 #struct_ident #ty_generics: ::toolkit::contracts::GrpcHubCapability,
661 {}
662 };
663 },
664 Capability::Grpc => quote! {
665 const _: () = {
666 #[allow(dead_code)]
667 fn __toolkit_require_GrpcServiceCapability_impl()
668 where
669 #struct_ident #ty_generics: ::toolkit::contracts::GrpcServiceCapability,
670 {}
671 };
672 },
673 };
674 cap_asserts.push(q);
675 }
676
677 let struct_name_snake = struct_ident.to_string().to_snake_case();
679 let registrator_name = format_ident!("__{}_registrator", struct_name_snake);
680
681 let mut extra_top_level = proc_macro2::TokenStream::new();
683
684 if let Some(lc) = &lifecycle_cfg_opt {
685 let entry_ident = format_ident!("{}", lc.entry);
687 let timeout_config =
688 lifecycle_timeout_tokens(lc.stop_timeout.as_deref(), lc.stop_timeout_fn.as_ref())
689 .unwrap_or_else(|e| e.to_compile_error());
690 let await_ready_bool = lc.await_ready;
691
692 if await_ready_bool {
693 let ready_shim_ident =
694 format_ident!("__toolkit_run_ready_shim_for_{}", struct_name_snake);
695
696 extra_top_level.extend(quote! {
698 #[::async_trait::async_trait]
699 impl #impl_generics ::toolkit::lifecycle::Runnable for #struct_ident #ty_generics #where_clause {
700 async fn run(self: ::std::sync::Arc<Self>, cancel: ::tokio_util::sync::CancellationToken) -> ::anyhow::Result<()> {
701 let (_tx, _rx) = ::toolkit::tokio::sync::oneshot::channel::<()>();
702 let ready = ::toolkit::lifecycle::ReadySignal::from_sender(_tx);
703 self.#entry_ident(cancel, ready).await
704 }
705 }
706
707 #[doc(hidden)]
708 #[allow(dead_code, non_snake_case)]
709 fn #ready_shim_ident(
710 this: ::std::sync::Arc<#struct_ident #ty_generics>,
711 cancel: ::tokio_util::sync::CancellationToken,
712 ready: ::toolkit::lifecycle::ReadySignal,
713 ) -> ::core::pin::Pin<Box<dyn ::core::future::Future<Output = ::anyhow::Result<()>> + Send>> {
714 Box::pin(async move { this.#entry_ident(cancel, ready).await })
715 }
716 });
717
718 extra_top_level.extend(quote! {
720 impl #impl_generics #struct_ident #ty_generics #where_clause {
721 pub fn into_gear(self) -> ::toolkit::lifecycle::WithLifecycle<Self> {
723 ::toolkit::lifecycle::WithLifecycle::new_with_name(self, #name_lit)
724 #timeout_config
725 .with_ready_mode(true, true, Some(#ready_shim_ident))
726 }
727 }
728 });
729 } else {
730 extra_top_level.extend(quote! {
732 #[::async_trait::async_trait]
733 impl #impl_generics ::toolkit::lifecycle::Runnable for #struct_ident #ty_generics #where_clause {
734 async fn run(self: ::std::sync::Arc<Self>, cancel: ::tokio_util::sync::CancellationToken) -> ::anyhow::Result<()> {
735 self.#entry_ident(cancel).await
736 }
737 }
738
739 impl #impl_generics #struct_ident #ty_generics #where_clause {
740 pub fn into_gear(self) -> ::toolkit::lifecycle::WithLifecycle<Self> {
742 ::toolkit::lifecycle::WithLifecycle::new_with_name(self, #name_lit)
743 #timeout_config
744 .with_ready_mode(false, false, None)
745 }
746 }
747 });
748 }
749 }
750
751 let capability_registrations = caps_for_regs.iter().map(|cap| {
753 match cap {
754 Capability::Db => quote! {
755 b.register_db_with_meta(#name_lit,
756 gear.clone() as ::std::sync::Arc<dyn ::toolkit::contracts::DatabaseCapability>);
757 },
758 Capability::Rest => quote! {
759 b.register_rest_with_meta(#name_lit,
760 gear.clone() as ::std::sync::Arc<dyn ::toolkit::contracts::RestApiCapability>);
761 },
762 Capability::RestHost => quote! {
763 b.register_rest_host_with_meta(#name_lit,
764 gear.clone() as ::std::sync::Arc<dyn ::toolkit::contracts::ApiGatewayCapability>);
765 },
766 Capability::Stateful => {
767 if let Some(lc) = &lifecycle_cfg_opt {
768 let timeout_config = lifecycle_timeout_tokens(lc.stop_timeout.as_deref(), lc.stop_timeout_fn.as_ref())
769 .unwrap_or_else(|e| e.to_compile_error());
770 let await_ready_bool = lc.await_ready;
771 let ready_shim_ident =
772 format_ident!("__toolkit_run_ready_shim_for_{}", struct_name_snake);
773
774 if await_ready_bool {
775 quote! {
776 let wl = ::toolkit::lifecycle::WithLifecycle::from_arc_with_name(
777 gear.clone(),
778 #name_lit,
779 )
780 #timeout_config
781 .with_ready_mode(true, true, Some(#ready_shim_ident));
782
783 b.register_stateful_with_meta(
784 #name_lit,
785 ::std::sync::Arc::new(wl) as ::std::sync::Arc<dyn ::toolkit::contracts::RunnableCapability>
786 );
787 }
788 } else {
789 quote! {
790 let wl = ::toolkit::lifecycle::WithLifecycle::from_arc_with_name(
791 gear.clone(),
792 #name_lit,
793 )
794 #timeout_config
795 .with_ready_mode(false, false, None);
796
797 b.register_stateful_with_meta(
798 #name_lit,
799 ::std::sync::Arc::new(wl) as ::std::sync::Arc<dyn ::toolkit::contracts::RunnableCapability>
800 );
801 }
802 }
803 } else {
804 quote! {
806 b.register_stateful_with_meta(#name_lit,
807 gear.clone() as ::std::sync::Arc<dyn ::toolkit::contracts::RunnableCapability>);
808 }
809 }
810 },
811 Capability::System => quote! {
812 b.register_system_with_meta(#name_lit,
813 gear.clone() as ::std::sync::Arc<dyn ::toolkit::contracts::SystemCapability>);
814 },
815 Capability::GrpcHub => quote! {
816 b.register_grpc_hub_with_meta(#name_lit,
817 gear.clone() as ::std::sync::Arc<dyn ::toolkit::contracts::GrpcHubCapability>);
818 },
819 Capability::Grpc => quote! {
820 b.register_grpc_service_with_meta(#name_lit,
821 gear.clone() as ::std::sync::Arc<dyn ::toolkit::contracts::GrpcServiceCapability>);
822 },
823 }
824 });
825
826 let client_code = if let Some(client_trait_path) = &client_trait_opt {
831 quote! {
832 const _: () = {
834 fn __toolkit_obj_safety<T: ?Sized + ::core::marker::Send + ::core::marker::Sync + 'static>() {}
835 let _ = __toolkit_obj_safety::<dyn #client_trait_path> as fn();
836 };
837
838 impl #impl_generics #struct_ident #ty_generics #where_clause {
839 pub const MODULE_NAME: &'static str = #name_lit;
840 }
841 }
842 } else {
843 quote! {
845 impl #impl_generics #struct_ident #ty_generics #where_clause {
846 pub const MODULE_NAME: &'static str = #name_lit;
847 }
848 }
849 };
850
851 let dep_reexports: Vec<_> = deps_idents
854 .iter()
855 .map(|crate_ident| {
856 let alias_ident = format_ident!("_gear_dep_{}", crate_ident);
857 quote! {
858 #[cfg(not(test))]
859 #[doc(hidden)]
860 pub use ::#crate_ident as #alias_ident;
861 }
862 })
863 .collect();
864
865 let expanded = quote! {
867 #input
868
869 #(#cap_asserts)*
871
872 #(#dep_reexports)*
874
875 #[doc(hidden)]
877 fn #registrator_name(b: &mut ::toolkit::registry::RegistryBuilder) {
878 use ::std::sync::Arc;
879
880 let gear: Arc<#struct_ident #ty_generics> = Arc::new(#constructor);
881
882 b.register_core_with_meta(
884 #name_lit,
885 &[#(#deps_lits),*],
886 gear.clone() as Arc<dyn ::toolkit::contracts::Gear>
887 );
888
889 #(#capability_registrations)*
891 }
892
893 ::toolkit::inventory::submit! {
894 ::toolkit::registry::Registrator(#registrator_name)
895 }
896
897 #client_code
898
899 #extra_top_level
901 };
902
903 TokenStream::from(expanded)
904}
905
906#[derive(Debug)]
911struct LcCfg {
912 method: String,
913 stop_timeout: Option<String>,
914 stop_timeout_fn: Option<Ident>,
915 await_ready: bool,
916}
917
918#[proc_macro_attribute]
919pub fn lifecycle(attr: TokenStream, item: TokenStream) -> TokenStream {
920 let args = parse_macro_input!(attr with Punctuated::<Meta, Token![,]>::parse_terminated);
921 let impl_item = parse_macro_input!(item as ItemImpl);
922
923 let cfg = match parse_lifecycle_args(args) {
924 Ok(c) => c,
925 Err(e) => return e.to_compile_error().into(),
926 };
927
928 let ty = match &*impl_item.self_ty {
930 syn::Type::Path(TypePath { path, .. }) => path.clone(),
931 other => {
932 return syn::Error::new_spanned(other, "unsupported impl target")
933 .to_compile_error()
934 .into();
935 }
936 };
937
938 let runner_ident = format_ident!("{}", cfg.method);
939 let mut has_runner = false;
940 let mut takes_ready_signal = false;
941 for it in &impl_item.items {
942 if let ImplItem::Fn(f) = it
943 && f.sig.ident == runner_ident
944 {
945 has_runner = true;
946 if f.sig.asyncness.is_none() {
947 return syn::Error::new_spanned(f.sig.fn_token, "runner must be async")
948 .to_compile_error()
949 .into();
950 }
951 let input_count = f.sig.inputs.len();
952 match input_count {
953 2 => {}
954 3 => {
955 if let Some(syn::FnArg::Typed(pat_ty)) = f.sig.inputs.iter().nth(2) {
956 match &*pat_ty.ty {
957 syn::Type::Path(tp) => {
958 if let Some(seg) = tp.path.segments.last() {
959 if seg.ident == "ReadySignal" {
960 takes_ready_signal = true;
961 } else {
962 return syn::Error::new_spanned(
963 &pat_ty.ty,
964 "third parameter must be ReadySignal when await_ready=true",
965 )
966 .to_compile_error()
967 .into();
968 }
969 }
970 }
971 other => {
972 return syn::Error::new_spanned(
973 other,
974 "third parameter must be ReadySignal when await_ready=true",
975 )
976 .to_compile_error()
977 .into();
978 }
979 }
980 }
981 }
982 _ => {
983 return syn::Error::new_spanned(
984 f.sig.inputs.clone(),
985 "invalid runner signature; expected (&self, CancellationToken) or (&self, CancellationToken, ReadySignal)",
986 )
987 .to_compile_error()
988 .into();
989 }
990 }
991 }
992 }
993 if !has_runner {
994 return syn::Error::new(
995 Span::call_site(),
996 format!("runner method `{}` not found in impl", cfg.method),
997 )
998 .to_compile_error()
999 .into();
1000 }
1001
1002 let timeout_config =
1004 match lifecycle_timeout_tokens(cfg.stop_timeout.as_deref(), cfg.stop_timeout_fn.as_ref()) {
1005 Ok(ts) => ts,
1006 Err(e) => return e.to_compile_error().into(),
1007 };
1008
1009 let ty_ident = match ty.segments.last() {
1011 Some(seg) => seg.ident.clone(),
1012 None => {
1013 return syn::Error::new_spanned(
1014 &ty,
1015 "unsupported impl target: expected a concrete type path",
1016 )
1017 .to_compile_error()
1018 .into();
1019 }
1020 };
1021 let ty_snake = ty_ident.to_string().to_snake_case();
1022
1023 let ready_shim_ident = format_ident!("__toolkit_run_ready_shim{ty_snake}");
1024 let await_ready_bool = cfg.await_ready;
1025
1026 let extra = if takes_ready_signal {
1027 quote! {
1028 #[async_trait::async_trait]
1029 impl ::toolkit::lifecycle::Runnable for #ty {
1030 async fn run(self: ::std::sync::Arc<Self>, cancel: ::tokio_util::sync::CancellationToken) -> ::anyhow::Result<()> {
1031 let (_tx, _rx) = ::toolkit::tokio::sync::oneshot::channel::<()>();
1032 let ready = ::toolkit::lifecycle::ReadySignal::from_sender(_tx);
1033 self.#runner_ident(cancel, ready).await
1034 }
1035 }
1036
1037 #[doc(hidden)]
1038 #[allow(non_snake_case, dead_code)]
1039 fn #ready_shim_ident(
1040 this: ::std::sync::Arc<#ty>,
1041 cancel: ::tokio_util::sync::CancellationToken,
1042 ready: ::toolkit::lifecycle::ReadySignal,
1043 ) -> ::core::pin::Pin<Box<dyn ::core::future::Future<Output = ::anyhow::Result<()>> + Send>> {
1044 Box::pin(async move { this.#runner_ident(cancel, ready).await })
1045 }
1046
1047 impl #ty {
1048 pub fn into_gear(self) -> ::toolkit::lifecycle::WithLifecycle<Self> {
1050 ::toolkit::lifecycle::WithLifecycle::new(self)
1051 #timeout_config
1052 .with_ready_mode(#await_ready_bool, true, Some(#ready_shim_ident))
1053 }
1054 }
1055 }
1056 } else {
1057 quote! {
1058 #[async_trait::async_trait]
1059 impl ::toolkit::lifecycle::Runnable for #ty {
1060 async fn run(self: ::std::sync::Arc<Self>, cancel: ::tokio_util::sync::CancellationToken) -> ::anyhow::Result<()> {
1061 self.#runner_ident(cancel).await
1062 }
1063 }
1064
1065 impl #ty {
1066 pub fn into_gear(self) -> ::toolkit::lifecycle::WithLifecycle<Self> {
1068 ::toolkit::lifecycle::WithLifecycle::new(self)
1069 #timeout_config
1070 .with_ready_mode(#await_ready_bool, false, None)
1071 }
1072 }
1073 }
1074 };
1075
1076 let out = quote! {
1077 #impl_item
1078 #extra
1079 };
1080 out.into()
1081}
1082
1083fn parse_lifecycle_args(args: Punctuated<Meta, Token![,]>) -> syn::Result<LcCfg> {
1084 let mut method: Option<String> = None;
1085 let mut stop_timeout = None;
1086 let mut stop_timeout_fn = None;
1087 let mut await_ready = false;
1088
1089 for m in args {
1090 match m {
1091 Meta::NameValue(nv) if nv.path.is_ident("method") => {
1092 if let Expr::Lit(el) = nv.value {
1093 if let Lit::Str(s) = el.lit {
1094 method = Some(s.value());
1095 } else {
1096 return Err(syn::Error::new_spanned(
1097 el,
1098 "method must be a string literal",
1099 ));
1100 }
1101 } else {
1102 return Err(syn::Error::new_spanned(
1103 nv,
1104 "method must be a string literal",
1105 ));
1106 }
1107 }
1108 Meta::NameValue(nv) if nv.path.is_ident("stop_timeout") => {
1109 if let Expr::Lit(el) = nv.value {
1110 if let Lit::Str(s) = el.lit {
1111 stop_timeout = Some(s.value());
1112 } else {
1113 return Err(syn::Error::new_spanned(
1114 el,
1115 "stop_timeout must be a string literal like \"45s\"",
1116 ));
1117 }
1118 } else {
1119 return Err(syn::Error::new_spanned(
1120 nv,
1121 "stop_timeout must be a string literal like \"45s\"",
1122 ));
1123 }
1124 }
1125 Meta::NameValue(nv) if nv.path.is_ident("stop_timeout_fn") => {
1126 if let Expr::Lit(syn::ExprLit {
1127 lit: Lit::Str(s), ..
1128 }) = nv.value
1129 {
1130 stop_timeout_fn = Some(s.parse()?);
1131 } else {
1132 return Err(syn::Error::new_spanned(
1133 nv,
1134 "stop_timeout_fn must name a method in a string literal",
1135 ));
1136 }
1137 }
1138 Meta::NameValue(nv) if nv.path.is_ident("await_ready") => {
1139 if let Expr::Lit(el) = nv.value {
1140 if let Lit::Bool(b) = el.lit {
1141 await_ready = b.value();
1142 } else {
1143 return Err(syn::Error::new_spanned(
1144 el,
1145 "await_ready must be a bool literal (true/false)",
1146 ));
1147 }
1148 } else {
1149 return Err(syn::Error::new_spanned(
1150 nv,
1151 "await_ready must be a bool literal (true/false)",
1152 ));
1153 }
1154 }
1155 Meta::Path(p) if p.is_ident("await_ready") => {
1156 await_ready = true;
1157 }
1158 other => {
1159 return Err(syn::Error::new_spanned(
1160 other,
1161 "expected named args: method=\"...\", stop_timeout=\"...\" or stop_timeout_fn=\"...\", await_ready=true|false",
1162 ));
1163 }
1164 }
1165 }
1166
1167 let method = method.ok_or_else(|| {
1168 syn::Error::new(
1169 Span::call_site(),
1170 "missing required arg: method=\"runner_name\"",
1171 )
1172 })?;
1173 lifecycle_timeout_tokens(stop_timeout.as_deref(), stop_timeout_fn.as_ref())?;
1174 Ok(LcCfg {
1175 method,
1176 stop_timeout,
1177 stop_timeout_fn,
1178 await_ready,
1179 })
1180}
1181
1182fn lifecycle_timeout_tokens(
1183 fixed: Option<&str>,
1184 provider: Option<&Ident>,
1185) -> syn::Result<proc_macro2::TokenStream> {
1186 match (fixed, provider) {
1187 (Some(_), Some(_)) => Err(syn::Error::new(
1188 Span::call_site(),
1189 "stop_timeout and stop_timeout_fn are mutually exclusive",
1190 )),
1191 (None, Some(method)) => Ok(quote! { .with_stop_timeout_fn(|gear| gear.#method()) }),
1192 (fixed, None) => {
1193 let duration = parse_duration_tokens(fixed.unwrap_or("30s"))?;
1194 Ok(quote! { .with_stop_timeout(#duration) })
1195 }
1196 }
1197}
1198
1199fn parse_duration_tokens(s: &str) -> syn::Result<proc_macro2::TokenStream> {
1200 let err = || {
1201 syn::Error::new(
1202 Span::call_site(),
1203 format!("invalid duration: {s}. Use e.g. \"500ms\", \"45s\", \"2m\", \"1h\""),
1204 )
1205 };
1206 if let Some(stripped) = s.strip_suffix("ms") {
1207 let v: u64 = stripped.parse().map_err(|_| err())?;
1208 Ok(quote! { ::std::time::Duration::from_millis(#v) })
1209 } else if let Some(stripped) = s.strip_suffix('s') {
1210 let v: u64 = stripped.parse().map_err(|_| err())?;
1211 Ok(quote! { ::std::time::Duration::from_secs(#v) })
1212 } else if let Some(stripped) = s.strip_suffix('m') {
1213 let v: u64 = stripped.parse().map_err(|_| err())?;
1214 Ok(quote! { ::std::time::Duration::from_secs(#v * 60) })
1215 } else if let Some(stripped) = s.strip_suffix('h') {
1216 let v: u64 = stripped.parse().map_err(|_| err())?;
1217 Ok(quote! { ::std::time::Duration::from_secs(#v * 3600) })
1218 } else {
1219 Err(err())
1220 }
1221}
1222
1223fn path_last_is(path: &syn::Path, want: &str) -> bool {
1224 path.segments.last().is_some_and(|s| s.ident == want)
1225}
1226
1227#[proc_macro_attribute]
1257pub fn grpc_client(attr: TokenStream, item: TokenStream) -> TokenStream {
1258 let config = parse_macro_input!(attr as grpc_client::GrpcClientConfig);
1259 let input = parse_macro_input!(item as DeriveInput);
1260
1261 match grpc_client::expand_grpc_client(config, input) {
1262 Ok(expanded) => TokenStream::from(expanded),
1263 Err(e) => TokenStream::from(e.to_compile_error()),
1264 }
1265}
1266
1267#[proc_macro_attribute]
1320pub fn api_dto(attr: TokenStream, item: TokenStream) -> TokenStream {
1321 let attrs = parse_macro_input!(attr with Punctuated::<Ident, Token![,]>::parse_terminated);
1322 let input = parse_macro_input!(item as DeriveInput);
1323 TokenStream::from(api_dto::expand_api_dto(&attrs, &input))
1324}
1325
1326#[proc_macro_attribute]
1369pub fn domain_model(_attr: TokenStream, item: TokenStream) -> TokenStream {
1370 let input = parse_macro_input!(item as DeriveInput);
1371 TokenStream::from(domain_model::expand_domain_model(&input))
1372}
1373
1374#[proc_macro_attribute]
1398pub fn temporary(attr: TokenStream, item: TokenStream) -> TokenStream {
1399 match temporary::expand_temporary(attr.into(), &item.into()) {
1400 Ok(tokens) => TokenStream::from(tokens),
1401 Err(err) => TokenStream::from(err.to_compile_error()),
1402 }
1403}
1404
1405#[proc_macro_derive(ExpandVars, attributes(expand_vars))]
1422pub fn derive_expand_vars(input: TokenStream) -> TokenStream {
1423 let input = parse_macro_input!(input as DeriveInput);
1424 TokenStream::from(expand_vars::derive(&input))
1425}
1426
1427#[cfg(test)]
1428mod lifecycle_timeout_tests {
1429 use super::*;
1430 use syn::parse::Parser;
1431
1432 #[test]
1433 fn rejects_fixed_and_computed_timeouts_in_both_macros() {
1434 let list: MetaList = syn::parse_quote!(lifecycle(
1435 stop_timeout = "30s",
1436 stop_timeout_fn = "shutdown_timeout"
1437 ));
1438 assert!(
1439 parse_lifecycle_list(&list)
1440 .unwrap_err()
1441 .to_string()
1442 .contains("mutually exclusive")
1443 );
1444 let args = Punctuated::<Meta, Token![,]>::parse_terminated.parse2(quote!(
1445 method = "serve",
1446 stop_timeout_fn = "shutdown_timeout",
1447 stop_timeout = "30s"
1448 ));
1449 assert!(
1450 parse_lifecycle_args(args.unwrap())
1451 .unwrap_err()
1452 .to_string()
1453 .contains("mutually exclusive")
1454 );
1455 }
1456}