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