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, ¯o_input)))
46 .collect::<Vec<(Vec<String>, TokenStream)>>();
47
48 let mut modules = HashMap::<String, Vec<TokenStream>>::new();
49 for (hierarchy, test) in tests {
50 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}