testing_macros/
fixture.rs

1use 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                    //
36                    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    // Allow only alphanumeric and underscore characters for the test_name.
106    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        // Skip excluded files
123        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}