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