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 grpc_client;
14mod utils;
15
16struct ModuleConfig {
18 name: String,
19 deps: Vec<String>,
20 caps: Vec<Capability>,
21 ctor: Option<Expr>, client: Option<Path>, lifecycle: Option<LcModuleCfg>, }
25
26#[derive(Debug, PartialEq, Clone)]
27enum Capability {
28 Db,
29 Rest,
30 RestHost,
31 Stateful,
32 System,
33 GrpcHub,
34 Grpc,
35}
36
37impl Capability {
38 const VALID_CAPABILITIES: &'static [&'static str] = &[
39 "db",
40 "rest",
41 "rest_host",
42 "stateful",
43 "system",
44 "grpc_hub",
45 "grpc",
46 ];
47
48 fn suggest_similar(input: &str) -> Vec<&'static str> {
49 let mut suggestions: Vec<(&str, f64)> = Self::VALID_CAPABILITIES
50 .iter()
51 .map(|&cap| (cap, strsim::jaro_winkler(input, cap)))
52 .filter(|(_, score)| *score > 0.6) .collect();
54
55 suggestions.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
56 suggestions
57 .into_iter()
58 .take(2)
59 .map(|(cap, _)| cap)
60 .collect()
61 }
62
63 fn from_ident(ident: &Ident) -> syn::Result<Self> {
64 let input = ident.to_string();
65 match input.as_str() {
66 "db" => Ok(Capability::Db),
67 "rest" => Ok(Capability::Rest),
68 "rest_host" => Ok(Capability::RestHost),
69 "stateful" => Ok(Capability::Stateful),
70 "system" => Ok(Capability::System),
71 "grpc_hub" => Ok(Capability::GrpcHub),
72 "grpc" => Ok(Capability::Grpc),
73 other => {
74 let suggestions = Self::suggest_similar(other);
75 let error_msg = if suggestions.is_empty() {
76 format!(
77 "unknown capability '{other}', expected one of: db, rest, rest_host, stateful, system, grpc_hub, grpc"
78 )
79 } else {
80 format!(
81 "unknown capability '{other}'\n = help: did you mean one of: {}?",
82 suggestions.join(", ")
83 )
84 };
85 Err(syn::Error::new_spanned(ident, error_msg))
86 }
87 }
88 }
89
90 fn from_str_lit(lit: &LitStr) -> syn::Result<Self> {
91 let input = lit.value();
92 match input.as_str() {
93 "db" => Ok(Capability::Db),
94 "rest" => Ok(Capability::Rest),
95 "rest_host" => Ok(Capability::RestHost),
96 "stateful" => Ok(Capability::Stateful),
97 "system" => Ok(Capability::System),
98 "grpc_hub" => Ok(Capability::GrpcHub),
99 "grpc" => Ok(Capability::Grpc),
100 other => {
101 let suggestions = Self::suggest_similar(other);
102 let error_msg = if suggestions.is_empty() {
103 format!(
104 "unknown capability '{other}', expected one of: db, rest, rest_host, stateful, system, grpc_hub, grpc"
105 )
106 } else {
107 format!(
108 "unknown capability '{other}'\n = help: did you mean one of: {}?",
109 suggestions.join(", ")
110 )
111 };
112 Err(syn::Error::new_spanned(lit, error_msg))
113 }
114 }
115 }
116}
117
118#[derive(Debug, Clone)]
119struct LcModuleCfg {
120 entry: String, stop_timeout: String, await_ready: bool, }
124
125impl Default for LcModuleCfg {
126 fn default() -> Self {
127 Self {
128 entry: "serve".to_owned(),
129 stop_timeout: "30s".to_owned(),
130 await_ready: false,
131 }
132 }
133}
134
135impl Parse for ModuleConfig {
136 fn parse(input: ParseStream) -> syn::Result<Self> {
137 let mut name: Option<String> = None;
138 let mut deps: Vec<String> = Vec::new();
139 let mut caps: Vec<Capability> = Vec::new();
140 let mut ctor: Option<Expr> = None;
141 let mut client: Option<Path> = None;
142 let mut lifecycle: Option<LcModuleCfg> = None;
143
144 let mut seen_name = false;
145 let mut seen_deps = false;
146 let mut seen_caps = false;
147 let mut seen_ctor = false;
148 let mut seen_client = false;
149 let mut seen_lifecycle = false;
150
151 let punctuated: Punctuated<Meta, Token![,]> =
152 input.parse_terminated(Meta::parse, Token![,])?;
153
154 for meta in punctuated {
155 match meta {
156 Meta::NameValue(nv) if nv.path.is_ident("name") => {
157 if seen_name {
158 return Err(syn::Error::new_spanned(
159 nv.path,
160 "duplicate `name` parameter",
161 ));
162 }
163 seen_name = true;
164 match nv.value {
165 Expr::Lit(syn::ExprLit {
166 lit: Lit::Str(s), ..
167 }) => {
168 name = Some(s.value());
169 }
170 other => {
171 return Err(syn::Error::new_spanned(
172 other,
173 "name must be a string literal, e.g. name = \"my-module\"",
174 ));
175 }
176 }
177 }
178 Meta::NameValue(nv) if nv.path.is_ident("ctor") => {
179 if seen_ctor {
180 return Err(syn::Error::new_spanned(
181 nv.path,
182 "duplicate `ctor` parameter",
183 ));
184 }
185 seen_ctor = true;
186
187 match &nv.value {
189 Expr::Lit(syn::ExprLit {
190 lit: Lit::Str(s), ..
191 }) => {
192 return Err(syn::Error::new_spanned(
193 s,
194 "ctor must be a Rust expression, not a string literal. \
195 Use: ctor = MyType::new() (with parentheses), \
196 or: ctor = Default::default()",
197 ));
198 }
199 _ => {
200 ctor = Some(nv.value.clone());
201 }
202 }
203 }
204 Meta::NameValue(nv) if nv.path.is_ident("client") => {
205 if seen_client {
206 return Err(syn::Error::new_spanned(
207 nv.path,
208 "duplicate `client` parameter",
209 ));
210 }
211 seen_client = true;
212 let value = nv.value.clone();
213 match value {
214 Expr::Path(ep) => {
215 client = Some(ep.path);
216 }
217 other => {
218 return Err(syn::Error::new_spanned(
219 other,
220 "client must be a trait path, e.g. client = crate::api::MyClient",
221 ));
222 }
223 }
224 }
225 Meta::NameValue(nv) if nv.path.is_ident("deps") => {
226 if seen_deps {
227 return Err(syn::Error::new_spanned(
228 nv.path,
229 "duplicate `deps` parameter",
230 ));
231 }
232 seen_deps = true;
233 let value = nv.value.clone();
234 match value {
235 Expr::Array(arr) => {
236 for elem in arr.elems {
237 match elem {
238 Expr::Lit(syn::ExprLit {
239 lit: Lit::Str(s), ..
240 }) => {
241 deps.push(s.value());
242 }
243 other => {
244 return Err(syn::Error::new_spanned(
245 other,
246 "deps must be an array of string literals, e.g. deps = [\"db\", \"auth\"]",
247 ));
248 }
249 }
250 }
251 }
252 other => {
253 return Err(syn::Error::new_spanned(
254 other,
255 "deps must be an array, e.g. deps = [\"db\", \"auth\"]",
256 ));
257 }
258 }
259 }
260 Meta::NameValue(nv) if nv.path.is_ident("capabilities") => {
261 if seen_caps {
262 return Err(syn::Error::new_spanned(
263 nv.path,
264 "duplicate `capabilities` parameter",
265 ));
266 }
267 seen_caps = true;
268 let value = nv.value.clone();
269 match value {
270 Expr::Array(arr) => {
271 for elem in arr.elems {
272 match elem {
273 Expr::Path(ref path) => {
274 if let Some(ident) = path.path.get_ident() {
275 caps.push(Capability::from_ident(ident)?);
276 } else {
277 return Err(syn::Error::new_spanned(
278 path,
279 "capability must be a simple identifier (db, rest, rest_host, stateful)",
280 ));
281 }
282 }
283 Expr::Lit(syn::ExprLit {
284 lit: Lit::Str(s), ..
285 }) => {
286 caps.push(Capability::from_str_lit(&s)?);
287 }
288 other => {
289 return Err(syn::Error::new_spanned(
290 other,
291 "capability must be an identifier or string literal (\"db\", \"rest\", \"rest_host\", \"stateful\")",
292 ));
293 }
294 }
295 }
296 }
297 other => {
298 return Err(syn::Error::new_spanned(
299 other,
300 "capabilities must be an array, e.g. capabilities = [db, rest]",
301 ));
302 }
303 }
304 }
305 Meta::List(list) if path_last_is(&list.path, "lifecycle") => {
307 if seen_lifecycle {
308 return Err(syn::Error::new_spanned(
309 list.path,
310 "duplicate `lifecycle(...)` parameter",
311 ));
312 }
313 seen_lifecycle = true;
314 lifecycle = Some(parse_lifecycle_list(&list)?);
315 }
316 other => {
317 return Err(syn::Error::new_spanned(
318 other,
319 "unknown attribute parameter",
320 ));
321 }
322 }
323 }
324
325 let name = name.ok_or_else(|| {
326 syn::Error::new(
327 Span::call_site(),
328 "name parameter is required, e.g. #[module(name = \"my-module\", ...)]",
329 )
330 })?;
331
332 Ok(ModuleConfig {
333 name,
334 deps,
335 caps,
336 ctor,
337 client,
338 lifecycle,
339 })
340 }
341}
342
343fn parse_lifecycle_list(list: &MetaList) -> syn::Result<LcModuleCfg> {
344 let mut cfg = LcModuleCfg::default();
345
346 let inner: Punctuated<Meta, Token![,]> =
347 list.parse_args_with(Punctuated::<Meta, Token![,]>::parse_terminated)?;
348
349 for m in inner {
350 match m {
351 Meta::NameValue(MetaNameValue { path, value, .. }) if path.is_ident("entry") => {
352 if let Expr::Lit(syn::ExprLit {
353 lit: Lit::Str(s), ..
354 }) = value
355 {
356 cfg.entry = s.value();
357 } else {
358 return Err(syn::Error::new_spanned(
359 value,
360 "entry must be a string literal, e.g. entry = \"serve\"",
361 ));
362 }
363 }
364 Meta::NameValue(MetaNameValue { path, value, .. }) if path.is_ident("stop_timeout") => {
365 if let Expr::Lit(syn::ExprLit {
366 lit: Lit::Str(s), ..
367 }) = value
368 {
369 cfg.stop_timeout = s.value();
370 } else {
371 return Err(syn::Error::new_spanned(
372 value,
373 "stop_timeout must be a string literal like \"45s\"",
374 ));
375 }
376 }
377 Meta::Path(p) if p.is_ident("await_ready") => {
378 cfg.await_ready = true;
379 }
380 Meta::NameValue(MetaNameValue { path, value, .. }) if path.is_ident("await_ready") => {
381 if let Expr::Lit(syn::ExprLit {
382 lit: Lit::Bool(LitBool { value: b, .. }),
383 ..
384 }) = value
385 {
386 cfg.await_ready = b;
387 } else {
388 return Err(syn::Error::new_spanned(
389 value,
390 "await_ready must be a bool literal (true/false) or a bare flag",
391 ));
392 }
393 }
394 other => {
395 return Err(syn::Error::new_spanned(
396 other,
397 "expected lifecycle args: entry=\"...\", stop_timeout=\"...\", await_ready[=true|false]",
398 ));
399 }
400 }
401 }
402
403 Ok(cfg)
404}
405
406#[proc_macro_attribute]
411#[allow(clippy::too_many_lines)]
412pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream {
413 let config = parse_macro_input!(attr as ModuleConfig);
414 let input = parse_macro_input!(item as DeriveInput);
415
416 let struct_ident = input.ident.clone();
418 let generics_clone = input.generics.clone();
419 let (impl_generics, ty_generics, where_clause) = generics_clone.split_for_impl();
420
421 let name_owned: String = config.name.clone();
422 let deps_owned: Vec<String> = config.deps.clone();
423 let caps_for_asserts: Vec<Capability> = config.caps.clone();
424 let caps_for_regs: Vec<Capability> = config.caps.clone();
425 let ctor_expr_opt: Option<Expr> = config.ctor.clone();
426 let client_trait_opt: Option<Path> = config.client.clone();
427 let lifecycle_cfg_opt: Option<LcModuleCfg> = config.lifecycle;
428
429 let name_lit = LitStr::new(&name_owned, Span::call_site());
431 let deps_lits: Vec<LitStr> = deps_owned
432 .iter()
433 .map(|s| LitStr::new(s, Span::call_site()))
434 .collect();
435
436 let constructor = if let Some(expr) = &ctor_expr_opt {
438 quote! { #expr }
439 } else {
440 quote! { <#struct_ident #ty_generics as ::core::default::Default>::default() }
442 };
443
444 let mut cap_asserts = Vec::new();
446
447 cap_asserts.push(quote! {
449 const _: () = {
450 #[allow(dead_code)]
451 fn __modkit_require_Module_impl()
452 where
453 #struct_ident #ty_generics: ::modkit::contracts::Module,
454 {}
455 };
456 });
457
458 for cap in &caps_for_asserts {
459 let q = match cap {
460 Capability::Db => quote! {
461 const _: () = {
462 #[allow(dead_code)]
463 fn __modkit_require_DatabaseCapability_impl()
464 where
465 #struct_ident #ty_generics: ::modkit::contracts::DatabaseCapability,
466 {}
467 };
468 },
469 Capability::Rest => quote! {
470 const _: () = {
471 #[allow(dead_code)]
472 fn __modkit_require_RestApiCapability_impl()
473 where
474 #struct_ident #ty_generics: ::modkit::contracts::RestApiCapability,
475 {}
476 };
477 },
478 Capability::RestHost => quote! {
479 const _: () = {
480 #[allow(dead_code)]
481 fn __modkit_require_ApiGatewayCapability_impl()
482 where
483 #struct_ident #ty_generics: ::modkit::contracts::ApiGatewayCapability,
484 {}
485 };
486 },
487 Capability::Stateful => {
488 if lifecycle_cfg_opt.is_none() {
489 quote! {
491 const _: () = {
492 #[allow(dead_code)]
493 fn __modkit_require_RunnableCapability_impl()
494 where
495 #struct_ident #ty_generics: ::modkit::contracts::RunnableCapability,
496 {}
497 };
498 }
499 } else {
500 quote! {}
501 }
502 }
503 Capability::System => {
504 quote! {}
506 }
507 Capability::GrpcHub => quote! {
508 const _: () = {
509 #[allow(dead_code)]
510 fn __modkit_require_GrpcHubCapability_impl()
511 where
512 #struct_ident #ty_generics: ::modkit::contracts::GrpcHubCapability,
513 {}
514 };
515 },
516 Capability::Grpc => quote! {
517 const _: () = {
518 #[allow(dead_code)]
519 fn __modkit_require_GrpcServiceCapability_impl()
520 where
521 #struct_ident #ty_generics: ::modkit::contracts::GrpcServiceCapability,
522 {}
523 };
524 },
525 };
526 cap_asserts.push(q);
527 }
528
529 let struct_name_snake = struct_ident.to_string().to_snake_case();
531 let registrator_name = format_ident!("__{}_registrator", struct_name_snake);
532
533 let mut extra_top_level = proc_macro2::TokenStream::new();
535
536 if let Some(lc) = &lifecycle_cfg_opt {
537 let entry_ident = format_ident!("{}", lc.entry);
539 let timeout_ts =
540 parse_duration_tokens(&lc.stop_timeout).unwrap_or_else(|e| e.to_compile_error());
541 let await_ready_bool = lc.await_ready;
542
543 if await_ready_bool {
544 let ready_shim_ident =
545 format_ident!("__modkit_run_ready_shim_for_{}", struct_name_snake);
546
547 extra_top_level.extend(quote! {
549 #[::async_trait::async_trait]
550 impl #impl_generics ::modkit::lifecycle::Runnable for #struct_ident #ty_generics #where_clause {
551 async fn run(self: ::std::sync::Arc<Self>, cancel: ::tokio_util::sync::CancellationToken) -> ::anyhow::Result<()> {
552 let (_tx, _rx) = ::tokio::sync::oneshot::channel::<()>();
553 let ready = ::modkit::lifecycle::ReadySignal::from_sender(_tx);
554 self.#entry_ident(cancel, ready).await
555 }
556 }
557
558 #[doc(hidden)]
559 #[allow(dead_code, non_snake_case)]
560 fn #ready_shim_ident(
561 this: ::std::sync::Arc<#struct_ident #ty_generics>,
562 cancel: ::tokio_util::sync::CancellationToken,
563 ready: ::modkit::lifecycle::ReadySignal,
564 ) -> ::core::pin::Pin<Box<dyn ::core::future::Future<Output = ::anyhow::Result<()>> + Send>> {
565 Box::pin(async move { this.#entry_ident(cancel, ready).await })
566 }
567 });
568
569 extra_top_level.extend(quote! {
571 impl #impl_generics #struct_ident #ty_generics #where_clause {
572 pub fn into_module(self) -> ::modkit::lifecycle::WithLifecycle<Self> {
574 ::modkit::lifecycle::WithLifecycle::new_with_name(self, #name_lit)
575 .with_stop_timeout(#timeout_ts)
576 .with_ready_mode(true, true, Some(#ready_shim_ident))
577 }
578 }
579 });
580 } else {
581 extra_top_level.extend(quote! {
583 #[::async_trait::async_trait]
584 impl #impl_generics ::modkit::lifecycle::Runnable for #struct_ident #ty_generics #where_clause {
585 async fn run(self: ::std::sync::Arc<Self>, cancel: ::tokio_util::sync::CancellationToken) -> ::anyhow::Result<()> {
586 self.#entry_ident(cancel).await
587 }
588 }
589
590 impl #impl_generics #struct_ident #ty_generics #where_clause {
591 pub fn into_module(self) -> ::modkit::lifecycle::WithLifecycle<Self> {
593 ::modkit::lifecycle::WithLifecycle::new_with_name(self, #name_lit)
594 .with_stop_timeout(#timeout_ts)
595 .with_ready_mode(false, false, None)
596 }
597 }
598 });
599 }
600 }
601
602 let capability_registrations = caps_for_regs.iter().map(|cap| {
604 match cap {
605 Capability::Db => quote! {
606 b.register_db_with_meta(#name_lit,
607 module.clone() as ::std::sync::Arc<dyn ::modkit::contracts::DatabaseCapability>);
608 },
609 Capability::Rest => quote! {
610 b.register_rest_with_meta(#name_lit,
611 module.clone() as ::std::sync::Arc<dyn ::modkit::contracts::RestApiCapability>);
612 },
613 Capability::RestHost => quote! {
614 b.register_rest_host_with_meta(#name_lit,
615 module.clone() as ::std::sync::Arc<dyn ::modkit::contracts::ApiGatewayCapability>);
616 },
617 Capability::Stateful => {
618 if let Some(lc) = &lifecycle_cfg_opt {
619 let timeout_ts = parse_duration_tokens(&lc.stop_timeout)
620 .unwrap_or_else(|e| e.to_compile_error());
621 let await_ready_bool = lc.await_ready;
622 let ready_shim_ident =
623 format_ident!("__modkit_run_ready_shim_for_{}", struct_name_snake);
624
625 if await_ready_bool {
626 quote! {
627 let wl = ::modkit::lifecycle::WithLifecycle::from_arc_with_name(
628 module.clone(),
629 #name_lit,
630 )
631 .with_stop_timeout(#timeout_ts)
632 .with_ready_mode(true, true, Some(#ready_shim_ident));
633
634 b.register_stateful_with_meta(
635 #name_lit,
636 ::std::sync::Arc::new(wl) as ::std::sync::Arc<dyn ::modkit::contracts::RunnableCapability>
637 );
638 }
639 } else {
640 quote! {
641 let wl = ::modkit::lifecycle::WithLifecycle::from_arc_with_name(
642 module.clone(),
643 #name_lit,
644 )
645 .with_stop_timeout(#timeout_ts)
646 .with_ready_mode(false, false, None);
647
648 b.register_stateful_with_meta(
649 #name_lit,
650 ::std::sync::Arc::new(wl) as ::std::sync::Arc<dyn ::modkit::contracts::RunnableCapability>
651 );
652 }
653 }
654 } else {
655 quote! {
657 b.register_stateful_with_meta(#name_lit,
658 module.clone() as ::std::sync::Arc<dyn ::modkit::contracts::RunnableCapability>);
659 }
660 }
661 },
662 Capability::System => quote! {
663 b.register_system_with_meta(#name_lit,
664 module.clone() as ::std::sync::Arc<dyn ::modkit::contracts::SystemCapability>);
665 },
666 Capability::GrpcHub => quote! {
667 b.register_grpc_hub_with_meta(#name_lit,
668 module.clone() as ::std::sync::Arc<dyn ::modkit::contracts::GrpcHubCapability>);
669 },
670 Capability::Grpc => quote! {
671 b.register_grpc_service_with_meta(#name_lit,
672 module.clone() as ::std::sync::Arc<dyn ::modkit::contracts::GrpcServiceCapability>);
673 },
674 }
675 });
676
677 let client_code = if let Some(client_trait_path) = &client_trait_opt {
682 quote! {
683 const _: () = {
685 fn __modkit_obj_safety<T: ?Sized + ::core::marker::Send + ::core::marker::Sync + 'static>() {}
686 let _ = __modkit_obj_safety::<dyn #client_trait_path> as fn();
687 };
688
689 impl #impl_generics #struct_ident #ty_generics #where_clause {
690 pub const MODULE_NAME: &'static str = #name_lit;
691 }
692 }
693 } else {
694 quote! {
696 impl #impl_generics #struct_ident #ty_generics #where_clause {
697 pub const MODULE_NAME: &'static str = #name_lit;
698 }
699 }
700 };
701
702 let expanded = quote! {
704 #input
705
706 #(#cap_asserts)*
708
709 #[doc(hidden)]
711 fn #registrator_name(b: &mut ::modkit::registry::RegistryBuilder) {
712 use ::std::sync::Arc;
713
714 let module: Arc<#struct_ident #ty_generics> = Arc::new(#constructor);
715
716 b.register_core_with_meta(
718 #name_lit,
719 &[#(#deps_lits),*],
720 module.clone() as Arc<dyn ::modkit::contracts::Module>
721 );
722
723 #(#capability_registrations)*
725 }
726
727 ::inventory::submit! {
728 ::modkit::registry::Registrator(#registrator_name)
729 }
730
731 #client_code
732
733 #extra_top_level
735 };
736
737 TokenStream::from(expanded)
738}
739
740#[derive(Debug)]
745struct LcCfg {
746 method: String,
747 stop_timeout: String,
748 await_ready: bool,
749}
750
751#[proc_macro_attribute]
752pub fn lifecycle(attr: TokenStream, item: TokenStream) -> TokenStream {
753 let args = parse_macro_input!(attr with Punctuated::<Meta, Token![,]>::parse_terminated);
754 let impl_item = parse_macro_input!(item as ItemImpl);
755
756 let cfg = match parse_lifecycle_args(args) {
757 Ok(c) => c,
758 Err(e) => return e.to_compile_error().into(),
759 };
760
761 let ty = match &*impl_item.self_ty {
763 syn::Type::Path(TypePath { path, .. }) => path.clone(),
764 other => {
765 return syn::Error::new_spanned(other, "unsupported impl target")
766 .to_compile_error()
767 .into();
768 }
769 };
770
771 let runner_ident = format_ident!("{}", cfg.method);
772 let mut has_runner = false;
773 let mut takes_ready_signal = false;
774 for it in &impl_item.items {
775 if let ImplItem::Fn(f) = it
776 && f.sig.ident == runner_ident
777 {
778 has_runner = true;
779 if f.sig.asyncness.is_none() {
780 return syn::Error::new_spanned(f.sig.fn_token, "runner must be async")
781 .to_compile_error()
782 .into();
783 }
784 let input_count = f.sig.inputs.len();
785 match input_count {
786 2 => {}
787 3 => {
788 if let Some(syn::FnArg::Typed(pat_ty)) = f.sig.inputs.iter().nth(2) {
789 match &*pat_ty.ty {
790 syn::Type::Path(tp) => {
791 if let Some(seg) = tp.path.segments.last() {
792 if seg.ident == "ReadySignal" {
793 takes_ready_signal = true;
794 } else {
795 return syn::Error::new_spanned(
796 &pat_ty.ty,
797 "third parameter must be ReadySignal when await_ready=true",
798 )
799 .to_compile_error()
800 .into();
801 }
802 }
803 }
804 other => {
805 return syn::Error::new_spanned(
806 other,
807 "third parameter must be ReadySignal when await_ready=true",
808 )
809 .to_compile_error()
810 .into();
811 }
812 }
813 }
814 }
815 _ => {
816 return syn::Error::new_spanned(
817 f.sig.inputs.clone(),
818 "invalid runner signature; expected (&self, CancellationToken) or (&self, CancellationToken, ReadySignal)",
819 )
820 .to_compile_error()
821 .into();
822 }
823 }
824 }
825 }
826 if !has_runner {
827 return syn::Error::new(
828 Span::call_site(),
829 format!("runner method `{}` not found in impl", cfg.method),
830 )
831 .to_compile_error()
832 .into();
833 }
834
835 let timeout_ts = match parse_duration_tokens(&cfg.stop_timeout) {
837 Ok(ts) => ts,
838 Err(e) => return e.to_compile_error().into(),
839 };
840
841 let ty_ident = match ty.segments.last() {
843 Some(seg) => seg.ident.clone(),
844 None => {
845 return syn::Error::new_spanned(
846 &ty,
847 "unsupported impl target: expected a concrete type path",
848 )
849 .to_compile_error()
850 .into();
851 }
852 };
853 let ty_snake = ty_ident.to_string().to_snake_case();
854
855 let ready_shim_ident = format_ident!("__modkit_run_ready_shim{ty_snake}");
856 let await_ready_bool = cfg.await_ready;
857
858 let extra = if takes_ready_signal {
859 quote! {
860 #[async_trait::async_trait]
861 impl ::modkit::lifecycle::Runnable for #ty {
862 async fn run(self: ::std::sync::Arc<Self>, cancel: ::tokio_util::sync::CancellationToken) -> ::anyhow::Result<()> {
863 let (_tx, _rx) = ::tokio::sync::oneshot::channel::<()>();
864 let ready = ::modkit::lifecycle::ReadySignal::from_sender(_tx);
865 self.#runner_ident(cancel, ready).await
866 }
867 }
868
869 #[doc(hidden)]
870 #[allow(non_snake_case, dead_code)]
871 fn #ready_shim_ident(
872 this: ::std::sync::Arc<#ty>,
873 cancel: ::tokio_util::sync::CancellationToken,
874 ready: ::modkit::lifecycle::ReadySignal,
875 ) -> ::core::pin::Pin<Box<dyn ::core::future::Future<Output = ::anyhow::Result<()>> + Send>> {
876 Box::pin(async move { this.#runner_ident(cancel, ready).await })
877 }
878
879 impl #ty {
880 pub fn into_module(self) -> ::modkit::lifecycle::WithLifecycle<Self> {
882 ::modkit::lifecycle::WithLifecycle::new(self)
883 .with_stop_timeout(#timeout_ts)
884 .with_ready_mode(#await_ready_bool, true, Some(#ready_shim_ident))
885 }
886 }
887 }
888 } else {
889 quote! {
890 #[async_trait::async_trait]
891 impl ::modkit::lifecycle::Runnable for #ty {
892 async fn run(self: ::std::sync::Arc<Self>, cancel: ::tokio_util::sync::CancellationToken) -> ::anyhow::Result<()> {
893 self.#runner_ident(cancel).await
894 }
895 }
896
897 impl #ty {
898 pub fn into_module(self) -> ::modkit::lifecycle::WithLifecycle<Self> {
900 ::modkit::lifecycle::WithLifecycle::new(self)
901 .with_stop_timeout(#timeout_ts)
902 .with_ready_mode(#await_ready_bool, false, None)
903 }
904 }
905 }
906 };
907
908 let out = quote! {
909 #impl_item
910 #extra
911 };
912 out.into()
913}
914
915fn parse_lifecycle_args(args: Punctuated<Meta, Token![,]>) -> syn::Result<LcCfg> {
916 let mut method: Option<String> = None;
917 let mut stop_timeout = "30s".to_owned();
918 let mut await_ready = false;
919
920 for m in args {
921 match m {
922 Meta::NameValue(nv) if nv.path.is_ident("method") => {
923 if let Expr::Lit(el) = nv.value {
924 if let Lit::Str(s) = el.lit {
925 method = Some(s.value());
926 } else {
927 return Err(syn::Error::new_spanned(
928 el,
929 "method must be a string literal",
930 ));
931 }
932 } else {
933 return Err(syn::Error::new_spanned(
934 nv,
935 "method must be a string literal",
936 ));
937 }
938 }
939 Meta::NameValue(nv) if nv.path.is_ident("stop_timeout") => {
940 if let Expr::Lit(el) = nv.value {
941 if let Lit::Str(s) = el.lit {
942 stop_timeout = s.value();
943 } else {
944 return Err(syn::Error::new_spanned(
945 el,
946 "stop_timeout must be a string literal like \"45s\"",
947 ));
948 }
949 } else {
950 return Err(syn::Error::new_spanned(
951 nv,
952 "stop_timeout must be a string literal like \"45s\"",
953 ));
954 }
955 }
956 Meta::NameValue(nv) if nv.path.is_ident("await_ready") => {
957 if let Expr::Lit(el) = nv.value {
958 if let Lit::Bool(b) = el.lit {
959 await_ready = b.value();
960 } else {
961 return Err(syn::Error::new_spanned(
962 el,
963 "await_ready must be a bool literal (true/false)",
964 ));
965 }
966 } else {
967 return Err(syn::Error::new_spanned(
968 nv,
969 "await_ready must be a bool literal (true/false)",
970 ));
971 }
972 }
973 Meta::Path(p) if p.is_ident("await_ready") => {
974 await_ready = true;
975 }
976 other => {
977 return Err(syn::Error::new_spanned(
978 other,
979 "expected named args: method=\"...\", stop_timeout=\"...\", await_ready=true|false",
980 ));
981 }
982 }
983 }
984
985 let method = method.ok_or_else(|| {
986 syn::Error::new(
987 Span::call_site(),
988 "missing required arg: method=\"runner_name\"",
989 )
990 })?;
991 Ok(LcCfg {
992 method,
993 stop_timeout,
994 await_ready,
995 })
996}
997
998fn parse_duration_tokens(s: &str) -> syn::Result<proc_macro2::TokenStream> {
999 let err = || {
1000 syn::Error::new(
1001 Span::call_site(),
1002 format!("invalid duration: {s}. Use e.g. \"500ms\", \"45s\", \"2m\", \"1h\""),
1003 )
1004 };
1005 if let Some(stripped) = s.strip_suffix("ms") {
1006 let v: u64 = stripped.parse().map_err(|_| err())?;
1007 Ok(quote! { ::std::time::Duration::from_millis(#v) })
1008 } else if let Some(stripped) = s.strip_suffix('s') {
1009 let v: u64 = stripped.parse().map_err(|_| err())?;
1010 Ok(quote! { ::std::time::Duration::from_secs(#v) })
1011 } else if let Some(stripped) = s.strip_suffix('m') {
1012 let v: u64 = stripped.parse().map_err(|_| err())?;
1013 Ok(quote! { ::std::time::Duration::from_secs(#v * 60) })
1014 } else if let Some(stripped) = s.strip_suffix('h') {
1015 let v: u64 = stripped.parse().map_err(|_| err())?;
1016 Ok(quote! { ::std::time::Duration::from_secs(#v * 3600) })
1017 } else {
1018 Err(err())
1019 }
1020}
1021
1022fn path_last_is(path: &syn::Path, want: &str) -> bool {
1023 path.segments.last().is_some_and(|s| s.ident == want)
1024}
1025
1026#[proc_macro_attribute]
1056pub fn grpc_client(attr: TokenStream, item: TokenStream) -> TokenStream {
1057 let config = parse_macro_input!(attr as grpc_client::GrpcClientConfig);
1058 let input = parse_macro_input!(item as DeriveInput);
1059
1060 match grpc_client::expand_grpc_client(config, input) {
1061 Ok(expanded) => TokenStream::from(expanded),
1062 Err(e) => TokenStream::from(e.to_compile_error()),
1063 }
1064}
1065
1066#[proc_macro_attribute]
1119pub fn api_dto(attr: TokenStream, item: TokenStream) -> TokenStream {
1120 let attrs = parse_macro_input!(attr with Punctuated::<Ident, Token![,]>::parse_terminated);
1121 let input = parse_macro_input!(item as DeriveInput);
1122 TokenStream::from(api_dto::expand_api_dto(&attrs, &input))
1123}