devbox_test_args/
lib.rs

1
2//! Adds parametrization capabilty to `#[test]` via `#[args]` attribute macro.
3//!
4//! # To install via umbrella devbox crate
5//!
6//! ```toml
7//! [dev-dependencies]
8//! devbox = { version = "0.1" }
9//! ```
10//!
11//! # Simplest example
12//!
13//! ```rust
14//! # use devbox_test_args::args;
15//! #[args(
16//!     char_a: 'a';
17//!     char_b: 'b' ! "wrong char";
18//! )]
19//! #[test]
20//! fn parametrized_test_for(character:_) {
21//!     assert_eq!('a', character, "wrong char");
22//! }
23//! ```
24//!
25//! Check [#\[args\]] attribute for full example and usage specification.
26//!
27//! [#\[args\]]: https://doc.rust-lang.org/devbox_test_args/attr.args.html
28
29use std::iter::FromIterator;
30use proc_macro::TokenStream;
31use proc_macro2::{Ident, Span};
32use proc_macro_error::{abort, emit_error, proc_macro_error};
33use quote::quote;
34use syn::{
35    parse_macro_input, Block, Expr, FnArg, ItemFn, LitStr, Local, Pat, Result, Stmt, Token,
36    parse::{Parse, ParseStream},
37    punctuated::Punctuated,
38    token::{Eq, Let, Semi},
39};
40
41//-- Macros ----------------------------------------------------------------------------------------
42
43/// This is an attribute complementing Rust's standard `#[test]` attribute for test parametrization.
44///
45/// A test function can have any number of parameters which can have anonymouse types that will
46/// be filled in by the attribute based it's arguments.
47///
48/// Make sure attribute is applied before the standard Rust `#[test]` attribute or you will
49/// get *functions used as tests can not have any arguments* error. You can also use [`test_args`]
50/// attribute instead which appends the `#[test]` automatically.
51///
52/// [`test_args`]: attr.test_args.html
53///
54/// # Test case
55///
56/// Macro emits a new standard Rust test for each named argument set (also called a case) by
57/// suffixing function name with case name.
58///
59/// Cases are seperated by `;` and need to have unique names for particular test function.
60/// Each case needs argument list seperated by `,` that consumes equal number of function parameters
61/// when generating the actual test function.
62///
63/// To mark a case as one that should panic, add a suffix with a slice of expected message after `!`
64///
65/// Syntax for a case is ```<case-name>: <arg1>, <arg2> ... <argN> [! "<message slice>"];```
66///
67/// # Cartesian product
68///
69/// You can apply mutiple test macros to a single function with individual macro cases consuming
70/// only a subset of function parameters. This forms a cartesian product of cases from each macro
71/// instance. It is import that all cartesian products consume all parameters or you will end up
72/// with a test function with nonzero parameters which is not supported by Rust built in test macro.
73///
74/// # Example
75///
76/// The following example have two cases named `char_a` and `char_b` in first attribute and
77/// `offset_0` and `offset_1` in the second which combines into four tests:
78///
79/// ```rust
80/// # use devbox_test_args::args;
81///
82/// #[args(
83///     char_a: 97, 'a';
84///     char_b: 98, 'b';
85/// )]
86/// #[args(
87///     offset_0: 0;
88///     offset_1: 1 ! "code incorrect";
89/// )]
90/// #[test]
91/// fn parametrized_test_for(code:_, letter:_, offset:_) {
92///     assert_eq!(code + offset, letter as u8, "Letter code incorrect");
93/// }
94/// ```
95///
96/// Should produce:
97/// ```txt
98/// test parametrized_test_for__char_a__offset_0 ... ok
99/// test parametrized_test_for__char_b__offset_0 ... ok
100/// test parametrized_test_for__char_a__offset_1 ... ok
101/// test parametrized_test_for__char_b__offset_1 ... ok
102/// ```
103#[proc_macro_attribute]
104#[proc_macro_error]
105pub fn args(attr: TokenStream, input: TokenStream) -> TokenStream {
106    apply_test_args(attr, input, false)
107}
108
109
110/// Same as [`args`] but applying standard Rust `#[test]` attribute automatically
111///
112/// [`args`]: attr.args.html
113///
114#[proc_macro_attribute]
115#[proc_macro_error]
116pub fn test_args(attr: TokenStream, input: TokenStream) -> TokenStream {
117    apply_test_args(attr, input, true)
118}
119
120//-- Implemenatation -------------------------------------------------------------------------------
121
122/// Main entry point for both macros
123fn apply_test_args(attr: TokenStream, input: TokenStream, append_test_attr: bool) -> TokenStream {
124    let cases = parse_macro_input!(attr as Cases);
125    let input = parse_macro_input!(input as ItemFn);
126
127    if cases.0.len() == 0 {
128        let test = test_attribute(&input, append_test_attr);
129        return quote!{
130            #test
131            #input
132        }.into();
133    }
134
135    let mut output = quote!{};
136    for case in cases.0 {
137        let should_panic = case.panics.clone().map(|e| quote!{ #[should_panic(expected = #e)] });
138        let func = make_case_function(&input, case);
139        let test = test_attribute(&func, append_test_attr);
140
141        output.extend(quote!{
142            #test
143            #should_panic
144            #func
145        });
146    }
147    output.into()
148}
149
150/// Checks if the test function already has the `#[test]` attribute applied
151fn test_attribute(func: &ItemFn, add_if_needed: bool) -> Option<proc_macro2::TokenStream> {
152    if func.sig.inputs.len() > 0 ||
153       func.attrs.iter().any(|a| a.path.segments.last().map_or(false, |seg|seg.ident=="test"))
154    {
155        return None;
156    }
157
158    if add_if_needed {
159        Some(quote!{ #[test] })
160    } else {
161        abort!(func, "Devbox: Function '{}' is missing '#[test]' attribute", func.sig.ident);
162    }
163}
164
165/// Clones `input` function with arguments for attribute `case` applied
166fn make_case_function(input: &ItemFn, case: Case) -> ItemFn {
167    if case.values.len() > input.sig.inputs.len() {
168        emit_error!(
169            input,
170            "Devbox: Test case '{}' arguments outnumber function '{}' parameters {} to {}",
171            case.ident, input.sig.ident, case.values.len(), input.sig.inputs.len()
172        );
173    }
174
175    let mut func = input.clone();
176    let name = format!("{}__{}", func.sig.ident, case.ident.to_string());
177    func.sig.ident = Ident::new(name.as_ref(), Span::call_site());
178
179    let inputs = func.sig.inputs.clone();
180    let mut args = inputs.iter().map(|t|t.clone());
181    for expr in case.values {
182        if let Some(arg) = args.next() {
183            insert_param(&mut func.block, arg, expr);
184        }
185    }
186
187    func.sig.inputs = syn::punctuated::Punctuated::from_iter(args);
188    func
189}
190
191/// Replaces one function parameter with one attribute case argument
192fn insert_param(block: &mut Box<Block>, arg: FnArg, init:Box<Expr>){
193    match arg {
194        FnArg::Typed(arg) => block.stmts.insert(0, Stmt::Local(Local {
195            attrs: vec![],
196            let_token: Let { span: Span::call_site() },
197            pat: Pat::Type(arg),
198            init: Some((Eq{ spans: [Span::call_site()] }, init)),
199            semi_token: Semi { spans: [Span::call_site()] },
200        })),
201        FnArg::Receiver(_) => emit_error!(
202            arg,
203            "Devbox: Parametrized test applied to non-associated function"
204        )
205    }
206}
207
208//-- Attribute parser ------------------------------------------------------------------------------
209
210struct Case {
211    pub ident: Ident,
212    pub colon: Token![:],
213    pub values: Vec<Box<Expr>>,
214    pub panics: Option<LitStr>,
215}
216
217impl Parse for Case {
218    fn parse(input: ParseStream) -> Result<Self> {
219        Ok(Case {
220            ident: input.parse()?,
221            colon: input.parse()?,
222            values: {
223                let mut result = vec![Box::new(input.parse()?)];
224                let mut more: Option<Token![,]> = input.parse()?;
225                while more.is_some() {
226                    result.push(Box::new(input.parse()?));
227                    more = input.parse()?;
228                }
229                result
230            },
231            panics: {
232                let excl: Option<Token![!]> = input.parse()?;
233                if excl.is_some() {
234                    input.parse()?
235                } else {
236                    None
237                }
238            }
239        })
240    }
241}
242
243struct Cases(Punctuated<Case, Token![;]>);
244
245impl Parse for Cases {
246    fn parse(input: ParseStream) -> Result<Self> {
247        Ok(Cases(input.parse_terminated(Case::parse)?))
248    }
249}