testing_macros/
fixture.rs1use std::{
2 env,
3 path::{Component, PathBuf},
4};
5
6use anyhow::{Context, Error};
7use glob::glob;
8use once_cell::sync::Lazy;
9use proc_macro2::{Span, TokenStream};
10use quote::quote;
11use regex::Regex;
12use relative_path::RelativePath;
13use syn::{
14 parse::{Parse, ParseStream},
15 parse2,
16 punctuated::Punctuated,
17 Ident, LitStr, Meta, Token,
18};
19
20pub struct Config {
21 pattern: String,
22 exclude_patterns: Vec<Regex>,
23}
24
25impl Parse for Config {
26 fn parse(input: ParseStream) -> syn::Result<Self> {
27 fn update(c: &mut Config, meta: Meta) {
28 if let Meta::List(list) = &meta {
29 if list
30 .path
31 .get_ident()
32 .map(|i| *i == "exclude")
33 .unwrap_or(false)
34 {
35 macro_rules! fail {
37 () => {{
38 fail!("invalid input to the attribute")
39 }};
40 ($inner:expr) => {{
41 panic!(
42 "{}\nnote: exclude() expects one or more comma-separated regular \
43 expressions, like exclude(\".*\\\\.d\\\\.ts\") or \
44 exclude(\".*\\\\.d\\\\.ts\", \".*\\\\.tsx\")",
45 $inner
46 )
47 }};
48 }
49
50 if list.tokens.is_empty() {
51 fail!("empty exclude()")
52 }
53
54 let input = parse2::<InputParen>(list.tokens.clone())
55 .expect("failed to parse token as `InputParen`");
56
57 for lit in input.input {
58 c.exclude_patterns
59 .push(Regex::new(&lit.value()).unwrap_or_else(|err| {
60 fail!(format!("failed to parse regex: {}\n{}", lit.value(), err))
61 }));
62 }
63
64 return;
65 }
66 }
67
68 let expected = r#"#[fixture("fixture/**/*.ts", exclude("*\.d\.ts"))]"#;
69
70 unimplemented!(
71 "Expected something like {}\nGot wrong meta tag: {:?}",
72 expected,
73 meta,
74 )
75 }
76
77 let pattern: LitStr = input.parse()?;
78 let pattern = pattern.value();
79
80 let mut config = Self {
81 pattern,
82 exclude_patterns: Vec::new(),
83 };
84
85 let comma: Option<Token![,]> = input.parse()?;
86 if comma.is_some() {
87 let meta: Meta = input.parse()?;
88 update(&mut config, meta);
89 }
90
91 Ok(config)
92 }
93}
94
95pub fn expand(callee: &Ident, attr: Config) -> Result<Vec<TokenStream>, Error> {
96 let base_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect(
97 "#[fixture] requires CARGO_MANIFEST_DIR because it's relative to cargo manifest directory",
98 ));
99 let resolved_path = RelativePath::new(&attr.pattern).to_path(&base_dir);
100 let pattern = resolved_path.to_string_lossy();
101
102 let paths =
103 glob(&pattern).with_context(|| format!("glob failed for whole path: `{pattern}`"))?;
104 let mut test_fns = Vec::new();
105 static RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"[^A-Za-z0-9_]").unwrap());
107
108 'add: for path in paths {
109 let path = path.with_context(|| "glob failed for file".to_string())?;
110 let abs_path = path
111 .canonicalize()
112 .with_context(|| format!("failed to canonicalize {}", path.display()))?;
113
114 let path_for_name = path.strip_prefix(&base_dir).with_context(|| {
115 format!(
116 "Failed to strip prefix `{}` from `{}`",
117 base_dir.display(),
118 path.display()
119 )
120 })?;
121 let path_str = path.to_string_lossy();
122 for pattern in &attr.exclude_patterns {
124 if pattern.is_match(&path_str) {
125 continue 'add;
126 }
127
128 if cfg!(target_os = "windows") && pattern.is_match(&path_str.replace('\\', "/")) {
129 continue 'add;
130 }
131 }
132
133 let ignored = path.components().any(|c| match c {
134 Component::Normal(s) => s.to_string_lossy().starts_with('.'),
135 _ => false,
136 });
137 let test_name = format!(
138 "{}_{}",
139 callee,
140 RE.replace_all(
141 path_for_name
142 .to_string_lossy()
143 .replace(['\\', '/'], "__")
144 .as_str(),
145 "_",
146 )
147 )
148 .replace("___", "__");
149 let test_ident = Ident::new(&test_name, Span::call_site());
150
151 let ignored_attr = if ignored { quote!(#[ignore]) } else { quote!() };
152
153 let path_str = abs_path.to_string_lossy();
154 let f = quote!(
155 #[test]
156 #[inline(never)]
157 #[doc(hidden)]
158 #[allow(non_snake_case)]
159 #ignored_attr
160 fn #test_ident() {
161 eprintln!("Input: {}", #path_str);
162
163 #callee(::std::path::PathBuf::from(#path_str));
164 }
165 );
166
167 test_fns.push(f);
168 }
169
170 if test_fns.is_empty() {
171 panic!("No test found")
172 }
173
174 Ok(test_fns)
175}
176
177struct InputParen {
178 input: Punctuated<LitStr, Token![,]>,
179}
180
181impl Parse for InputParen {
182 fn parse(input: ParseStream) -> syn::Result<Self> {
183 Ok(Self {
184 input: input.call(Punctuated::parse_terminated)?,
185 })
186 }
187}