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}