test_fork_macros/
lib.rs

1// Copyright (C) 2025 Daniel Mueller <deso@posteo.net>
2// SPDX-License-Identifier: (Apache-2.0 OR MIT)
3
4extern crate proc_macro;
5
6use proc_macro::TokenStream;
7use proc_macro2::TokenStream as Tokens;
8
9use quote::quote;
10
11use syn::parse_macro_input;
12use syn::Attribute;
13use syn::Error;
14use syn::ItemFn;
15use syn::Result;
16
17
18/// A procedural macro for running a test in a separate process.
19///
20/// The attribute can be used to associate one or more tags with a test.
21/// The attribute should be placed before the eventual `#[test]`
22/// attribute.
23///
24/// # Example
25///
26/// Use the attribute for all tests in scope:
27/// ```rust,ignore
28/// use test_fork::test;
29///
30/// #[test]
31/// fn test1() {
32///   assert_eq!(2 + 2, 4);
33/// }
34/// ```
35///
36/// The attribute also supports an alternative syntax that nests more
37/// easily with other custom `#[test]` attributes and which allows for
38/// easier annotation of individual tests (e.g., if only a sub-set is
39/// meant to be run in separate processes):
40/// ```rust,ignore
41/// use test_fork::test as fork;
42///
43/// #[fork]
44/// #[test]
45/// fn test2() {
46///   assert_eq!(2 + 3, 5);
47/// }
48/// ```
49#[proc_macro_attribute]
50pub fn test(attr: TokenStream, item: TokenStream) -> TokenStream {
51    let input_fn = parse_macro_input!(item as ItemFn);
52
53    try_test(attr, input_fn)
54        .unwrap_or_else(syn::Error::into_compile_error)
55        .into()
56}
57
58/// Check whether given attribute is a test attribute of forms:
59/// - `#[test]`
60/// - `#[core::prelude::*::test]` or `#[::core::prelude::*::test]`
61/// - `#[std::prelude::*::test]` or `#[::std::prelude::*::test]`
62fn is_test_attribute(attr: &Attribute) -> bool {
63    let path = match &attr.meta {
64        syn::Meta::Path(path) => path,
65        _ => return false,
66    };
67    let candidates = [
68        ["core", "prelude", "*", "test"],
69        ["std", "prelude", "*", "test"],
70    ];
71    if path.leading_colon.is_none()
72        && path.segments.len() == 1
73        && path.segments[0].arguments.is_none()
74        && path.segments[0].ident == "test"
75    {
76        return true;
77    } else if path.segments.len() != candidates[0].len() {
78        return false;
79    }
80    candidates.into_iter().any(|segments| {
81        path.segments.iter().zip(segments).all(|(segment, path)| {
82            segment.arguments.is_none() && (path == "*" || segment.ident == path)
83        })
84    })
85}
86
87fn try_test(attr: TokenStream, input_fn: ItemFn) -> Result<Tokens> {
88    if !attr.is_empty() {
89        return Err(Error::new_spanned(
90            Tokens::from(attr),
91            "test_fork::test does not currently accept arguments",
92        ))
93    }
94
95    let has_test = input_fn.attrs.iter().any(is_test_attribute);
96    let inner_test = if has_test {
97        quote! {}
98    } else {
99        quote! { #[::core::prelude::v1::test]}
100    };
101
102    let augmented_test = quote! {
103        ::test_fork::fork_test! {
104            #inner_test
105            #input_fn
106        }
107    };
108
109    Ok(augmented_test)
110}