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 ModuleConfig {
20 name: String,
21 deps: Vec<String>,
22 caps: Vec<Capability>,
23 ctor: Option<Expr>, client: Option<Path>, lifecycle: Option<LcModuleCfg>, }
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("module name cannot be empty".to_owned());
135 }
136
137 if name.contains('_') {
139 let suggested = name.replace('_', "-");
140 return Err(format!(
141 "module 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 "module name must start with a lowercase letter, found '{first_char}'"
150 ));
151 }
152 } else {
153 return Err("module name cannot be empty".to_owned());
155 }
156
157 if name.ends_with('-') {
159 return Err("module 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("module 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 "module name must contain only lowercase letters, digits, and hyphens, found '{ch}'"
175 ));
176 }
177 }
178
179 Ok(())
180}
181
182#[derive(Debug, Clone)]
183struct LcModuleCfg {
184 entry: String, stop_timeout: String, await_ready: bool, }
188
189impl Default for LcModuleCfg {
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 ModuleConfig {
200 fn parse(input: ParseStream) -> syn::Result<Self> {
201 let mut name: Option<String> = None;
202 let mut deps: Vec<String> = Vec::new();
203 let mut caps: Vec<Capability> = Vec::new();
204 let mut ctor: Option<Expr> = None;
205 let mut client: Option<Path> = None;
206 let mut lifecycle: Option<LcModuleCfg> = None;
207
208 let mut seen_name = false;
209 let mut seen_deps = false;
210 let mut seen_caps = false;
211 let mut seen_ctor = false;
212 let mut seen_client = false;
213 let mut seen_lifecycle = false;
214
215 let punctuated: Punctuated<Meta, Token![,]> =
216 input.parse_terminated(Meta::parse, Token![,])?;
217
218 for meta in punctuated {
219 match meta {
220 Meta::NameValue(nv) if nv.path.is_ident("name") => {
221 if seen_name {
222 return Err(syn::Error::new_spanned(
223 nv.path,
224 "duplicate `name` parameter",
225 ));
226 }
227 seen_name = true;
228 match nv.value {
229 Expr::Lit(syn::ExprLit {
230 lit: Lit::Str(s), ..
231 }) => {
232 let module_name = s.value();
233 if let Err(err) = validate_kebab_case(&module_name) {
235 return Err(syn::Error::new_spanned(s, err));
236 }
237 name = Some(module_name);
238 }
239 other => {
240 return Err(syn::Error::new_spanned(
241 other,
242 "name must be a string literal, e.g. name = \"my-module\"",
243 ));
244 }
245 }
246 }
247 Meta::NameValue(nv) if nv.path.is_ident("ctor") => {
248 if seen_ctor {
249 return Err(syn::Error::new_spanned(
250 nv.path,
251 "duplicate `ctor` parameter",
252 ));
253 }
254 seen_ctor = true;
255
256 match &nv.value {
258 Expr::Lit(syn::ExprLit {
259 lit: Lit::Str(s), ..
260 }) => {
261 return Err(syn::Error::new_spanned(
262 s,
263 "ctor must be a Rust expression, not a string literal. \
264 Use: ctor = MyType::new() (with parentheses), \
265 or: ctor = Default::default()",
266 ));
267 }
268 _ => {
269 ctor = Some(nv.value.clone());
270 }
271 }
272 }
273 Meta::NameValue(nv) if nv.path.is_ident("client") => {
274 if seen_client {
275 return Err(syn::Error::new_spanned(
276 nv.path,
277 "duplicate `client` parameter",
278 ));
279 }
280 seen_client = true;
281 let value = nv.value.clone();
282 match value {
283 Expr::Path(ep) => {
284 client = Some(ep.path);
285 }
286 other => {
287 return Err(syn::Error::new_spanned(
288 other,
289 "client must be a trait path, e.g. client = crate::api::MyClient",
290 ));
291 }
292 }
293 }
294 Meta::NameValue(nv) if nv.path.is_ident("deps") => {
295 if seen_deps {
296 return Err(syn::Error::new_spanned(
297 nv.path,
298 "duplicate `deps` parameter",
299 ));
300 }
301 seen_deps = true;
302 let value = nv.value.clone();
303 match value {
304 Expr::Array(arr) => {
305 for elem in arr.elems {
306 match elem {
307 Expr::Lit(syn::ExprLit {
308 lit: Lit::Str(s), ..
309 }) => {
310 deps.push(s.value());
311 }
312 other => {
313 return Err(syn::Error::new_spanned(
314 other,
315 "deps must be an array of string literals, e.g. deps = [\"db\", \"auth\"]",
316 ));
317 }
318 }
319 }
320 }
321 other => {
322 return Err(syn::Error::new_spanned(
323 other,
324 "deps must be an array, e.g. deps = [\"db\", \"auth\"]",
325 ));
326 }
327 }
328 }
329 Meta::NameValue(nv) if nv.path.is_ident("capabilities") => {
330 if seen_caps {
331 return Err(syn::Error::new_spanned(
332 nv.path,
333 "duplicate `capabilities` parameter",
334 ));
335 }
336 seen_caps = true;
337 let value = nv.value.clone();
338 match value {
339 Expr::Array(arr) => {
340 for elem in arr.elems {
341 match elem {
342 Expr::Path(ref path) => {
343 if let Some(ident) = path.path.get_ident() {
344 caps.push(Capability::from_ident(ident)?);
345 } else {
346 return Err(syn::Error::new_spanned(
347 path,
348 "capability must be a simple identifier (db, rest, rest_host, stateful)",
349 ));
350 }
351 }
352 Expr::Lit(syn::ExprLit {
353 lit: Lit::Str(s), ..
354 }) => {
355 caps.push(Capability::from_str_lit(&s)?);
356 }
357 other => {
358 return Err(syn::Error::new_spanned(
359 other,
360 "capability must be an identifier or string literal (\"db\", \"rest\", \"rest_host\", \"stateful\")",
361 ));
362 }
363 }
364 }
365 }
366 other => {
367 return Err(syn::Error::new_spanned(
368 other,
369 "capabilities must be an array, e.g. capabilities = [db, rest]",
370 ));
371 }
372 }
373 }
374 Meta::List(list) if path_last_is(&list.path, "lifecycle") => {
376 if seen_lifecycle {
377 return Err(syn::Error::new_spanned(
378 list.path,
379 "duplicate `lifecycle(...)` parameter",
380 ));
381 }
382 seen_lifecycle = true;
383 lifecycle = Some(parse_lifecycle_list(&list)?);
384 }
385 other => {
386 return Err(syn::Error::new_spanned(
387 other,
388 "unknown attribute parameter",
389 ));
390 }
391 }
392 }
393
394 let name = name.ok_or_else(|| {
395 syn::Error::new(
396 Span::call_site(),
397 "name parameter is required, e.g. #[module(name = \"my-module\", ...)]",
398 )
399 })?;
400
401 Ok(ModuleConfig {
402 name,
403 deps,
404 caps,
405 ctor,
406 client,
407 lifecycle,
408 })
409 }
410}
411
412fn parse_lifecycle_list(list: &MetaList) -> syn::Result<LcModuleCfg> {
413 let mut cfg = LcModuleCfg::default();
414
415 let inner: Punctuated<Meta, Token![,]> =
416 list.parse_args_with(Punctuated::<Meta, Token![,]>::parse_terminated)?;
417
418 for m in inner {
419 match m {
420 Meta::NameValue(MetaNameValue { path, value, .. }) if path.is_ident("entry") => {
421 if let Expr::Lit(syn::ExprLit {
422 lit: Lit::Str(s), ..
423 }) = value
424 {
425 cfg.entry = s.value();
426 } else {
427 return Err(syn::Error::new_spanned(
428 value,
429 "entry must be a string literal, e.g. entry = \"serve\"",
430 ));
431 }
432 }
433 Meta::NameValue(MetaNameValue { path, value, .. }) if path.is_ident("stop_timeout") => {
434 if let Expr::Lit(syn::ExprLit {
435 lit: Lit::Str(s), ..
436 }) = value
437 {
438 cfg.stop_timeout = s.value();
439 } else {
440 return Err(syn::Error::new_spanned(
441 value,
442 "stop_timeout must be a string literal like \"45s\"",
443 ));
444 }
445 }
446 Meta::Path(p) if p.is_ident("await_ready") => {
447 cfg.await_ready = true;
448 }
449 Meta::NameValue(MetaNameValue { path, value, .. }) if path.is_ident("await_ready") => {
450 if let Expr::Lit(syn::ExprLit {
451 lit: Lit::Bool(LitBool { value: b, .. }),
452 ..
453 }) = value
454 {
455 cfg.await_ready = b;
456 } else {
457 return Err(syn::Error::new_spanned(
458 value,
459 "await_ready must be a bool literal (true/false) or a bare flag",
460 ));
461 }
462 }
463 other => {
464 return Err(syn::Error::new_spanned(
465 other,
466 "expected lifecycle args: entry=\"...\", stop_timeout=\"...\", await_ready[=true|false]",
467 ));
468 }
469 }
470 }
471
472 Ok(cfg)
473}
474
475#[proc_macro_attribute]
480#[allow(clippy::too_many_lines)]
481pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream {
482 let config = parse_macro_input!(attr as ModuleConfig);
483 let input = parse_macro_input!(item as DeriveInput);
484
485 let struct_ident = input.ident.clone();
487 let generics_clone = input.generics.clone();
488 let (impl_generics, ty_generics, where_clause) = generics_clone.split_for_impl();
489
490 let name_owned: String = config.name.clone();
491 let deps_owned: Vec<String> = config.deps.clone();
492 let caps_for_asserts: Vec<Capability> = config.caps.clone();
493 let caps_for_regs: Vec<Capability> = config.caps.clone();
494 let ctor_expr_opt: Option<Expr> = config.ctor.clone();
495 let client_trait_opt: Option<Path> = config.client.clone();
496 let lifecycle_cfg_opt: Option<LcModuleCfg> = config.lifecycle;
497
498 let name_lit = LitStr::new(&name_owned, Span::call_site());
500 let deps_lits: Vec<LitStr> = deps_owned
501 .iter()
502 .map(|s| LitStr::new(s, Span::call_site()))
503 .collect();
504
505 let constructor = if let Some(expr) = &ctor_expr_opt {
507 quote! { #expr }
508 } else {
509 quote! { <#struct_ident #ty_generics as ::core::default::Default>::default() }
511 };
512
513 let mut cap_asserts = Vec::new();
515
516 cap_asserts.push(quote! {
518 const _: () = {
519 #[allow(dead_code)]
520 fn __modkit_require_Module_impl()
521 where
522 #struct_ident #ty_generics: ::modkit::contracts::Module,
523 {}
524 };
525 });
526
527 for cap in &caps_for_asserts {
528 let q = match cap {
529 Capability::Db => quote! {
530 const _: () = {
531 #[allow(dead_code)]
532 fn __modkit_require_DatabaseCapability_impl()
533 where
534 #struct_ident #ty_generics: ::modkit::contracts::DatabaseCapability,
535 {}
536 };
537 },
538 Capability::Rest => quote! {
539 const _: () = {
540 #[allow(dead_code)]
541 fn __modkit_require_RestApiCapability_impl()
542 where
543 #struct_ident #ty_generics: ::modkit::contracts::RestApiCapability,
544 {}
545 };
546 },
547 Capability::RestHost => quote! {
548 const _: () = {
549 #[allow(dead_code)]
550 fn __modkit_require_ApiGatewayCapability_impl()
551 where
552 #struct_ident #ty_generics: ::modkit::contracts::ApiGatewayCapability,
553 {}
554 };
555 },
556 Capability::Stateful => {
557 if lifecycle_cfg_opt.is_none() {
558 quote! {
560 const _: () = {
561 #[allow(dead_code)]
562 fn __modkit_require_RunnableCapability_impl()
563 where
564 #struct_ident #ty_generics: ::modkit::contracts::RunnableCapability,
565 {}
566 };
567 }
568 } else {
569 quote! {}
570 }
571 }
572 Capability::System => {
573 quote! {}
575 }
576 Capability::GrpcHub => quote! {
577 const _: () = {
578 #[allow(dead_code)]
579 fn __modkit_require_GrpcHubCapability_impl()
580 where
581 #struct_ident #ty_generics: ::modkit::contracts::GrpcHubCapability,
582 {}
583 };
584 },
585 Capability::Grpc => quote! {
586 const _: () = {
587 #[allow(dead_code)]
588 fn __modkit_require_GrpcServiceCapability_impl()
589 where
590 #struct_ident #ty_generics: ::modkit::contracts::GrpcServiceCapability,
591 {}
592 };
593 },
594 };
595 cap_asserts.push(q);
596 }
597
598 let struct_name_snake = struct_ident.to_string().to_snake_case();
600 let registrator_name = format_ident!("__{}_registrator", struct_name_snake);
601
602 let mut extra_top_level = proc_macro2::TokenStream::new();
604
605 if let Some(lc) = &lifecycle_cfg_opt {
606 let entry_ident = format_ident!("{}", lc.entry);
608 let timeout_ts =
609 parse_duration_tokens(&lc.stop_timeout).unwrap_or_else(|e| e.to_compile_error());
610 let await_ready_bool = lc.await_ready;
611
612 if await_ready_bool {
613 let ready_shim_ident =
614 format_ident!("__modkit_run_ready_shim_for_{}", struct_name_snake);
615
616 extra_top_level.extend(quote! {
618 #[::async_trait::async_trait]
619 impl #impl_generics ::modkit::lifecycle::Runnable for #struct_ident #ty_generics #where_clause {
620 async fn run(self: ::std::sync::Arc<Self>, cancel: ::tokio_util::sync::CancellationToken) -> ::anyhow::Result<()> {
621 let (_tx, _rx) = ::tokio::sync::oneshot::channel::<()>();
622 let ready = ::modkit::lifecycle::ReadySignal::from_sender(_tx);
623 self.#entry_ident(cancel, ready).await
624 }
625 }
626
627 #[doc(hidden)]
628 #[allow(dead_code, non_snake_case)]
629 fn #ready_shim_ident(
630 this: ::std::sync::Arc<#struct_ident #ty_generics>,
631 cancel: ::tokio_util::sync::CancellationToken,
632 ready: ::modkit::lifecycle::ReadySignal,
633 ) -> ::core::pin::Pin<Box<dyn ::core::future::Future<Output = ::anyhow::Result<()>> + Send>> {
634 Box::pin(async move { this.#entry_ident(cancel, ready).await })
635 }
636 });
637
638 extra_top_level.extend(quote! {
640 impl #impl_generics #struct_ident #ty_generics #where_clause {
641 pub fn into_module(self) -> ::modkit::lifecycle::WithLifecycle<Self> {
643 ::modkit::lifecycle::WithLifecycle::new_with_name(self, #name_lit)
644 .with_stop_timeout(#timeout_ts)
645 .with_ready_mode(true, true, Some(#ready_shim_ident))
646 }
647 }
648 });
649 } else {
650 extra_top_level.extend(quote! {
652 #[::async_trait::async_trait]
653 impl #impl_generics ::modkit::lifecycle::Runnable for #struct_ident #ty_generics #where_clause {
654 async fn run(self: ::std::sync::Arc<Self>, cancel: ::tokio_util::sync::CancellationToken) -> ::anyhow::Result<()> {
655 self.#entry_ident(cancel).await
656 }
657 }
658
659 impl #impl_generics #struct_ident #ty_generics #where_clause {
660 pub fn into_module(self) -> ::modkit::lifecycle::WithLifecycle<Self> {
662 ::modkit::lifecycle::WithLifecycle::new_with_name(self, #name_lit)
663 .with_stop_timeout(#timeout_ts)
664 .with_ready_mode(false, false, None)
665 }
666 }
667 });
668 }
669 }
670
671 let capability_registrations = caps_for_regs.iter().map(|cap| {
673 match cap {
674 Capability::Db => quote! {
675 b.register_db_with_meta(#name_lit,
676 module.clone() as ::std::sync::Arc<dyn ::modkit::contracts::DatabaseCapability>);
677 },
678 Capability::Rest => quote! {
679 b.register_rest_with_meta(#name_lit,
680 module.clone() as ::std::sync::Arc<dyn ::modkit::contracts::RestApiCapability>);
681 },
682 Capability::RestHost => quote! {
683 b.register_rest_host_with_meta(#name_lit,
684 module.clone() as ::std::sync::Arc<dyn ::modkit::contracts::ApiGatewayCapability>);
685 },
686 Capability::Stateful => {
687 if let Some(lc) = &lifecycle_cfg_opt {
688 let timeout_ts = parse_duration_tokens(&lc.stop_timeout)
689 .unwrap_or_else(|e| e.to_compile_error());
690 let await_ready_bool = lc.await_ready;
691 let ready_shim_ident =
692 format_ident!("__modkit_run_ready_shim_for_{}", struct_name_snake);
693
694 if await_ready_bool {
695 quote! {
696 let wl = ::modkit::lifecycle::WithLifecycle::from_arc_with_name(
697 module.clone(),
698 #name_lit,
699 )
700 .with_stop_timeout(#timeout_ts)
701 .with_ready_mode(true, true, Some(#ready_shim_ident));
702
703 b.register_stateful_with_meta(
704 #name_lit,
705 ::std::sync::Arc::new(wl) as ::std::sync::Arc<dyn ::modkit::contracts::RunnableCapability>
706 );
707 }
708 } else {
709 quote! {
710 let wl = ::modkit::lifecycle::WithLifecycle::from_arc_with_name(
711 module.clone(),
712 #name_lit,
713 )
714 .with_stop_timeout(#timeout_ts)
715 .with_ready_mode(false, false, None);
716
717 b.register_stateful_with_meta(
718 #name_lit,
719 ::std::sync::Arc::new(wl) as ::std::sync::Arc<dyn ::modkit::contracts::RunnableCapability>
720 );
721 }
722 }
723 } else {
724 quote! {
726 b.register_stateful_with_meta(#name_lit,
727 module.clone() as ::std::sync::Arc<dyn ::modkit::contracts::RunnableCapability>);
728 }
729 }
730 },
731 Capability::System => quote! {
732 b.register_system_with_meta(#name_lit,
733 module.clone() as ::std::sync::Arc<dyn ::modkit::contracts::SystemCapability>);
734 },
735 Capability::GrpcHub => quote! {
736 b.register_grpc_hub_with_meta(#name_lit,
737 module.clone() as ::std::sync::Arc<dyn ::modkit::contracts::GrpcHubCapability>);
738 },
739 Capability::Grpc => quote! {
740 b.register_grpc_service_with_meta(#name_lit,
741 module.clone() as ::std::sync::Arc<dyn ::modkit::contracts::GrpcServiceCapability>);
742 },
743 }
744 });
745
746 let client_code = if let Some(client_trait_path) = &client_trait_opt {
751 quote! {
752 const _: () = {
754 fn __modkit_obj_safety<T: ?Sized + ::core::marker::Send + ::core::marker::Sync + 'static>() {}
755 let _ = __modkit_obj_safety::<dyn #client_trait_path> as fn();
756 };
757
758 impl #impl_generics #struct_ident #ty_generics #where_clause {
759 pub const MODULE_NAME: &'static str = #name_lit;
760 }
761 }
762 } else {
763 quote! {
765 impl #impl_generics #struct_ident #ty_generics #where_clause {
766 pub const MODULE_NAME: &'static str = #name_lit;
767 }
768 }
769 };
770
771 let expanded = quote! {
773 #input
774
775 #(#cap_asserts)*
777
778 #[doc(hidden)]
780 fn #registrator_name(b: &mut ::modkit::registry::RegistryBuilder) {
781 use ::std::sync::Arc;
782
783 let module: Arc<#struct_ident #ty_generics> = Arc::new(#constructor);
784
785 b.register_core_with_meta(
787 #name_lit,
788 &[#(#deps_lits),*],
789 module.clone() as Arc<dyn ::modkit::contracts::Module>
790 );
791
792 #(#capability_registrations)*
794 }
795
796 ::modkit::inventory::submit! {
797 ::modkit::registry::Registrator(#registrator_name)
798 }
799
800 #client_code
801
802 #extra_top_level
804 };
805
806 TokenStream::from(expanded)
807}
808
809#[derive(Debug)]
814struct LcCfg {
815 method: String,
816 stop_timeout: String,
817 await_ready: bool,
818}
819
820#[proc_macro_attribute]
821pub fn lifecycle(attr: TokenStream, item: TokenStream) -> TokenStream {
822 let args = parse_macro_input!(attr with Punctuated::<Meta, Token![,]>::parse_terminated);
823 let impl_item = parse_macro_input!(item as ItemImpl);
824
825 let cfg = match parse_lifecycle_args(args) {
826 Ok(c) => c,
827 Err(e) => return e.to_compile_error().into(),
828 };
829
830 let ty = match &*impl_item.self_ty {
832 syn::Type::Path(TypePath { path, .. }) => path.clone(),
833 other => {
834 return syn::Error::new_spanned(other, "unsupported impl target")
835 .to_compile_error()
836 .into();
837 }
838 };
839
840 let runner_ident = format_ident!("{}", cfg.method);
841 let mut has_runner = false;
842 let mut takes_ready_signal = false;
843 for it in &impl_item.items {
844 if let ImplItem::Fn(f) = it
845 && f.sig.ident == runner_ident
846 {
847 has_runner = true;
848 if f.sig.asyncness.is_none() {
849 return syn::Error::new_spanned(f.sig.fn_token, "runner must be async")
850 .to_compile_error()
851 .into();
852 }
853 let input_count = f.sig.inputs.len();
854 match input_count {
855 2 => {}
856 3 => {
857 if let Some(syn::FnArg::Typed(pat_ty)) = f.sig.inputs.iter().nth(2) {
858 match &*pat_ty.ty {
859 syn::Type::Path(tp) => {
860 if let Some(seg) = tp.path.segments.last() {
861 if seg.ident == "ReadySignal" {
862 takes_ready_signal = true;
863 } else {
864 return syn::Error::new_spanned(
865 &pat_ty.ty,
866 "third parameter must be ReadySignal when await_ready=true",
867 )
868 .to_compile_error()
869 .into();
870 }
871 }
872 }
873 other => {
874 return syn::Error::new_spanned(
875 other,
876 "third parameter must be ReadySignal when await_ready=true",
877 )
878 .to_compile_error()
879 .into();
880 }
881 }
882 }
883 }
884 _ => {
885 return syn::Error::new_spanned(
886 f.sig.inputs.clone(),
887 "invalid runner signature; expected (&self, CancellationToken) or (&self, CancellationToken, ReadySignal)",
888 )
889 .to_compile_error()
890 .into();
891 }
892 }
893 }
894 }
895 if !has_runner {
896 return syn::Error::new(
897 Span::call_site(),
898 format!("runner method `{}` not found in impl", cfg.method),
899 )
900 .to_compile_error()
901 .into();
902 }
903
904 let timeout_ts = match parse_duration_tokens(&cfg.stop_timeout) {
906 Ok(ts) => ts,
907 Err(e) => return e.to_compile_error().into(),
908 };
909
910 let ty_ident = match ty.segments.last() {
912 Some(seg) => seg.ident.clone(),
913 None => {
914 return syn::Error::new_spanned(
915 &ty,
916 "unsupported impl target: expected a concrete type path",
917 )
918 .to_compile_error()
919 .into();
920 }
921 };
922 let ty_snake = ty_ident.to_string().to_snake_case();
923
924 let ready_shim_ident = format_ident!("__modkit_run_ready_shim{ty_snake}");
925 let await_ready_bool = cfg.await_ready;
926
927 let extra = if takes_ready_signal {
928 quote! {
929 #[async_trait::async_trait]
930 impl ::modkit::lifecycle::Runnable for #ty {
931 async fn run(self: ::std::sync::Arc<Self>, cancel: ::tokio_util::sync::CancellationToken) -> ::anyhow::Result<()> {
932 let (_tx, _rx) = ::tokio::sync::oneshot::channel::<()>();
933 let ready = ::modkit::lifecycle::ReadySignal::from_sender(_tx);
934 self.#runner_ident(cancel, ready).await
935 }
936 }
937
938 #[doc(hidden)]
939 #[allow(non_snake_case, dead_code)]
940 fn #ready_shim_ident(
941 this: ::std::sync::Arc<#ty>,
942 cancel: ::tokio_util::sync::CancellationToken,
943 ready: ::modkit::lifecycle::ReadySignal,
944 ) -> ::core::pin::Pin<Box<dyn ::core::future::Future<Output = ::anyhow::Result<()>> + Send>> {
945 Box::pin(async move { this.#runner_ident(cancel, ready).await })
946 }
947
948 impl #ty {
949 pub fn into_module(self) -> ::modkit::lifecycle::WithLifecycle<Self> {
951 ::modkit::lifecycle::WithLifecycle::new(self)
952 .with_stop_timeout(#timeout_ts)
953 .with_ready_mode(#await_ready_bool, true, Some(#ready_shim_ident))
954 }
955 }
956 }
957 } else {
958 quote! {
959 #[async_trait::async_trait]
960 impl ::modkit::lifecycle::Runnable for #ty {
961 async fn run(self: ::std::sync::Arc<Self>, cancel: ::tokio_util::sync::CancellationToken) -> ::anyhow::Result<()> {
962 self.#runner_ident(cancel).await
963 }
964 }
965
966 impl #ty {
967 pub fn into_module(self) -> ::modkit::lifecycle::WithLifecycle<Self> {
969 ::modkit::lifecycle::WithLifecycle::new(self)
970 .with_stop_timeout(#timeout_ts)
971 .with_ready_mode(#await_ready_bool, false, None)
972 }
973 }
974 }
975 };
976
977 let out = quote! {
978 #impl_item
979 #extra
980 };
981 out.into()
982}
983
984fn parse_lifecycle_args(args: Punctuated<Meta, Token![,]>) -> syn::Result<LcCfg> {
985 let mut method: Option<String> = None;
986 let mut stop_timeout = "30s".to_owned();
987 let mut await_ready = false;
988
989 for m in args {
990 match m {
991 Meta::NameValue(nv) if nv.path.is_ident("method") => {
992 if let Expr::Lit(el) = nv.value {
993 if let Lit::Str(s) = el.lit {
994 method = Some(s.value());
995 } else {
996 return Err(syn::Error::new_spanned(
997 el,
998 "method must be a string literal",
999 ));
1000 }
1001 } else {
1002 return Err(syn::Error::new_spanned(
1003 nv,
1004 "method must be a string literal",
1005 ));
1006 }
1007 }
1008 Meta::NameValue(nv) if nv.path.is_ident("stop_timeout") => {
1009 if let Expr::Lit(el) = nv.value {
1010 if let Lit::Str(s) = el.lit {
1011 stop_timeout = s.value();
1012 } else {
1013 return Err(syn::Error::new_spanned(
1014 el,
1015 "stop_timeout must be a string literal like \"45s\"",
1016 ));
1017 }
1018 } else {
1019 return Err(syn::Error::new_spanned(
1020 nv,
1021 "stop_timeout must be a string literal like \"45s\"",
1022 ));
1023 }
1024 }
1025 Meta::NameValue(nv) if nv.path.is_ident("await_ready") => {
1026 if let Expr::Lit(el) = nv.value {
1027 if let Lit::Bool(b) = el.lit {
1028 await_ready = b.value();
1029 } else {
1030 return Err(syn::Error::new_spanned(
1031 el,
1032 "await_ready must be a bool literal (true/false)",
1033 ));
1034 }
1035 } else {
1036 return Err(syn::Error::new_spanned(
1037 nv,
1038 "await_ready must be a bool literal (true/false)",
1039 ));
1040 }
1041 }
1042 Meta::Path(p) if p.is_ident("await_ready") => {
1043 await_ready = true;
1044 }
1045 other => {
1046 return Err(syn::Error::new_spanned(
1047 other,
1048 "expected named args: method=\"...\", stop_timeout=\"...\", await_ready=true|false",
1049 ));
1050 }
1051 }
1052 }
1053
1054 let method = method.ok_or_else(|| {
1055 syn::Error::new(
1056 Span::call_site(),
1057 "missing required arg: method=\"runner_name\"",
1058 )
1059 })?;
1060 Ok(LcCfg {
1061 method,
1062 stop_timeout,
1063 await_ready,
1064 })
1065}
1066
1067fn parse_duration_tokens(s: &str) -> syn::Result<proc_macro2::TokenStream> {
1068 let err = || {
1069 syn::Error::new(
1070 Span::call_site(),
1071 format!("invalid duration: {s}. Use e.g. \"500ms\", \"45s\", \"2m\", \"1h\""),
1072 )
1073 };
1074 if let Some(stripped) = s.strip_suffix("ms") {
1075 let v: u64 = stripped.parse().map_err(|_| err())?;
1076 Ok(quote! { ::std::time::Duration::from_millis(#v) })
1077 } else if let Some(stripped) = s.strip_suffix('s') {
1078 let v: u64 = stripped.parse().map_err(|_| err())?;
1079 Ok(quote! { ::std::time::Duration::from_secs(#v) })
1080 } else if let Some(stripped) = s.strip_suffix('m') {
1081 let v: u64 = stripped.parse().map_err(|_| err())?;
1082 Ok(quote! { ::std::time::Duration::from_secs(#v * 60) })
1083 } else if let Some(stripped) = s.strip_suffix('h') {
1084 let v: u64 = stripped.parse().map_err(|_| err())?;
1085 Ok(quote! { ::std::time::Duration::from_secs(#v * 3600) })
1086 } else {
1087 Err(err())
1088 }
1089}
1090
1091fn path_last_is(path: &syn::Path, want: &str) -> bool {
1092 path.segments.last().is_some_and(|s| s.ident == want)
1093}
1094
1095#[proc_macro_attribute]
1125pub fn grpc_client(attr: TokenStream, item: TokenStream) -> TokenStream {
1126 let config = parse_macro_input!(attr as grpc_client::GrpcClientConfig);
1127 let input = parse_macro_input!(item as DeriveInput);
1128
1129 match grpc_client::expand_grpc_client(config, input) {
1130 Ok(expanded) => TokenStream::from(expanded),
1131 Err(e) => TokenStream::from(e.to_compile_error()),
1132 }
1133}
1134
1135#[proc_macro_attribute]
1188pub fn api_dto(attr: TokenStream, item: TokenStream) -> TokenStream {
1189 let attrs = parse_macro_input!(attr with Punctuated::<Ident, Token![,]>::parse_terminated);
1190 let input = parse_macro_input!(item as DeriveInput);
1191 TokenStream::from(api_dto::expand_api_dto(&attrs, &input))
1192}
1193
1194#[proc_macro_attribute]
1237pub fn domain_model(_attr: TokenStream, item: TokenStream) -> TokenStream {
1238 let input = parse_macro_input!(item as DeriveInput);
1239 TokenStream::from(domain_model::expand_domain_model(&input))
1240}
1241
1242#[proc_macro_derive(ExpandVars, attributes(expand_vars))]
1259pub fn derive_expand_vars(input: TokenStream) -> TokenStream {
1260 let input = parse_macro_input!(input as DeriveInput);
1261 TokenStream::from(expand_vars::derive(&input))
1262}