extern crate proc_macro;
mod ignore_matcher;
mod parse;
mod utils;
use ignore_matcher::{IgnoreMatcher, MatchResult};
use parse::{ignore_attribute::IgnoreAttribute, spanned::Spanned};
use proc_macro::TokenStream;
use proc_macro_error::{emit_warning, proc_macro_error};
use quote::quote;
use syn::{
parse_macro_input, parse_quote, punctuated::Punctuated, AttrStyle, FnArg, Ident, ItemFn,
LitStr, Meta, Pat, Path, Token,
};
use unicode_xid::UnicodeXID;
struct TestFnExpansion {
ident: Ident,
tokens: proc_macro2::TokenStream,
}
#[proc_macro_error]
#[proc_macro_attribute]
pub fn fixtures(args: TokenStream, input: TokenStream) -> TokenStream {
let args = parse_macro_input!(args as parse::args::Args);
let test_fn = parse_macro_input!(input as ItemFn);
let fn_name = &test_fn.sig.ident;
let fn_args = &test_fn.sig.inputs;
let fn_output = &test_fn.sig.output;
let fn_block = &test_fn.block;
if let Some(ignore) = &args.ignore {
emit_warning!(
ignore.span(),
"Use of deprecated property 'ignore'. Use `#[fixtures::ignore(\"<glob>\")]` instead."
);
}
let (fn_attrs, ignore_attrs) = {
let mut fn_attrs = Vec::new();
let mut ignore_attrs = Vec::new();
for attr in &test_fn.attrs {
match IgnoreAttribute::try_from_attribute(attr) {
Ok(None) => fn_attrs.push(attr),
Ok(Some(ignore_config)) => ignore_attrs.push(ignore_config),
Err(err) => return err.into_compile_error().into(),
}
}
(fn_attrs, ignore_attrs)
};
let current_dir = std::env::current_dir().expect("failed to get current directory");
let paths_iterator = globwalk::GlobWalkerBuilder::from_patterns(
¤t_dir,
&args
.include
.paths()
.iter()
.map(|lit_glob_path| lit_glob_path.value())
.collect::<Vec<_>>(),
)
.build()
.expect("failed to build glob walker")
.filter_map(Result::ok);
let ignore_matcher = match IgnoreMatcher::new(&args.ignore, &ignore_attrs, current_dir) {
Ok(matcher) => matcher,
Err((span, err)) => {
return syn::Error::new(span, format!("{err}"))
.to_compile_error()
.into();
}
};
let fn_non_path_args = {
let mut remaining_args = Punctuated::<&FnArg, Token![,]>::new();
for fn_arg in fn_args.iter().skip(1) {
remaining_args.push(fn_arg);
}
remaining_args
};
let fn_non_path_args_idents = {
let mut idents = Punctuated::<&Ident, Token![,]>::new();
for arg in fn_non_path_args.iter() {
if let FnArg::Typed(pat_ty) = arg {
if let Pat::Ident(ident) = pat_ty.pat.as_ref() {
idents.push(&ident.ident);
continue;
}
return syn::Error::new(arg.span(), "Expected an identifier, but found a pattern")
.to_compile_error()
.into();
}
return syn::Error::new(arg.span(), "Unexpected receiver argument")
.to_compile_error()
.into();
}
idents
};
let is_test = fn_attrs.iter().any(|attr| {
if attr.style != AttrStyle::Outer {
return false;
}
if let Meta::Path(Path {
leading_colon: None,
segments,
}) = &attr.meta
{
if segments.len() != 1 {
return false;
}
let path_segment = segments.first().unwrap();
return path_segment.ident == "test";
}
false
});
if !is_test {
if let Some(ignore) = &args.ignore {
return syn::Error::new(ignore.span(), "The ignore option is only valid for test functions. This function doesn't have a `#[test]` attribute.")
.to_compile_error()
.into();
}
if let Some(ignore_attr) = ignore_attrs.first() {
return syn::Error::new(ignore_attr.span(), "The ignore option is only valid for test functions. This function doesn't have a `#[test]` attribute.")
.to_compile_error()
.into();
}
}
let mut file_names = std::collections::HashMap::new();
let expansions = paths_iterator
.filter_map(|path| {
let file_name = path.file_name().to_str()?.to_owned();
let fn_file_name = file_name_to_valid_identifier(&file_name);
let lit_file_path = LitStr::new(
path.path()
.to_str()
.expect("file path should be valid UTF-8"),
args.include.span(),
);
let similar_file_names = file_names.entry(file_name.clone()).or_insert(0usize);
*similar_file_names += 1;
let ident = if *similar_file_names == 1 {
Ident::new(&fn_file_name, fn_name.span())
} else {
Ident::new(
&format!("{fn_file_name}_{similar_file_names}"),
fn_name.span(),
)
};
let maybe_ignore_attr = match ignore_matcher.matched(path.path()) {
MatchResult::Matched {
reason: Some(reason),
} => parse_quote!(#[ignore = #reason]),
MatchResult::Matched { reason: None } => parse_quote!(#[ignore]),
MatchResult::Unmatched => proc_macro2::TokenStream::new(),
};
let tokens = quote! {
#(#fn_attrs)*
#maybe_ignore_attr
pub fn #ident(#fn_non_path_args) #fn_output {
#fn_name(::std::path::Path::new(#lit_file_path), #fn_non_path_args_idents)
}
};
Some(TestFnExpansion { ident, tokens })
})
.collect::<Vec<_>>();
if expansions.is_empty() {
return syn::Error::new(args.include.span(), "No valid files found".to_string())
.into_compile_error()
.into();
}
let fn_expansions = expansions.iter().map(|expansion| &expansion.tokens);
let expansion_idents = {
let mut impl_idents = Punctuated::<&Ident, Token![,]>::new();
for expansion in expansions.iter() {
impl_idents.push(&expansion.ident);
}
impl_idents
};
let maybe_cfg_test_attr = if is_test {
parse_quote!(#[cfg(test)])
} else {
proc_macro2::TokenStream::new()
};
let output = quote! {
#maybe_cfg_test_attr
fn #fn_name(#fn_args) #fn_output #fn_block
#maybe_cfg_test_attr
mod #fn_name {
use super::*;
#(#fn_expansions)*
pub const EXPANSIONS: &[fn(#fn_non_path_args) #fn_output] = &[#expansion_idents];
}
};
output.into()
}
fn file_name_to_valid_identifier(file_name: &str) -> String {
if file_name.is_empty() {
return "test".to_string();
}
if file_name == "_" {
return "__".to_string();
}
if is_rust_keyword(file_name) {
return format!("_{file_name}");
}
file_name
.chars()
.enumerate()
.map(|(i, c)| match (i, c) {
(0, '.') => "dot_".to_string(),
(0, c) if c.is_numeric() => format!("_{c}"),
(0, c) if c != '_' && !UnicodeXID::is_xid_start(c) => "_".to_string(),
(_, '.') => "_dot_".to_string(),
(_, c) if !UnicodeXID::is_xid_continue(c) => "_".to_string(),
_ => c.to_string(),
})
.collect()
}
fn is_rust_keyword(s: &str) -> bool {
matches!(
s,
"as" | "break"
| "const"
| "continue"
| "crate"
| "else"
| "enum"
| "extern"
| "false"
| "fn"
| "for"
| "if"
| "impl"
| "in"
| "let"
| "loop"
| "match"
| "mod"
| "move"
| "mut"
| "pub"
| "ref"
| "return"
| "self"
| "Self"
| "static"
| "struct"
| "super"
| "trait"
| "true"
| "type"
| "unsafe"
| "use"
| "where"
| "while"
| "async"
| "await"
| "dyn"
| "abstract"
| "become"
| "box"
| "do"
| "final"
| "macro"
| "override"
| "priv"
| "typeof"
| "unsized"
| "virtual"
| "yield"
| "try"
| "gen",
)
}