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;
13mod domain_model;
14mod expand_vars;
15mod grpc_client;
16mod utils;
17
18struct GearConfig {
20 name: String,
21 deps: Vec<Ident>,
22 caps: Vec<Capability>,
23 ctor: Option<Expr>, client: Option<Path>, lifecycle: Option<LcGearCfg>, }
27
28#[derive(Debug, PartialEq, Clone)]
29enum Capability {
30 Db,
31 Rest,
32 RestHost,
33 Stateful,
34 System,
35 GrpcHub,
36 Grpc,
37}
38
39impl Capability {
40 const VALID_CAPABILITIES: &'static [&'static str] = &[
41 "db",
42 "rest",
43 "rest_host",
44 "stateful",
45 "system",
46 "grpc_hub",
47 "grpc",
48 ];
49
50 fn suggest_similar(input: &str) -> Vec<&'static str> {
51 let mut suggestions: Vec<(&str, f64)> = Self::VALID_CAPABILITIES
52 .iter()
53 .map(|&cap| (cap, strsim::jaro_winkler(input, cap)))
54 .filter(|(_, score)| *score > 0.6) .collect();
56
57 suggestions.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
58 suggestions
59 .into_iter()
60 .take(2)
61 .map(|(cap, _)| cap)
62 .collect()
63 }
64
65 fn from_ident(ident: &Ident) -> syn::Result<Self> {
66 let input = ident.to_string();
67 match input.as_str() {
68 "db" => Ok(Capability::Db),
69 "rest" => Ok(Capability::Rest),
70 "rest_host" => Ok(Capability::RestHost),
71 "stateful" => Ok(Capability::Stateful),
72 "system" => Ok(Capability::System),
73 "grpc_hub" => Ok(Capability::GrpcHub),
74 "grpc" => Ok(Capability::Grpc),
75 other => {
76 let suggestions = Self::suggest_similar(other);
77 let error_msg = if suggestions.is_empty() {
78 format!(
79 "unknown capability '{other}', expected one of: db, rest, rest_host, stateful, system, grpc_hub, grpc"
80 )
81 } else {
82 format!(
83 "unknown capability '{other}'\n = help: did you mean one of: {}?",
84 suggestions.join(", ")
85 )
86 };
87 Err(syn::Error::new_spanned(ident, error_msg))
88 }
89 }
90 }
91
92 fn from_str_lit(lit: &LitStr) -> syn::Result<Self> {
93 let input = lit.value();
94 match input.as_str() {
95 "db" => Ok(Capability::Db),
96 "rest" => Ok(Capability::Rest),
97 "rest_host" => Ok(Capability::RestHost),
98 "stateful" => Ok(Capability::Stateful),
99 "system" => Ok(Capability::System),
100 "grpc_hub" => Ok(Capability::GrpcHub),
101 "grpc" => Ok(Capability::Grpc),
102 other => {
103 let suggestions = Self::suggest_similar(other);
104 let error_msg = if suggestions.is_empty() {
105 format!(
106 "unknown capability '{other}', expected one of: db, rest, rest_host, stateful, system, grpc_hub, grpc"
107 )
108 } else {
109 format!(
110 "unknown capability '{other}'\n = help: did you mean one of: {}?",
111 suggestions.join(", ")
112 )
113 };
114 Err(syn::Error::new_spanned(lit, error_msg))
115 }
116 }
117 }
118}
119
120fn validate_kebab_case(name: &str) -> Result<(), String> {
133 if name.is_empty() {
134 return Err("gear name cannot be empty".to_owned());
135 }
136
137 if name.contains('_') {
139 let suggested = name.replace('_', "-");
140 return Err(format!(
141 "gear name must use kebab-case, not snake_case\n = help: use '{suggested}' instead of '{name}'"
142 ));
143 }
144
145 if let Some(first_char) = name.chars().next() {
147 if !first_char.is_ascii_lowercase() {
148 return Err(format!(
149 "gear name must start with a lowercase letter, found '{first_char}'"
150 ));
151 }
152 } else {
153 return Err("gear name cannot be empty".to_owned());
155 }
156
157 if name.ends_with('-') {
159 return Err("gear name must not end with a hyphen".to_owned());
160 }
161
162 let mut prev_was_hyphen = false;
164 for ch in name.chars() {
165 if ch == '-' {
166 if prev_was_hyphen {
167 return Err("gear name must not contain consecutive hyphens".to_owned());
168 }
169 prev_was_hyphen = true;
170 } else if ch.is_ascii_lowercase() || ch.is_ascii_digit() {
171 prev_was_hyphen = false;
172 } else {
173 return Err(format!(
174 "gear name must contain only lowercase letters, digits, and hyphens, found '{ch}'"
175 ));
176 }
177 }
178
179 Ok(())
180}
181
182#[derive(Debug, Clone)]
183struct LcGearCfg {
184 entry: String, stop_timeout: String, await_ready: bool, }
188
189impl Default for LcGearCfg {
190 fn default() -> Self {
191 Self {
192 entry: "serve".to_owned(),
193 stop_timeout: "30s".to_owned(),
194 await_ready: false,
195 }
196 }
197}
198
199impl Parse for GearConfig {
200 #[allow(clippy::too_many_lines)]
201 fn parse(input: ParseStream) -> syn::Result<Self> {
202 let mut name: Option<String> = None;
203 let mut deps: Vec<Ident> = Vec::new();
204 let mut caps: Vec<Capability> = Vec::new();
205 let mut ctor: Option<Expr> = None;
206 let mut client: Option<Path> = None;
207 let mut lifecycle: Option<LcGearCfg> = None;
208
209 let mut seen_name = false;
210 let mut seen_deps = false;
211 let mut seen_caps = false;
212 let mut seen_ctor = false;
213 let mut seen_client = false;
214 let mut seen_lifecycle = false;
215
216 let punctuated: Punctuated<Meta, Token![,]> =
217 input.parse_terminated(Meta::parse, Token![,])?;
218
219 for meta in punctuated {
220 match meta {
221 Meta::NameValue(nv) if nv.path.is_ident("name") => {
222 if seen_name {
223 return Err(syn::Error::new_spanned(
224 nv.path,
225 "duplicate `name` parameter",
226 ));
227 }
228 seen_name = true;
229 match nv.value {
230 Expr::Lit(syn::ExprLit {
231 lit: Lit::Str(s), ..
232 }) => {
233 let gear_name = s.value();
234 if let Err(err) = validate_kebab_case(&gear_name) {
236 return Err(syn::Error::new_spanned(s, err));
237 }
238 name = Some(gear_name);
239 }
240 other => {
241 return Err(syn::Error::new_spanned(
242 other,
243 "name must be a string literal, e.g. name = \"my-gear\"",
244 ));
245 }
246 }
247 }
248 Meta::NameValue(nv) if nv.path.is_ident("ctor") => {
249 if seen_ctor {
250 return Err(syn::Error::new_spanned(
251 nv.path,
252 "duplicate `ctor` parameter",
253 ));
254 }
255 seen_ctor = true;
256
257 match &nv.value {
259 Expr::Lit(syn::ExprLit {
260 lit: Lit::Str(s), ..
261 }) => {
262 return Err(syn::Error::new_spanned(
263 s,
264 "ctor must be a Rust expression, not a string literal. \
265 Use: ctor = MyType::new() (with parentheses), \
266 or: ctor = Default::default()",
267 ));
268 }
269 _ => {
270 ctor = Some(nv.value.clone());
271 }
272 }
273 }
274 Meta::NameValue(nv) if nv.path.is_ident("client") => {
275 if seen_client {
276 return Err(syn::Error::new_spanned(
277 nv.path,
278 "duplicate `client` parameter",
279 ));
280 }
281 seen_client = true;
282 let value = nv.value.clone();
283 match value {
284 Expr::Path(ep) => {
285 client = Some(ep.path);
286 }
287 other => {
288 return Err(syn::Error::new_spanned(
289 other,
290 "client must be a trait path, e.g. client = crate::api::MyClient",
291 ));
292 }
293 }
294 }
295 Meta::NameValue(nv) if nv.path.is_ident("deps") => {
296 if seen_deps {
297 return Err(syn::Error::new_spanned(
298 nv.path,
299 "duplicate `deps` parameter",
300 ));
301 }
302 seen_deps = true;
303 let value = nv.value.clone();
304 match value {
305 Expr::Array(arr) => {
306 for elem in arr.elems {
307 match elem {
308 Expr::Path(ref path) => {
309 if let Some(ident) = path.path.get_ident() {
310 deps.push(ident.clone());
311 } else {
312 return Err(syn::Error::new_spanned(
313 path,
314 "deps must be crate identifiers, e.g. deps = [authn_resolver, types_registry]",
315 ));
316 }
317 }
318 other => {
319 return Err(syn::Error::new_spanned(
320 other,
321 "deps must be crate identifiers, e.g. deps = [authn_resolver, types_registry]",
322 ));
323 }
324 }
325 }
326 }
327 other => {
328 return Err(syn::Error::new_spanned(
329 other,
330 "deps must be an array, e.g. deps = [authn_resolver, types_registry]",
331 ));
332 }
333 }
334 }
335 Meta::NameValue(nv) if nv.path.is_ident("capabilities") => {
336 if seen_caps {
337 return Err(syn::Error::new_spanned(
338 nv.path,
339 "duplicate `capabilities` parameter",
340 ));
341 }
342 seen_caps = 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 caps.push(Capability::from_ident(ident)?);
351 } else {
352 return Err(syn::Error::new_spanned(
353 path,
354 "capability must be a simple identifier (db, rest, rest_host, stateful)",
355 ));
356 }
357 }
358 Expr::Lit(syn::ExprLit {
359 lit: Lit::Str(s), ..
360 }) => {
361 caps.push(Capability::from_str_lit(&s)?);
362 }
363 other => {
364 return Err(syn::Error::new_spanned(
365 other,
366 "capability must be an identifier or string literal (\"db\", \"rest\", \"rest_host\", \"stateful\")",
367 ));
368 }
369 }
370 }
371 }
372 other => {
373 return Err(syn::Error::new_spanned(
374 other,
375 "capabilities must be an array, e.g. capabilities = [db, rest]",
376 ));
377 }
378 }
379 }
380 Meta::List(list) if path_last_is(&list.path, "lifecycle") => {
382 if seen_lifecycle {
383 return Err(syn::Error::new_spanned(
384 list.path,
385 "duplicate `lifecycle(...)` parameter",
386 ));
387 }
388 seen_lifecycle = true;
389 lifecycle = Some(parse_lifecycle_list(&list)?);
390 }
391 other => {
392 return Err(syn::Error::new_spanned(
393 other,
394 "unknown attribute parameter",
395 ));
396 }
397 }
398 }
399
400 let name = name.ok_or_else(|| {
401 syn::Error::new(
402 Span::call_site(),
403 "name parameter is required, e.g. #[gear(name = \"my-gear\", ...)]",
404 )
405 })?;
406
407 Ok(GearConfig {
408 name,
409 deps,
410 caps,
411 ctor,
412 client,
413 lifecycle,
414 })
415 }
416}
417
418fn parse_lifecycle_list(list: &MetaList) -> syn::Result<LcGearCfg> {
419 let mut cfg = LcGearCfg::default();
420
421 let inner: Punctuated<Meta, Token![,]> =
422 list.parse_args_with(Punctuated::<Meta, Token![,]>::parse_terminated)?;
423
424 for m in inner {
425 match m {
426 Meta::NameValue(MetaNameValue { path, value, .. }) if path.is_ident("entry") => {
427 if let Expr::Lit(syn::ExprLit {
428 lit: Lit::Str(s), ..
429 }) = value
430 {
431 cfg.entry = s.value();
432 } else {
433 return Err(syn::Error::new_spanned(
434 value,
435 "entry must be a string literal, e.g. entry = \"serve\"",
436 ));
437 }
438 }
439 Meta::NameValue(MetaNameValue { path, value, .. }) if path.is_ident("stop_timeout") => {
440 if let Expr::Lit(syn::ExprLit {
441 lit: Lit::Str(s), ..
442 }) = value
443 {
444 cfg.stop_timeout = s.value();
445 } else {
446 return Err(syn::Error::new_spanned(
447 value,
448 "stop_timeout must be a string literal like \"45s\"",
449 ));
450 }
451 }
452 Meta::Path(p) if p.is_ident("await_ready") => {
453 cfg.await_ready = true;
454 }
455 Meta::NameValue(MetaNameValue { path, value, .. }) if path.is_ident("await_ready") => {
456 if let Expr::Lit(syn::ExprLit {
457 lit: Lit::Bool(LitBool { value: b, .. }),
458 ..
459 }) = value
460 {
461 cfg.await_ready = b;
462 } else {
463 return Err(syn::Error::new_spanned(
464 value,
465 "await_ready must be a bool literal (true/false) or a bare flag",
466 ));
467 }
468 }
469 other => {
470 return Err(syn::Error::new_spanned(
471 other,
472 "expected lifecycle args: entry=\"...\", stop_timeout=\"...\", await_ready[=true|false]",
473 ));
474 }
475 }
476 }
477
478 Ok(cfg)
479}
480
481#[proc_macro_attribute]
502#[allow(clippy::too_many_lines)]
503pub fn gear(attr: TokenStream, item: TokenStream) -> TokenStream {
504 let config = parse_macro_input!(attr as GearConfig);
505 let input = parse_macro_input!(item as DeriveInput);
506
507 let struct_ident = input.ident.clone();
509 let generics_clone = input.generics.clone();
510 let (impl_generics, ty_generics, where_clause) = generics_clone.split_for_impl();
511
512 let name_owned: String = config.name.clone();
513 let deps_idents: Vec<Ident> = config.deps.clone();
514 let caps_for_asserts: Vec<Capability> = config.caps.clone();
515 let caps_for_regs: Vec<Capability> = config.caps.clone();
516 let ctor_expr_opt: Option<Expr> = config.ctor.clone();
517 let client_trait_opt: Option<Path> = config.client.clone();
518 let lifecycle_cfg_opt: Option<LcGearCfg> = config.lifecycle;
519
520 let name_lit = LitStr::new(&name_owned, Span::call_site());
522 let deps_lits: Vec<LitStr> = deps_idents
524 .iter()
525 .map(|ident| LitStr::new(&ident.to_string().replace('_', "-"), Span::call_site()))
526 .collect();
527
528 let constructor = if let Some(expr) = &ctor_expr_opt {
530 quote! { #expr }
531 } else {
532 quote! { <#struct_ident #ty_generics as ::core::default::Default>::default() }
534 };
535
536 let mut cap_asserts = Vec::new();
538
539 cap_asserts.push(quote! {
541 const _: () = {
542 #[allow(dead_code)]
543 fn __toolkit_require_Gear_impl()
544 where
545 #struct_ident #ty_generics: ::toolkit::contracts::Gear,
546 {}
547 };
548 });
549
550 for cap in &caps_for_asserts {
551 let q = match cap {
552 Capability::Db => quote! {
553 const _: () = {
554 #[allow(dead_code)]
555 fn __toolkit_require_DatabaseCapability_impl()
556 where
557 #struct_ident #ty_generics: ::toolkit::contracts::DatabaseCapability,
558 {}
559 };
560 },
561 Capability::Rest => quote! {
562 const _: () = {
563 #[allow(dead_code)]
564 fn __toolkit_require_RestApiCapability_impl()
565 where
566 #struct_ident #ty_generics: ::toolkit::contracts::RestApiCapability,
567 {}
568 };
569 },
570 Capability::RestHost => quote! {
571 const _: () = {
572 #[allow(dead_code)]
573 fn __toolkit_require_ApiGatewayCapability_impl()
574 where
575 #struct_ident #ty_generics: ::toolkit::contracts::ApiGatewayCapability,
576 {}
577 };
578 },
579 Capability::Stateful => {
580 if lifecycle_cfg_opt.is_none() {
581 quote! {
583 const _: () = {
584 #[allow(dead_code)]
585 fn __toolkit_require_RunnableCapability_impl()
586 where
587 #struct_ident #ty_generics: ::toolkit::contracts::RunnableCapability,
588 {}
589 };
590 }
591 } else {
592 quote! {}
593 }
594 }
595 Capability::System => {
596 quote! {}
598 }
599 Capability::GrpcHub => quote! {
600 const _: () = {
601 #[allow(dead_code)]
602 fn __toolkit_require_GrpcHubCapability_impl()
603 where
604 #struct_ident #ty_generics: ::toolkit::contracts::GrpcHubCapability,
605 {}
606 };
607 },
608 Capability::Grpc => quote! {
609 const _: () = {
610 #[allow(dead_code)]
611 fn __toolkit_require_GrpcServiceCapability_impl()
612 where
613 #struct_ident #ty_generics: ::toolkit::contracts::GrpcServiceCapability,
614 {}
615 };
616 },
617 };
618 cap_asserts.push(q);
619 }
620
621 let struct_name_snake = struct_ident.to_string().to_snake_case();
623 let registrator_name = format_ident!("__{}_registrator", struct_name_snake);
624
625 let mut extra_top_level = proc_macro2::TokenStream::new();
627
628 if let Some(lc) = &lifecycle_cfg_opt {
629 let entry_ident = format_ident!("{}", lc.entry);
631 let timeout_ts =
632 parse_duration_tokens(&lc.stop_timeout).unwrap_or_else(|e| e.to_compile_error());
633 let await_ready_bool = lc.await_ready;
634
635 if await_ready_bool {
636 let ready_shim_ident =
637 format_ident!("__toolkit_run_ready_shim_for_{}", struct_name_snake);
638
639 extra_top_level.extend(quote! {
641 #[::async_trait::async_trait]
642 impl #impl_generics ::toolkit::lifecycle::Runnable for #struct_ident #ty_generics #where_clause {
643 async fn run(self: ::std::sync::Arc<Self>, cancel: ::tokio_util::sync::CancellationToken) -> ::anyhow::Result<()> {
644 let (_tx, _rx) = ::toolkit::tokio::sync::oneshot::channel::<()>();
645 let ready = ::toolkit::lifecycle::ReadySignal::from_sender(_tx);
646 self.#entry_ident(cancel, ready).await
647 }
648 }
649
650 #[doc(hidden)]
651 #[allow(dead_code, non_snake_case)]
652 fn #ready_shim_ident(
653 this: ::std::sync::Arc<#struct_ident #ty_generics>,
654 cancel: ::tokio_util::sync::CancellationToken,
655 ready: ::toolkit::lifecycle::ReadySignal,
656 ) -> ::core::pin::Pin<Box<dyn ::core::future::Future<Output = ::anyhow::Result<()>> + Send>> {
657 Box::pin(async move { this.#entry_ident(cancel, ready).await })
658 }
659 });
660
661 extra_top_level.extend(quote! {
663 impl #impl_generics #struct_ident #ty_generics #where_clause {
664 pub fn into_gear(self) -> ::toolkit::lifecycle::WithLifecycle<Self> {
666 ::toolkit::lifecycle::WithLifecycle::new_with_name(self, #name_lit)
667 .with_stop_timeout(#timeout_ts)
668 .with_ready_mode(true, true, Some(#ready_shim_ident))
669 }
670 }
671 });
672 } else {
673 extra_top_level.extend(quote! {
675 #[::async_trait::async_trait]
676 impl #impl_generics ::toolkit::lifecycle::Runnable for #struct_ident #ty_generics #where_clause {
677 async fn run(self: ::std::sync::Arc<Self>, cancel: ::tokio_util::sync::CancellationToken) -> ::anyhow::Result<()> {
678 self.#entry_ident(cancel).await
679 }
680 }
681
682 impl #impl_generics #struct_ident #ty_generics #where_clause {
683 pub fn into_gear(self) -> ::toolkit::lifecycle::WithLifecycle<Self> {
685 ::toolkit::lifecycle::WithLifecycle::new_with_name(self, #name_lit)
686 .with_stop_timeout(#timeout_ts)
687 .with_ready_mode(false, false, None)
688 }
689 }
690 });
691 }
692 }
693
694 let capability_registrations = caps_for_regs.iter().map(|cap| {
696 match cap {
697 Capability::Db => quote! {
698 b.register_db_with_meta(#name_lit,
699 gear.clone() as ::std::sync::Arc<dyn ::toolkit::contracts::DatabaseCapability>);
700 },
701 Capability::Rest => quote! {
702 b.register_rest_with_meta(#name_lit,
703 gear.clone() as ::std::sync::Arc<dyn ::toolkit::contracts::RestApiCapability>);
704 },
705 Capability::RestHost => quote! {
706 b.register_rest_host_with_meta(#name_lit,
707 gear.clone() as ::std::sync::Arc<dyn ::toolkit::contracts::ApiGatewayCapability>);
708 },
709 Capability::Stateful => {
710 if let Some(lc) = &lifecycle_cfg_opt {
711 let timeout_ts = parse_duration_tokens(&lc.stop_timeout)
712 .unwrap_or_else(|e| e.to_compile_error());
713 let await_ready_bool = lc.await_ready;
714 let ready_shim_ident =
715 format_ident!("__toolkit_run_ready_shim_for_{}", struct_name_snake);
716
717 if await_ready_bool {
718 quote! {
719 let wl = ::toolkit::lifecycle::WithLifecycle::from_arc_with_name(
720 gear.clone(),
721 #name_lit,
722 )
723 .with_stop_timeout(#timeout_ts)
724 .with_ready_mode(true, true, Some(#ready_shim_ident));
725
726 b.register_stateful_with_meta(
727 #name_lit,
728 ::std::sync::Arc::new(wl) as ::std::sync::Arc<dyn ::toolkit::contracts::RunnableCapability>
729 );
730 }
731 } else {
732 quote! {
733 let wl = ::toolkit::lifecycle::WithLifecycle::from_arc_with_name(
734 gear.clone(),
735 #name_lit,
736 )
737 .with_stop_timeout(#timeout_ts)
738 .with_ready_mode(false, false, None);
739
740 b.register_stateful_with_meta(
741 #name_lit,
742 ::std::sync::Arc::new(wl) as ::std::sync::Arc<dyn ::toolkit::contracts::RunnableCapability>
743 );
744 }
745 }
746 } else {
747 quote! {
749 b.register_stateful_with_meta(#name_lit,
750 gear.clone() as ::std::sync::Arc<dyn ::toolkit::contracts::RunnableCapability>);
751 }
752 }
753 },
754 Capability::System => quote! {
755 b.register_system_with_meta(#name_lit,
756 gear.clone() as ::std::sync::Arc<dyn ::toolkit::contracts::SystemCapability>);
757 },
758 Capability::GrpcHub => quote! {
759 b.register_grpc_hub_with_meta(#name_lit,
760 gear.clone() as ::std::sync::Arc<dyn ::toolkit::contracts::GrpcHubCapability>);
761 },
762 Capability::Grpc => quote! {
763 b.register_grpc_service_with_meta(#name_lit,
764 gear.clone() as ::std::sync::Arc<dyn ::toolkit::contracts::GrpcServiceCapability>);
765 },
766 }
767 });
768
769 let client_code = if let Some(client_trait_path) = &client_trait_opt {
774 quote! {
775 const _: () = {
777 fn __toolkit_obj_safety<T: ?Sized + ::core::marker::Send + ::core::marker::Sync + 'static>() {}
778 let _ = __toolkit_obj_safety::<dyn #client_trait_path> as fn();
779 };
780
781 impl #impl_generics #struct_ident #ty_generics #where_clause {
782 pub const MODULE_NAME: &'static str = #name_lit;
783 }
784 }
785 } else {
786 quote! {
788 impl #impl_generics #struct_ident #ty_generics #where_clause {
789 pub const MODULE_NAME: &'static str = #name_lit;
790 }
791 }
792 };
793
794 let dep_reexports: Vec<_> = deps_idents
797 .iter()
798 .map(|crate_ident| {
799 let alias_ident = format_ident!("_gear_dep_{}", crate_ident);
800 quote! {
801 #[cfg(not(test))]
802 #[doc(hidden)]
803 pub use ::#crate_ident as #alias_ident;
804 }
805 })
806 .collect();
807
808 let expanded = quote! {
810 #input
811
812 #(#cap_asserts)*
814
815 #(#dep_reexports)*
817
818 #[doc(hidden)]
820 fn #registrator_name(b: &mut ::toolkit::registry::RegistryBuilder) {
821 use ::std::sync::Arc;
822
823 let gear: Arc<#struct_ident #ty_generics> = Arc::new(#constructor);
824
825 b.register_core_with_meta(
827 #name_lit,
828 &[#(#deps_lits),*],
829 gear.clone() as Arc<dyn ::toolkit::contracts::Gear>
830 );
831
832 #(#capability_registrations)*
834 }
835
836 ::toolkit::inventory::submit! {
837 ::toolkit::registry::Registrator(#registrator_name)
838 }
839
840 #client_code
841
842 #extra_top_level
844 };
845
846 TokenStream::from(expanded)
847}
848
849#[derive(Debug)]
854struct LcCfg {
855 method: String,
856 stop_timeout: String,
857 await_ready: bool,
858}
859
860#[proc_macro_attribute]
861pub fn lifecycle(attr: TokenStream, item: TokenStream) -> TokenStream {
862 let args = parse_macro_input!(attr with Punctuated::<Meta, Token![,]>::parse_terminated);
863 let impl_item = parse_macro_input!(item as ItemImpl);
864
865 let cfg = match parse_lifecycle_args(args) {
866 Ok(c) => c,
867 Err(e) => return e.to_compile_error().into(),
868 };
869
870 let ty = match &*impl_item.self_ty {
872 syn::Type::Path(TypePath { path, .. }) => path.clone(),
873 other => {
874 return syn::Error::new_spanned(other, "unsupported impl target")
875 .to_compile_error()
876 .into();
877 }
878 };
879
880 let runner_ident = format_ident!("{}", cfg.method);
881 let mut has_runner = false;
882 let mut takes_ready_signal = false;
883 for it in &impl_item.items {
884 if let ImplItem::Fn(f) = it
885 && f.sig.ident == runner_ident
886 {
887 has_runner = true;
888 if f.sig.asyncness.is_none() {
889 return syn::Error::new_spanned(f.sig.fn_token, "runner must be async")
890 .to_compile_error()
891 .into();
892 }
893 let input_count = f.sig.inputs.len();
894 match input_count {
895 2 => {}
896 3 => {
897 if let Some(syn::FnArg::Typed(pat_ty)) = f.sig.inputs.iter().nth(2) {
898 match &*pat_ty.ty {
899 syn::Type::Path(tp) => {
900 if let Some(seg) = tp.path.segments.last() {
901 if seg.ident == "ReadySignal" {
902 takes_ready_signal = true;
903 } else {
904 return syn::Error::new_spanned(
905 &pat_ty.ty,
906 "third parameter must be ReadySignal when await_ready=true",
907 )
908 .to_compile_error()
909 .into();
910 }
911 }
912 }
913 other => {
914 return syn::Error::new_spanned(
915 other,
916 "third parameter must be ReadySignal when await_ready=true",
917 )
918 .to_compile_error()
919 .into();
920 }
921 }
922 }
923 }
924 _ => {
925 return syn::Error::new_spanned(
926 f.sig.inputs.clone(),
927 "invalid runner signature; expected (&self, CancellationToken) or (&self, CancellationToken, ReadySignal)",
928 )
929 .to_compile_error()
930 .into();
931 }
932 }
933 }
934 }
935 if !has_runner {
936 return syn::Error::new(
937 Span::call_site(),
938 format!("runner method `{}` not found in impl", cfg.method),
939 )
940 .to_compile_error()
941 .into();
942 }
943
944 let timeout_ts = match parse_duration_tokens(&cfg.stop_timeout) {
946 Ok(ts) => ts,
947 Err(e) => return e.to_compile_error().into(),
948 };
949
950 let ty_ident = match ty.segments.last() {
952 Some(seg) => seg.ident.clone(),
953 None => {
954 return syn::Error::new_spanned(
955 &ty,
956 "unsupported impl target: expected a concrete type path",
957 )
958 .to_compile_error()
959 .into();
960 }
961 };
962 let ty_snake = ty_ident.to_string().to_snake_case();
963
964 let ready_shim_ident = format_ident!("__toolkit_run_ready_shim{ty_snake}");
965 let await_ready_bool = cfg.await_ready;
966
967 let extra = if takes_ready_signal {
968 quote! {
969 #[async_trait::async_trait]
970 impl ::toolkit::lifecycle::Runnable for #ty {
971 async fn run(self: ::std::sync::Arc<Self>, cancel: ::tokio_util::sync::CancellationToken) -> ::anyhow::Result<()> {
972 let (_tx, _rx) = ::toolkit::tokio::sync::oneshot::channel::<()>();
973 let ready = ::toolkit::lifecycle::ReadySignal::from_sender(_tx);
974 self.#runner_ident(cancel, ready).await
975 }
976 }
977
978 #[doc(hidden)]
979 #[allow(non_snake_case, dead_code)]
980 fn #ready_shim_ident(
981 this: ::std::sync::Arc<#ty>,
982 cancel: ::tokio_util::sync::CancellationToken,
983 ready: ::toolkit::lifecycle::ReadySignal,
984 ) -> ::core::pin::Pin<Box<dyn ::core::future::Future<Output = ::anyhow::Result<()>> + Send>> {
985 Box::pin(async move { this.#runner_ident(cancel, ready).await })
986 }
987
988 impl #ty {
989 pub fn into_gear(self) -> ::toolkit::lifecycle::WithLifecycle<Self> {
991 ::toolkit::lifecycle::WithLifecycle::new(self)
992 .with_stop_timeout(#timeout_ts)
993 .with_ready_mode(#await_ready_bool, true, Some(#ready_shim_ident))
994 }
995 }
996 }
997 } else {
998 quote! {
999 #[async_trait::async_trait]
1000 impl ::toolkit::lifecycle::Runnable for #ty {
1001 async fn run(self: ::std::sync::Arc<Self>, cancel: ::tokio_util::sync::CancellationToken) -> ::anyhow::Result<()> {
1002 self.#runner_ident(cancel).await
1003 }
1004 }
1005
1006 impl #ty {
1007 pub fn into_gear(self) -> ::toolkit::lifecycle::WithLifecycle<Self> {
1009 ::toolkit::lifecycle::WithLifecycle::new(self)
1010 .with_stop_timeout(#timeout_ts)
1011 .with_ready_mode(#await_ready_bool, false, None)
1012 }
1013 }
1014 }
1015 };
1016
1017 let out = quote! {
1018 #impl_item
1019 #extra
1020 };
1021 out.into()
1022}
1023
1024fn parse_lifecycle_args(args: Punctuated<Meta, Token![,]>) -> syn::Result<LcCfg> {
1025 let mut method: Option<String> = None;
1026 let mut stop_timeout = "30s".to_owned();
1027 let mut await_ready = false;
1028
1029 for m in args {
1030 match m {
1031 Meta::NameValue(nv) if nv.path.is_ident("method") => {
1032 if let Expr::Lit(el) = nv.value {
1033 if let Lit::Str(s) = el.lit {
1034 method = Some(s.value());
1035 } else {
1036 return Err(syn::Error::new_spanned(
1037 el,
1038 "method must be a string literal",
1039 ));
1040 }
1041 } else {
1042 return Err(syn::Error::new_spanned(
1043 nv,
1044 "method must be a string literal",
1045 ));
1046 }
1047 }
1048 Meta::NameValue(nv) if nv.path.is_ident("stop_timeout") => {
1049 if let Expr::Lit(el) = nv.value {
1050 if let Lit::Str(s) = el.lit {
1051 stop_timeout = s.value();
1052 } else {
1053 return Err(syn::Error::new_spanned(
1054 el,
1055 "stop_timeout must be a string literal like \"45s\"",
1056 ));
1057 }
1058 } else {
1059 return Err(syn::Error::new_spanned(
1060 nv,
1061 "stop_timeout must be a string literal like \"45s\"",
1062 ));
1063 }
1064 }
1065 Meta::NameValue(nv) if nv.path.is_ident("await_ready") => {
1066 if let Expr::Lit(el) = nv.value {
1067 if let Lit::Bool(b) = el.lit {
1068 await_ready = b.value();
1069 } else {
1070 return Err(syn::Error::new_spanned(
1071 el,
1072 "await_ready must be a bool literal (true/false)",
1073 ));
1074 }
1075 } else {
1076 return Err(syn::Error::new_spanned(
1077 nv,
1078 "await_ready must be a bool literal (true/false)",
1079 ));
1080 }
1081 }
1082 Meta::Path(p) if p.is_ident("await_ready") => {
1083 await_ready = true;
1084 }
1085 other => {
1086 return Err(syn::Error::new_spanned(
1087 other,
1088 "expected named args: method=\"...\", stop_timeout=\"...\", await_ready=true|false",
1089 ));
1090 }
1091 }
1092 }
1093
1094 let method = method.ok_or_else(|| {
1095 syn::Error::new(
1096 Span::call_site(),
1097 "missing required arg: method=\"runner_name\"",
1098 )
1099 })?;
1100 Ok(LcCfg {
1101 method,
1102 stop_timeout,
1103 await_ready,
1104 })
1105}
1106
1107fn parse_duration_tokens(s: &str) -> syn::Result<proc_macro2::TokenStream> {
1108 let err = || {
1109 syn::Error::new(
1110 Span::call_site(),
1111 format!("invalid duration: {s}. Use e.g. \"500ms\", \"45s\", \"2m\", \"1h\""),
1112 )
1113 };
1114 if let Some(stripped) = s.strip_suffix("ms") {
1115 let v: u64 = stripped.parse().map_err(|_| err())?;
1116 Ok(quote! { ::std::time::Duration::from_millis(#v) })
1117 } else if let Some(stripped) = s.strip_suffix('s') {
1118 let v: u64 = stripped.parse().map_err(|_| err())?;
1119 Ok(quote! { ::std::time::Duration::from_secs(#v) })
1120 } else if let Some(stripped) = s.strip_suffix('m') {
1121 let v: u64 = stripped.parse().map_err(|_| err())?;
1122 Ok(quote! { ::std::time::Duration::from_secs(#v * 60) })
1123 } else if let Some(stripped) = s.strip_suffix('h') {
1124 let v: u64 = stripped.parse().map_err(|_| err())?;
1125 Ok(quote! { ::std::time::Duration::from_secs(#v * 3600) })
1126 } else {
1127 Err(err())
1128 }
1129}
1130
1131fn path_last_is(path: &syn::Path, want: &str) -> bool {
1132 path.segments.last().is_some_and(|s| s.ident == want)
1133}
1134
1135#[proc_macro_attribute]
1165pub fn grpc_client(attr: TokenStream, item: TokenStream) -> TokenStream {
1166 let config = parse_macro_input!(attr as grpc_client::GrpcClientConfig);
1167 let input = parse_macro_input!(item as DeriveInput);
1168
1169 match grpc_client::expand_grpc_client(config, input) {
1170 Ok(expanded) => TokenStream::from(expanded),
1171 Err(e) => TokenStream::from(e.to_compile_error()),
1172 }
1173}
1174
1175#[proc_macro_attribute]
1228pub fn api_dto(attr: TokenStream, item: TokenStream) -> TokenStream {
1229 let attrs = parse_macro_input!(attr with Punctuated::<Ident, Token![,]>::parse_terminated);
1230 let input = parse_macro_input!(item as DeriveInput);
1231 TokenStream::from(api_dto::expand_api_dto(&attrs, &input))
1232}
1233
1234#[proc_macro_attribute]
1277pub fn domain_model(_attr: TokenStream, item: TokenStream) -> TokenStream {
1278 let input = parse_macro_input!(item as DeriveInput);
1279 TokenStream::from(domain_model::expand_domain_model(&input))
1280}
1281
1282#[proc_macro_derive(ExpandVars, attributes(expand_vars))]
1299pub fn derive_expand_vars(input: TokenStream) -> TokenStream {
1300 let input = parse_macro_input!(input as DeriveInput);
1301 TokenStream::from(expand_vars::derive(&input))
1302}