1use proc_macro::TokenStream;
26use quote::{format_ident, quote};
27use syn::parse::{Parse, ParseStream};
28use syn::punctuated::Punctuated;
29use syn::{Expr, FnArg, ItemFn, ItemMod, Lit, Meta, Pat, Token, Type, parse_macro_input, parse_quote};
30
31struct FerritestArgs {
33 retries: Option<u32>,
34 timeout_ms: Option<u64>,
35 tags: Vec<String>,
36 skip: Option<Option<String>>,
38 slow: Option<Option<String>>,
40 fixme: Option<Option<String>>,
42 fail: Option<Option<String>>,
44 only: bool,
45 infos: Vec<(String, String)>,
47 use_options: Option<String>,
49}
50
51impl Parse for FerritestArgs {
52 fn parse(input: ParseStream<'_>) -> syn::Result<Self> {
53 let mut args = Self {
54 retries: None,
55 timeout_ms: None,
56 tags: Vec::new(),
57 skip: None,
58 slow: None,
59 fixme: None,
60 fail: None,
61 only: false,
62 infos: Vec::new(),
63 use_options: None,
64 };
65
66 let metas = Punctuated::<Meta, Token![,]>::parse_terminated(input)?;
67 for meta in metas {
68 match &meta {
69 Meta::NameValue(nv) => {
70 let ident = nv.path.get_ident().map(ToString::to_string).unwrap_or_default();
71 match ident.as_str() {
72 "retries" => {
73 if let syn::Expr::Lit(lit) = &nv.value {
74 if let Lit::Int(i) = &lit.lit {
75 args.retries = Some(i.base10_parse()?);
76 }
77 }
78 },
79 "timeout" => {
80 if let syn::Expr::Lit(lit) = &nv.value {
81 if let Lit::Str(s) = &lit.lit {
82 args.timeout_ms = Some(parse_duration_str(&s.value())?);
83 }
84 }
85 },
86 "tag" => {
87 if let syn::Expr::Lit(lit) = &nv.value {
88 if let Lit::Str(s) = &lit.lit {
89 args.tags.push(s.value());
90 }
91 }
92 },
93 "skip" => {
94 if let syn::Expr::Lit(lit) = &nv.value {
95 if let Lit::Str(s) = &lit.lit {
96 args.skip = Some(Some(s.value()));
97 }
98 }
99 },
100 "slow" => {
101 if let syn::Expr::Lit(lit) = &nv.value {
102 if let Lit::Str(s) = &lit.lit {
103 args.slow = Some(Some(s.value()));
104 }
105 }
106 },
107 "fixme" => {
108 if let syn::Expr::Lit(lit) = &nv.value {
109 if let Lit::Str(s) = &lit.lit {
110 args.fixme = Some(Some(s.value()));
111 }
112 }
113 },
114 "fail" => {
115 if let syn::Expr::Lit(lit) = &nv.value {
116 if let Lit::Str(s) = &lit.lit {
117 args.fail = Some(Some(s.value()));
118 }
119 }
120 },
121 "use_options" => {
122 if let syn::Expr::Lit(lit) = &nv.value {
123 if let Lit::Str(s) = &lit.lit {
124 args.use_options = Some(s.value());
125 }
126 }
127 },
128 "info" => {
129 if let syn::Expr::Lit(lit) = &nv.value {
130 if let Lit::Str(s) = &lit.lit {
131 let val = s.value();
132 if let Some((type_name, desc)) = val.split_once(':') {
133 args.infos.push((type_name.trim().to_string(), desc.trim().to_string()));
134 } else {
135 args.infos.push((val, String::new()));
136 }
137 }
138 }
139 },
140 _ => {
141 return Err(syn::Error::new_spanned(
142 &nv.path,
143 format!("unknown ferritest attribute: {ident}"),
144 ));
145 },
146 }
147 },
148 Meta::Path(p) => {
149 let ident = p.get_ident().map(ToString::to_string).unwrap_or_default();
150 match ident.as_str() {
151 "skip" => args.skip = Some(None),
152 "slow" => args.slow = Some(None),
153 "fixme" => args.fixme = Some(None),
154 "fail" => args.fail = Some(None),
155 "only" => args.only = true,
156 _ => return Err(syn::Error::new_spanned(p, format!("unknown ferritest flag: {ident}"))),
157 }
158 },
159 Meta::List(_) => {
160 return Err(syn::Error::new_spanned(&meta, "unexpected nested attribute"));
161 },
162 }
163 }
164 Ok(args)
165 }
166}
167
168fn parse_duration_str(s: &str) -> syn::Result<u64> {
169 let s = s.trim();
170 if let Some(secs) = s.strip_suffix('s') {
171 secs
172 .trim()
173 .parse::<u64>()
174 .map(|v| v * 1000)
175 .map_err(|e| syn::Error::new(proc_macro2::Span::call_site(), format!("invalid timeout: {e}")))
176 } else if let Some(ms) = s.strip_suffix("ms") {
177 ms.trim()
178 .parse::<u64>()
179 .map_err(|e| syn::Error::new(proc_macro2::Span::call_site(), format!("invalid timeout: {e}")))
180 } else {
181 s.parse::<u64>().map_err(|e| {
182 syn::Error::new(
183 proc_macro2::Span::call_site(),
184 format!("invalid timeout (use '30s' or '5000ms'): {e}"),
185 )
186 })
187 }
188}
189
190#[proc_macro_attribute]
195pub fn ferritest(attr: TokenStream, item: TokenStream) -> TokenStream {
196 let args = parse_macro_input!(attr as FerritestArgs);
197 let input = parse_macro_input!(item as ItemFn);
198
199 let fn_name = &input.sig.ident;
200 let fn_name_str = fn_name.to_string();
201 let vis = &input.vis;
202 let block = &input.block;
203 let attrs = &input.attrs;
204
205 let ctx_param_name = if let Some(FnArg::Typed(pt)) = input.sig.inputs.first() {
208 if let Pat::Ident(pi) = pt.pat.as_ref() {
209 pi.ident.clone()
210 } else {
211 format_ident!("ctx")
212 }
213 } else {
214 format_ident!("ctx")
215 };
216
217 let fixture_names: Vec<String> = Vec::new();
219 let fixture_array = fixture_names.iter().map(|f| quote! { #f });
220
221 fn annotation_tokens(variant: &str, arg: &Option<Option<String>>, annotations: &mut Vec<proc_macro2::TokenStream>) {
224 let variant_ident = quote::format_ident!("{}", variant);
225 match arg {
226 Some(None) => {
227 annotations
228 .push(quote! { ferridriver_test::model::TestAnnotation::#variant_ident { reason: None, condition: None } });
229 },
230 Some(Some(val)) => {
231 if let Some((cond, reason)) = val.split_once('|') {
233 let cond = cond.trim();
234 let reason = reason.trim();
235 annotations.push(quote! { ferridriver_test::model::TestAnnotation::#variant_ident {
236 reason: Some(#reason.to_string()),
237 condition: Some(#cond.to_string()),
238 } });
239 } else {
240 annotations.push(quote! { ferridriver_test::model::TestAnnotation::#variant_ident {
241 reason: None,
242 condition: Some(#val.to_string()),
243 } });
244 }
245 },
246 None => {},
247 }
248 }
249
250 let mut annotations = Vec::new();
251 annotation_tokens("Skip", &args.skip, &mut annotations);
252 annotation_tokens("Slow", &args.slow, &mut annotations);
253 annotation_tokens("Fixme", &args.fixme, &mut annotations);
254 annotation_tokens("Fail", &args.fail, &mut annotations);
255 if args.only {
256 annotations.push(quote! { ferridriver_test::model::TestAnnotation::Only });
257 }
258 for tag in &args.tags {
259 annotations.push(quote! { ferridriver_test::model::TestAnnotation::Tag(#tag.to_string()) });
260 }
261 for (type_name, desc) in &args.infos {
262 annotations.push(
263 quote! { ferridriver_test::model::TestAnnotation::Info { type_name: #type_name.to_string(), description: #desc.to_string() } },
264 );
265 }
266
267 let retries_expr = match args.retries {
268 Some(r) => quote! { Some(#r) },
269 None => quote! { None },
270 };
271 let timeout_ms_expr = match args.timeout_ms {
272 Some(ms) => quote! { Some(#ms) },
273 None => quote! { None },
274 };
275 let use_options_expr = match &args.use_options {
276 Some(json) => quote! { Some(#json) },
277 None => quote! { None },
278 };
279
280 let expanded = quote! {
281 #(#attrs)*
282 #[allow(clippy::unused_async)]
283 #vis async fn #fn_name(__pool: ferridriver_test::fixture::FixturePool) -> Result<(), ferridriver_test::model::TestFailure> {
284 let #ctx_param_name = ferridriver_test::TestContext::new(__pool);
285 #block
286 Ok(())
287 }
288
289 inventory::submit! {
290 ferridriver_test::discovery::TestRegistration {
291 file: file!(),
292 module_path: module_path!(),
293 name: #fn_name_str,
294 fixture_requests: &[#(#fixture_array),*],
295 annotations: &[#(#annotations),*],
296 timeout_ms: #timeout_ms_expr,
297 retries: #retries_expr,
298 use_options: #use_options_expr,
299 test_fn: |pool| Box::pin(#fn_name(pool)),
300 }
301 }
302 };
303
304 expanded.into()
305}
306
307struct FerritestEachArgs {
309 data: Vec<Vec<Expr>>,
310}
311
312impl Parse for FerritestEachArgs {
313 fn parse(input: ParseStream<'_>) -> syn::Result<Self> {
314 let ident: syn::Ident = input.parse()?;
316 if ident != "data" {
317 return Err(syn::Error::new_spanned(&ident, "expected `data = [...]`"));
318 }
319 let _: Token![=] = input.parse()?;
320
321 let content;
322 syn::bracketed!(content in input);
323
324 let mut data = Vec::new();
325 while !content.is_empty() {
326 let inner;
327 syn::parenthesized!(inner in content);
328 let exprs: Punctuated<Expr, Token![,]> = Punctuated::parse_terminated(&inner)?;
329 data.push(exprs.into_iter().collect());
330
331 if content.peek(Token![,]) {
332 let _: Token![,] = content.parse()?;
333 }
334 }
335
336 Ok(Self { data })
337 }
338}
339
340#[proc_macro_attribute]
354pub fn ferritest_each(attr: TokenStream, item: TokenStream) -> TokenStream {
355 let args = parse_macro_input!(attr as FerritestEachArgs);
356 let input = parse_macro_input!(item as ItemFn);
357
358 let fn_name = &input.sig.ident;
359 let fn_name_str = fn_name.to_string();
360 let block = &input.block;
361 let attrs = &input.attrs;
362
363 let all_params: Vec<_> = input.sig.inputs.iter().collect();
365 let ctx_param_name = if let Some(FnArg::Typed(pt)) = all_params.first() {
366 if let Pat::Ident(pi) = pt.pat.as_ref() {
367 pi.ident.clone()
368 } else {
369 format_ident!("ctx")
370 }
371 } else {
372 format_ident!("ctx")
373 };
374
375 let data_params: Vec<(&syn::Ident, &Type)> = all_params
376 .iter()
377 .skip(1) .filter_map(|arg| {
379 if let FnArg::Typed(pat_type) = arg {
380 if let Pat::Ident(pat_ident) = pat_type.pat.as_ref() {
381 return Some((&pat_ident.ident, &*pat_type.ty));
382 }
383 }
384 None
385 })
386 .collect();
387
388 let fixture_names: Vec<String> = Vec::new();
389
390 let mut submissions = Vec::new();
392 for (row_idx, row) in args.data.iter().enumerate() {
393 if row.len() != data_params.len() {
394 return syn::Error::new_spanned(
395 &input.sig.ident,
396 format!(
397 "data row {} has {} values but function expects {} data parameters",
398 row_idx,
399 row.len(),
400 data_params.len()
401 ),
402 )
403 .to_compile_error()
404 .into();
405 }
406
407 let row_values_str: Vec<String> = row.iter().map(|e| quote!(#e).to_string().replace('"', "")).collect();
409 let suffix = row_values_str.join(", ");
410 let test_name = format!("{fn_name_str} ({suffix})");
411
412 let data_bindings: Vec<_> = data_params
414 .iter()
415 .zip(row.iter())
416 .map(|((param_name, param_type), value)| {
417 quote! { let #param_name: #param_type = #value; }
418 })
419 .collect();
420
421 let inner_fn_name = format_ident!("__ferritest_each_{}_{}", fn_name, row_idx);
422 let fixture_array = fixture_names.iter().map(|f| quote! { #f });
423 let ctx_param = ctx_param_name.clone();
424
425 submissions.push(quote! {
426 #[allow(clippy::unused_async)]
427 async fn #inner_fn_name(__pool: ferridriver_test::fixture::FixturePool) -> Result<(), ferridriver_test::model::TestFailure> {
428 let #ctx_param = ferridriver_test::TestContext::new(__pool);
429 #(#data_bindings)*
430 #block
431 Ok(())
432 }
433
434 inventory::submit! {
435 ferridriver_test::discovery::TestRegistration {
436 file: file!(),
437 module_path: module_path!(),
438 name: #test_name,
439 fixture_requests: &[#(#fixture_array),*],
440 annotations: &[],
441 timeout_ms: None,
442 retries: None,
443 test_fn: |pool| Box::pin(#inner_fn_name(pool)),
444 }
445 }
446 });
447 }
448
449 let expanded = quote! {
450 #(#attrs)*
451 #(#submissions)*
452 };
453
454 expanded.into()
455}
456
457enum FixtureScopeArg {
461 Test,
462 Worker,
463 Global,
464}
465
466struct FixtureArgs {
468 scope: FixtureScopeArg,
469 auto: bool,
470 timeout_ms: Option<u64>,
471}
472
473impl Parse for FixtureArgs {
474 fn parse(input: ParseStream<'_>) -> syn::Result<Self> {
475 let mut args = Self {
476 scope: FixtureScopeArg::Test,
477 auto: false,
478 timeout_ms: None,
479 };
480 let metas = Punctuated::<Meta, Token![,]>::parse_terminated(input)?;
481 for meta in metas {
482 match &meta {
483 Meta::NameValue(nv) => {
484 let ident = nv.path.get_ident().map(ToString::to_string).unwrap_or_default();
485 match ident.as_str() {
486 "scope" => {
487 if let syn::Expr::Lit(lit) = &nv.value {
488 if let Lit::Str(s) = &lit.lit {
489 args.scope = match s.value().as_str() {
490 "test" => FixtureScopeArg::Test,
491 "worker" => FixtureScopeArg::Worker,
492 "global" => FixtureScopeArg::Global,
493 other => {
494 return Err(syn::Error::new_spanned(
495 &nv.value,
496 format!("unknown fixture scope '{other}' (use \"test\", \"worker\", or \"global\")"),
497 ));
498 },
499 };
500 }
501 }
502 },
503 "timeout" => {
504 if let syn::Expr::Lit(lit) = &nv.value {
505 if let Lit::Str(s) = &lit.lit {
506 args.timeout_ms = Some(parse_duration_str(&s.value())?);
507 }
508 }
509 },
510 _ => {
511 return Err(syn::Error::new_spanned(
512 &nv.path,
513 format!("unknown fixture attribute: {ident}"),
514 ));
515 },
516 }
517 },
518 Meta::Path(p) => {
519 let ident = p.get_ident().map(ToString::to_string).unwrap_or_default();
520 match ident.as_str() {
521 "auto" => args.auto = true,
522 _ => return Err(syn::Error::new_spanned(p, format!("unknown fixture flag: {ident}"))),
523 }
524 },
525 Meta::List(_) => {
526 return Err(syn::Error::new_spanned(&meta, "unexpected nested attribute"));
527 },
528 }
529 }
530 Ok(args)
531 }
532}
533
534#[proc_macro_attribute]
563pub fn fixture(attr: TokenStream, item: TokenStream) -> TokenStream {
564 let args = parse_macro_input!(attr as FixtureArgs);
565 let input = parse_macro_input!(item as ItemFn);
566
567 if input.sig.inputs.len() != 1 {
569 return syn::Error::new_spanned(
570 &input.sig,
571 "#[fixture] functions take exactly one parameter: `ctx: TestContext`",
572 )
573 .to_compile_error()
574 .into();
575 }
576
577 let fn_name = &input.sig.ident;
578 let fn_name_str = fn_name.to_string();
579 let builder_ident = format_ident!("__ferridriver_fixture_build_{}", fn_name);
580
581 let scope_tok = match args.scope {
582 FixtureScopeArg::Test => quote! { ferridriver_test::fixture::FixtureScope::Test },
583 FixtureScopeArg::Worker => quote! { ferridriver_test::fixture::FixtureScope::Worker },
584 FixtureScopeArg::Global => quote! { ferridriver_test::fixture::FixtureScope::Global },
585 };
586 let timeout_ms = args.timeout_ms.unwrap_or(10_000);
587 let auto = args.auto;
588
589 let expanded = quote! {
590 #[allow(clippy::unused_async)]
595 #input
596
597 #[doc(hidden)]
598 fn #builder_ident() -> ferridriver_test::fixture::FixtureDef {
599 ferridriver_test::fixture::FixtureDef {
600 name: #fn_name_str.to_string(),
601 scope: #scope_tok,
602 dependencies: ::std::vec::Vec::new(),
603 setup: ::std::sync::Arc::new(|__pool: ferridriver_test::fixture::FixturePool| {
604 ::std::boxed::Box::pin(async move {
605 let __ctx = ferridriver_test::TestContext::new(__pool);
606 let __value = #fn_name(__ctx).await.map_err(|__e| {
607 ferridriver::error::FerriError::backend(::std::format!("fixture '{}' failed: {}", #fn_name_str, __e))
608 })?;
609 ::std::result::Result::Ok(
610 ::std::sync::Arc::new(__value)
611 as ::std::sync::Arc<dyn ::std::any::Any + ::std::marker::Send + ::std::marker::Sync>,
612 )
613 })
614 }),
615 teardown: ::std::option::Option::None,
616 timeout: ::std::time::Duration::from_millis(#timeout_ms),
617 auto: #auto,
618 }
619 }
620
621 ferridriver_test::inventory::submit! {
622 ferridriver_test::discovery::FixtureRegistration {
623 name: #fn_name_str,
624 module_path: ::core::module_path!(),
625 build: #builder_ident,
626 }
627 }
628 };
629
630 expanded.into()
631}
632
633enum SuiteModeArg {
637 Serial,
638 Parallel,
639}
640
641struct SuiteArgs {
642 mode: SuiteModeArg,
643}
644
645impl Parse for SuiteArgs {
646 fn parse(input: ParseStream<'_>) -> syn::Result<Self> {
647 let mut mode = SuiteModeArg::Parallel;
648 let metas = Punctuated::<Meta, Token![,]>::parse_terminated(input)?;
649 for meta in metas {
650 match &meta {
651 Meta::NameValue(nv) if nv.path.is_ident("mode") => {
652 if let syn::Expr::Lit(lit) = &nv.value {
653 if let Lit::Str(s) = &lit.lit {
654 mode = match s.value().as_str() {
655 "serial" => SuiteModeArg::Serial,
656 "parallel" => SuiteModeArg::Parallel,
657 other => {
658 return Err(syn::Error::new_spanned(
659 &nv.value,
660 format!("unknown suite mode '{other}' (use \"serial\" or \"parallel\")"),
661 ));
662 },
663 };
664 }
665 }
666 },
667 _ => {
668 return Err(syn::Error::new_spanned(
669 &meta,
670 "expected `mode = \"serial\" | \"parallel\"`",
671 ));
672 },
673 }
674 }
675 Ok(Self { mode })
676 }
677}
678
679#[proc_macro_attribute]
696pub fn ferritest_suite(attr: TokenStream, item: TokenStream) -> TokenStream {
697 let args = parse_macro_input!(attr as SuiteArgs);
698 let mut module = parse_macro_input!(item as ItemMod);
699
700 let Some((_, ref mut items)) = module.content else {
701 return syn::Error::new_spanned(
702 &module,
703 "#[ferritest_suite] requires an inline module body `mod name { ... }`",
704 )
705 .to_compile_error()
706 .into();
707 };
708
709 let mode_tok = match args.mode {
710 SuiteModeArg::Serial => quote! { ferridriver_test::model::SuiteMode::Serial },
711 SuiteModeArg::Parallel => quote! { ferridriver_test::model::SuiteMode::Parallel },
712 };
713
714 let submit: syn::Item = parse_quote! {
718 ferridriver_test::inventory::submit! {
719 ferridriver_test::discovery::SuiteModeRegistration {
720 module_path: ::core::module_path!(),
721 mode: #mode_tok,
722 }
723 }
724 };
725 items.push(submit);
726
727 quote! { #module }.into()
728}
729
730fn hook_impl(kind_tag: &str, is_suite_hook: bool, item: TokenStream) -> TokenStream {
734 let input = parse_macro_input!(item as ItemFn);
735 let fn_name = &input.sig.ident;
736 let vis = &input.vis;
737 let block = &input.block;
738 let attrs = &input.attrs;
739
740 let kind_ident = format_ident!("{}", kind_tag);
741
742 let ctx_param_name = if let Some(FnArg::Typed(pt)) = input.sig.inputs.first() {
744 if let Pat::Ident(pi) = pt.pat.as_ref() {
745 pi.ident.clone()
746 } else {
747 format_ident!("ctx")
748 }
749 } else {
750 format_ident!("ctx")
751 };
752
753 if is_suite_hook {
754 let expanded = quote! {
756 #(#attrs)*
757 #vis fn #fn_name(__pool: ferridriver_test::fixture::FixturePool)
758 -> ::std::pin::Pin<Box<dyn ::std::future::Future<Output = Result<(), ferridriver_test::model::TestFailure>> + Send>>
759 {
760 Box::pin(async move {
761 let #ctx_param_name = ferridriver_test::TestContext::new(__pool);
762 #block
763 Ok(())
764 })
765 }
766
767 inventory::submit! {
768 ferridriver_test::discovery::HookRegistration {
769 module_path: module_path!(),
770 suite_hook_fn: Some(#fn_name),
771 each_hook_fn: None,
772 kind: ferridriver_test::discovery::HookKindTag::#kind_ident,
773 }
774 }
775 };
776 expanded.into()
777 } else {
778 let expanded = quote! {
780 #(#attrs)*
781 #vis fn #fn_name(
782 __pool: ferridriver_test::fixture::FixturePool,
783 __info: ::std::sync::Arc<ferridriver_test::model::TestInfo>,
784 ) -> ::std::pin::Pin<Box<dyn ::std::future::Future<Output = Result<(), ferridriver_test::model::TestFailure>> + Send>>
785 {
786 Box::pin(async move {
787 let #ctx_param_name = ferridriver_test::TestContext::new(__pool);
788 #block
789 Ok(())
790 })
791 }
792
793 inventory::submit! {
794 ferridriver_test::discovery::HookRegistration {
795 module_path: module_path!(),
796 suite_hook_fn: None,
797 each_hook_fn: Some(#fn_name),
798 kind: ferridriver_test::discovery::HookKindTag::#kind_ident,
799 }
800 }
801 };
802 expanded.into()
803 }
804}
805
806#[proc_macro_attribute]
822pub fn before_all(_attr: TokenStream, item: TokenStream) -> TokenStream {
823 hook_impl("BeforeAll", true, item)
824}
825
826#[proc_macro_attribute]
828pub fn after_all(_attr: TokenStream, item: TokenStream) -> TokenStream {
829 hook_impl("AfterAll", true, item)
830}
831
832#[proc_macro_attribute]
849pub fn before_each(_attr: TokenStream, item: TokenStream) -> TokenStream {
850 hook_impl("BeforeEach", false, item)
851}
852
853#[proc_macro_attribute]
855pub fn after_each(_attr: TokenStream, item: TokenStream) -> TokenStream {
856 hook_impl("AfterEach", false, item)
857}