fn_fixture_lib/
lib.rs

1//! This crate exists only as a way of isolating the internal
2//! functionality of [`fn-fixture`] in a way that allows self-testing.
3//!
4//! [`fn-fixture`]: https://docs.rs/fn-fixture/
5
6use std::{
7    cmp::Ordering,
8    env::var,
9    fs::DirEntry,
10    path::PathBuf,
11    format_args as fmt,
12};
13
14use proc_macro2::{
15    Ident,
16    Literal,
17    Span,
18    TokenStream,
19};
20use smallvec::SmallVec;
21use syn::{
22    ExprLit,
23    FnArg,
24    ItemFn,
25    Lit,
26    parse2,
27    parse_str,
28    Pat,
29    PatIdent,
30    PatType,
31    Signature,
32    Type,
33    Generics,
34};
35
36use quote::{
37    quote,
38    ToTokens,
39};
40
41mod traits;
42
43use self::traits::*;
44
45const INPUT_TXT: &str = "input.txt";
46const INPUT_RS: &str = "input.rs";
47const INPUT_BIN: &str = "input.bin";
48
49#[doc(hidden)]
50pub fn make_snapshots(path_attr: &TokenStream, item: &TokenStream) -> Result<TokenStream, TokenStream> {
51    let (
52        name,
53        Generics {
54            lt_token: generic_lt,
55            gt_token: generic_gt,
56            params: generic_params,
57            where_clause: generic_where,
58        },
59        (param_name, param_type),
60    ) = pull_function_description(item.clone())?;
61
62    let actual_file_name = {
63        let mut base_name = name.to_string();
64        base_name.push_str(".actual.txt");
65        base_name
66    };
67    let expected_file_name = {
68        let mut base_name = name.to_string();
69        base_name.push_str(".txt");
70        base_name
71    };
72
73    if expected_file_name == INPUT_TXT {
74        return ().compile_error(fmt!("Cannot use that name, as it conflicts with {} detection", INPUT_TXT))
75    }
76
77    let base_name = name.to_token_stream();
78
79    let path = {
80        let mut path = PathBuf::new();
81        path.push(var("CARGO_MANIFEST_DIR").compile_err("No manifest directory env")?);
82        path.push(
83            if let Lit::Str(string) =
84            parse2::<ExprLit>(path_attr.clone())
85                .compile_error(fmt!("Expected literal path in attribute, received: {}\n\n", path_attr))?
86                .lit
87            {
88                string.value()
89            } else {
90                return ().compile_error(fmt!("Expected literal path: {}", path_attr))
91            }
92        );
93        path
94    };
95
96    let tag: TokenStream = "#[test]".parse().compile_err("Failed to init tag")?;
97    let supers: TokenStream = "super::".parse().compile_err("Failed to init supers")?;
98
99    let outputs = nested_fixtures(
100        sort_dir(path
101            .read_dir()
102            .compile_error(fmt!("Failed to read {:?}", path))?
103        )
104            .into_iter()
105            .map(|result|
106                result.compile_error(fmt!("Failed to read in {:?}", path))
107            ),
108        &TokenStream::new(),
109        &Params {
110            tag,
111            base_name,
112            supers,
113            actual_file_name,
114            expected_file_name,
115        }
116    );
117
118    // <String> panics come from the formatted panic!, including .unwrap/.expect
119    // <&str> panics come from unformatted panic!, like panic!("Nooo!")
120    Ok(quote! {
121        fn #name #generic_lt #generic_params #generic_gt (mut #param_name: (
122            impl std::ops::Fn(&mut std::option::Option<#param_type>) + std::panic::RefUnwindSafe + std::panic::UnwindSafe,
123            &'static str,
124            &'static str,
125         )) #generic_where {
126            #item
127
128            let (to_call, (provider, expected_file, actual_file)) =
129                (&#name, #param_name);
130
131            let result = format!(
132                "{:#?}\n",
133                std::panic::catch_unwind(
134                    move || {
135                        let mut temp = std::option::Option::None;
136                        provider(&mut temp);
137                        to_call(temp.unwrap())
138                    }
139                ).map_err(|err| err
140                    .downcast::<String>()
141                    .or_else(|err|
142                        if let Some(string) = err.downcast_ref::<&str>() {
143                            std::result::Result::Ok(std::boxed::Box::new(string.to_string()))
144                        } else {
145                            std::result::Result::Err(("<!String> Panic", err))
146                        }
147                    )
148                    .map(|ok| ("<String> Panic", ok))
149                )
150            );
151            if std::path::Path::new(expected_file).is_file() {
152                let expected = std::fs::read_to_string(expected_file)
153                    .unwrap_or_else(|err|
154                        panic!("Reading expected from {}: {:?}", expected_file, err)
155                    );
156                assert_eq!(result, expected)
157            } else {
158                std::fs::write(actual_file, result.as_bytes())
159                    .unwrap_or_else(|err|
160                        panic!("Writing actual to {}: {:?}", actual_file, err)
161                    );
162                panic!("No expected value set: {}", actual_file)
163            }
164        }
165
166        mod #name {
167            #outputs
168        }
169    })
170}
171
172fn pull_function_description(item: TokenStream) -> Result<(Ident, Generics, (Ident, Type)), TokenStream> {
173    let Signature {
174        ident: name,
175        inputs: param,
176        generics,
177        ..
178    } = parse2::<ItemFn>(item.clone())
179        .compile_error(fmt!("Expected attribute must be on a function, received: {}\n\n", item))?
180        .sig;
181    let param: SmallVec<[FnArg; 1]> = param.into_iter().collect();
182    let param = match param.into_inner() {
183        Ok([param]) => param,
184        Err(ref param) if param.is_empty() => return ().compile_err("No input parameter"),
185        Err(param) => return ().compile_error(fmt!(
186            "Expected one parameter, received {}",
187            param
188                .into_iter()
189                .map(FnArg::into_token_stream)
190                .flatten()
191                .collect::<TokenStream>()
192        )),
193    };
194    let (param_type, param_name) = match param {
195        FnArg::Typed(PatType { pat, ty, .. }) => (*ty, *pat),
196        param => return ().compile_error(fmt!("Unexpected self in {}", param.into_token_stream())),
197    };
198    let param_name = match param_name {
199        Pat::Ident(PatIdent { ident, .. }) => ident,
200        pat => return ().compile_error(fmt!("Expected parameter, received {}", pat.into_token_stream())),
201    };
202    if format!("{}", param_name) == format!("{}", name) {
203        return ().compile_error(fmt!("Function {} may not share name with its parameter", name));
204    }
205    Ok((name, generics, (param_name, param_type)))
206}
207
208struct Params {
209    tag: TokenStream,
210    base_name: TokenStream,
211    supers: TokenStream,
212    actual_file_name: String,
213    expected_file_name: String,
214}
215
216fn nested_fixtures(
217    folders: impl IntoIterator<Item=Result<DirEntry, TokenStream>>,
218    super_chain: &TokenStream,
219    params: &Params,
220) -> TokenStream {
221    let Params {
222        tag,
223        base_name,
224        supers,
225        actual_file_name,
226        expected_file_name,
227    } = params;
228    let super_chain = {
229        let mut super_chain = super_chain.clone();
230        supers.to_tokens(&mut super_chain);
231        super_chain
232    };
233    folders
234        .into_iter()
235        .map(|result| result.and_then(|fixture: DirEntry| {
236            let fixture_path = fixture
237                .path()
238                .canonicalize()
239                .compile_error(fmt!("Failed to canonicalize fixtures: {:?}", fixture))?;
240            let fixture_name = parse_str::<Ident>(fixture
241                .file_name()
242                .to_str()
243                .compile_error(fmt!("Failed to convert filename to utf8 of {:?}", fixture))?,
244            ).compile_error(fmt!("Failed to convert filename of {:?} into rust identifier", fixture_path))?;
245
246            let mut input_rs = None;
247            let mut input_txt = None;
248            let mut input_bin = None;
249            let mut folders: Option<Vec<_>> = None;
250
251            for file in sort_dir(fixture_path
252                .read_dir()
253                .compile_error(fmt!("Failed to read fixture directory {:?}", fixture_path))?
254            ) {
255                macro_rules! push_err {($ex:expr) => {{
256                    match $ex {
257                        Err(e) => {
258                            folders.get_or_insert_with(Vec::new).push(Err(e));
259                            continue;
260                        },
261                        Ok(value) => value,
262                    }
263                }};}
264
265                let file: DirEntry = push_err!(
266                    file.compile_error(fmt!("Failed to get DirEntry in {:?}", fixture_path))
267                );
268
269                if push_err!(
270                    file.file_type().compile_error(fmt!("Bad file type of {:?}", file))
271                ).is_dir() {
272                    folders
273                        .get_or_insert_with(Vec::new)
274                        .push(Ok(file));
275                    continue;
276                }
277
278                let name = file.file_name();
279                let name = push_err!(
280                    name.to_str().compile_error(fmt!("Unresolvable file name"))
281                );
282
283                let file_pointer = match name {
284                    INPUT_RS => &mut input_rs,
285                    INPUT_TXT => &mut input_txt,
286                    INPUT_BIN => &mut input_bin,
287                    _ => continue,
288                };
289                *file_pointer = Some(file);
290            }
291
292            match (
293                folders.as_ref().map_or(
294                    true,
295                    |folders|
296                        folders.iter().any(Result::is_err),
297                ),
298                &input_rs,
299                &input_bin,
300                &input_txt,
301            ) {
302                // No vec and one file
303                // Vec with error and one file
304                (true, None, Some(_), None) => {},
305                (true, None, None, Some(_)) => {},
306                (true, Some(_), None, None) => {},
307                // Vec without errors and no files
308                (false, None, None, None) => {},
309                // Vec with error and multiple files
310                // Vec with error and no files
311                // No vec and no files
312                // No vec and multiple files
313                _ => folders
314                    .get_or_insert_with(Vec::new)
315                    .push(().compile_error(fmt!(
316                        "Expected sub-directories or exactly one of {}, {}, or {} in {:?}",
317                        INPUT_RS,
318                        INPUT_BIN,
319                        INPUT_TXT,
320                        fixture_path,
321                    ))),
322            }
323
324            let (include, file) = match (folders, input_rs, input_bin, input_txt) {
325                // dir
326                (Some(folders), _, _, _) => {
327                    let fixtures = nested_fixtures(
328                        folders,
329                        &super_chain,
330                        params,
331                    );
332                    return Ok(quote! {
333                        mod #fixture_name {
334                            #fixtures
335                        }
336                    })
337                },
338                // rs
339                (None, Some(file), None, None) => ("include", file),
340                // bin
341                (None, None, Some(file), None) => ("include_bytes", file),
342                // txt
343                (None, None, None, Some(file)) => ("include_str", file),
344                // If there wasn't a single-file, folders would be populated
345                _ => unreachable!(),
346            };
347            // Can't panic; we have them explicitly outlined
348            let include= Ident::new(include, Span::call_site());
349
350            let make_literal = |path: PathBuf| path
351                .to_str()
352                .compile_error(fmt!("Failed to get utf8 string from {:?}", path))
353                .map(Literal::string);
354            let input_literal = make_literal(file.path())?;
355            let actual_literal = make_literal(fixture_path.join(actual_file_name))?;
356            let expected_literal = make_literal(fixture_path.join(expected_file_name))?;
357
358            Ok(quote! {
359                #tag
360                fn #fixture_name() {
361                    #super_chain #base_name((
362                        |#fixture_name: &mut std::option::Option<_>| {
363                            #fixture_name.replace(#include!(#input_literal));
364                        },
365                        #expected_literal,
366                        #actual_literal,
367                    ))
368                }
369            })
370        }))
371        .map(EitherResult::either)
372        .collect()
373}
374
375fn sort_dir<T>(iter: impl IntoIterator<Item=Result<DirEntry, T>>) -> impl IntoIterator<Item=Result<DirEntry, T>> {
376    let mut vec: Vec<_> = iter.into_iter().collect();
377    vec.sort_by(|left, right| match (left, right) {
378        (Ok(left), Ok(right)) => match (left.file_name().to_str(), right.file_name().to_str()) {
379            (Some(left), Some(right)) => left.cmp(right),
380            (None, None) => Ordering::Equal,
381            (Some(_), None) => Ordering::Greater,
382            (None, Some(_)) => Ordering::Less,
383        },
384        (Err(_), Err(_)) => Ordering::Equal,
385        (Ok(_), Err(_)) => Ordering::Greater,
386        (Err(_), Ok(_)) => Ordering::Less,
387    });
388    vec
389}