galvan_test_macro/
lib.rs

1use proc_macro2::{Ident, TokenStream};
2use quote::{format_ident, quote};
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use syn::parse::{Parse, ParseStream};
6use syn::{Expr, Result, Token};
7
8struct MacroInput {
9    prefix: Ident,
10    tag: String,
11    operation: Option<Ident>,
12    processed: Expr,
13}
14
15impl Parse for MacroInput {
16    fn parse(input: ParseStream) -> Result<Self> {
17        let prefix = input.parse::<Ident>()?;
18        input.parse::<Token![,]>()?;
19        let tag = input.parse::<Ident>()?.to_string();
20        input.parse::<Token![,]>()?;
21        let operation = input.parse::<Ident>().ok();
22        if operation.is_some() {
23            let _ = input.parse::<Token![,]>();
24        }
25        let processed = input.parse::<Expr>()?;
26        Ok(MacroInput {
27            prefix,
28            tag,
29            operation,
30            processed,
31        })
32    }
33}
34
35#[proc_macro]
36pub fn generate_code_tests(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
37    let macro_input = syn::parse_macro_input!(input as MacroInput);
38
39    let tests = walkdir::WalkDir::new("example-code")
40        .into_iter()
41        .map(|entry| entry.unwrap())
42        .filter(|entry| entry.file_type().is_file())
43        .filter(|entry| !entry.file_name().to_string_lossy().starts_with('_'))
44        .map(|entry| entry.path().to_path_buf())
45        .map(|path| (module_hierarchy(&path), generate_test(path, &macro_input)))
46        .collect::<Vec<(Vec<String>, TokenStream)>>();
47
48    let mut modules = HashMap::<String, Vec<TokenStream>>::new();
49    for (hierarchy, test) in tests {
50        // TODO: Generate nested module hierarchy
51        let module_name = hierarchy.join("_");
52
53        if let Some(tests) = modules.get_mut(&module_name) {
54            tests.push(test);
55            continue;
56        } else {
57            modules.insert(module_name, vec![test]);
58        }
59    }
60
61    modules
62        .into_iter()
63        .map(|(module_name, tests)| {
64            if module_name.is_empty() {
65                quote! {
66                    #(#tests)*
67                }
68            } else {
69                let module_name = format_ident!("{}", module_name);
70                quote! {
71                    mod #module_name {
72                        use super::*;
73                        #(#tests)*
74                    }
75                }
76            }
77        })
78        .collect::<TokenStream>()
79        .into()
80}
81
82fn module_hierarchy(path: &Path) -> Vec<String> {
83    let relative = &path.strip_prefix("example-code").unwrap();
84
85    let mut hierarchy = relative
86        .components()
87        .map(|c| c.as_os_str().to_str().unwrap().to_string())
88        .collect::<Vec<_>>();
89    hierarchy.pop();
90    hierarchy
91}
92
93fn generate_test(path: PathBuf, macro_input: &MacroInput) -> TokenStream {
94    let MacroInput {
95        prefix,
96        tag,
97        operation,
98        processed,
99    } = macro_input;
100    let name = path.file_stem().unwrap().to_str().unwrap();
101
102    let Ok(test_file) = std::fs::read_to_string(&path) else {
103        panic!("Test file not found!")
104    };
105
106    let Ok(expected_struct) = expected_result(&test_file, tag).map_err(|e| match e {
107        TestError::TagNotClosed => panic!("Tag not closed in test file!"),
108        TestError::InvalidRustCode => panic!("Invalid Rust code in test file!"),
109        TestError::TagNotFound => e,
110    }) else {
111        return quote! {};
112    };
113
114    let code = test_file;
115
116    let test_name = format_ident!("{prefix}_{name}");
117    if let Some(operation) = operation {
118        quote! {
119            #[test]
120            fn #test_name() {
121                let code = #code;
122                let expected_struct = #expected_struct.#operation();
123                let actual_struct = #processed.#operation();
124                assert_eq!(expected_struct, actual_struct);
125            }
126        }
127    } else {
128        quote! {
129            #[test]
130            fn #test_name() {
131                let code = #code;
132                let expected_struct = #expected_struct;
133                let actual_struct = #processed;
134                assert_eq!(expected_struct, actual_struct);
135            }
136        }
137    }
138}
139
140enum TestError {
141    TagNotFound,
142    TagNotClosed,
143    InvalidRustCode,
144}
145
146fn expected_result(test_file: &str, tag: &str) -> std::result::Result<TokenStream, TestError> {
147    let prefix = "/*#";
148    let mut lines = vec![];
149    let mut iter = test_file.lines();
150
151    loop {
152        match iter.next() {
153            Some(line)
154                if line
155                    .strip_prefix(prefix)
156                    .is_some_and(|s| s.trim().starts_with(tag)) =>
157            {
158                break
159            }
160            Some(_) => continue,
161            None => return Err(TestError::TagNotFound),
162        }
163    }
164
165    loop {
166        match iter.next() {
167            Some(line) if line.starts_with("*/") => break,
168            Some(line) => lines.push(line),
169            None => return Err(TestError::TagNotClosed),
170        }
171    }
172
173    let code = lines.join("\n");
174    code.parse::<TokenStream>()
175        .map_err(|_| TestError::InvalidRustCode)
176}