rustleaf_macros_internal/
lib.rs

1use proc_macro::TokenStream;
2use quote::quote;
3use std::fs;
4use std::path::Path;
5use syn::{parse_macro_input, LitStr};
6
7#[derive(Debug, Clone)]
8enum TestType {
9    Normal,
10    Panic,
11    Ignore,
12}
13
14// Test discovery implementation
15#[proc_macro_attribute]
16pub fn rustleaf_tests(args: TokenStream, _input: TokenStream) -> TokenStream {
17    let test_dir = parse_macro_input!(args as LitStr);
18    let test_dir_path = test_dir.value();
19
20    // Read directory and find .rustleaf files
21    let test_files = match discover_rustleaf_files(&test_dir_path) {
22        Ok(files) => files,
23        Err(e) => panic!("Failed to read test directory '{test_dir_path}': {e}"),
24    };
25
26    // Generate individual test functions that use include_str!
27    let test_functions =
28        test_files
29            .iter()
30            .map(|(test_name, include_path, test_type, full_path)| {
31                let test_fn_name = syn::Ident::new(test_name, proc_macro2::Span::call_site());
32
33                let is_panic_test = matches!(test_type, TestType::Panic);
34                let test_body = quote! {
35                    // Read the markdown file and extract rustleaf code block
36                    let md_content = include_str!(#include_path);
37
38                    let extract_rustleaf_code_block = |md_content: &str| -> Option<String> {
39                        let lines: Vec<&str> = md_content.lines().collect();
40                        let mut in_rustleaf_block = false;
41                        let mut code_lines = Vec::new();
42
43                        for line in lines {
44                            if line.trim() == "```rustleaf" {
45                                in_rustleaf_block = true;
46                                continue;
47                            }
48                            if line.trim() == "```" && in_rustleaf_block {
49                                break;
50                            }
51                            if in_rustleaf_block {
52                                code_lines.push(line);
53                            }
54                        }
55
56                        if code_lines.is_empty() {
57                            None
58                        } else {
59                            Some(code_lines.join("\n"))
60                        }
61                    };
62
63                    let update_markdown_with_results = |_original_md: &str, source: &str, circle: &str,
64                        output_section: &str, execution_output: &str, lex_output: &str,
65                        parse_output: &str, eval_output: &str, assertion_count: u32| -> String {
66                        // Reconstruct the entire markdown from template
67                        let output_display = if output_section == "None" {
68                            format!("# Output\n{}", output_section)
69                        } else {
70                            format!("# Output\n```\n{}\n```", output_section)
71                        };
72
73                        format!(
74                            "# Program\nStatus: {}\nAssertions: {}\n\n```rustleaf\n{}\n```\n\n{}\n\n# Result\n```rust\n{}\n```\n\n# Lex\n```rust\n{}\n```\n\n# Parse\n```rust\n{}\n```\n\n# Eval\n```rust\n{}\n```",
75                            circle, assertion_count, source, output_display, execution_output, lex_output, parse_output, eval_output
76                        )
77                    };
78
79                    let source = extract_rustleaf_code_block(md_content)
80                        .expect("Failed to find rustleaf code block in markdown file");
81
82                    println!("DEBUG: Starting test for file: {}", #full_path);
83                    println!("DEBUG: Extracted source code: {}", source);
84
85                    // Start print capture early to capture parser traces
86                    rustleaf::core::start_print_capture();
87                    rustleaf::core::start_assertion_count();
88
89                    // Try lexing
90                    println!("DEBUG: Starting lexing phase");
91                    let tokens_result = rustleaf::lexer::Lexer::tokenize(&source);
92                    let lex_output = match &tokens_result {
93                        Ok(tokens) => {
94                            let formatted_tokens: Vec<String> = tokens.iter().enumerate()
95                                .map(|(i, token)| format!("{}: {:?}", i, token))
96                                .collect();
97                            format!("Ok(\n    [\n        {}\n    ],\n)", formatted_tokens.join(",\n        "))
98                        }
99                        Err(e) => format!("Err({:#?})", e)
100                    };
101                    println!("DEBUG: Lexing completed");
102
103                    // Try parsing (only if lexing succeeded)
104                    println!("DEBUG: Starting parsing phase");
105                    let parse_result = match &tokens_result {
106                        Ok(_) => rustleaf::parser::Parser::parse_str(&source),
107                        Err(e) => Err(anyhow::Error::msg(format!("Lex error: {}", e))),
108                    };
109                    let parse_output = format!("{:#?}", parse_result);
110                    println!("DEBUG: Parsing completed");
111
112                    // Try compiling to eval IR (only if parsing succeeded)
113                    println!("DEBUG: Starting compilation phase");
114                    let eval_output = match &parse_result {
115                        Ok(ast) => {
116                            let eval_result = rustleaf::eval::Compiler::compile(ast.clone());
117                            format!("{:#?}", eval_result)
118                        }
119                        Err(_) => "Skipped due to parse error".to_string(),
120                    };
121                    println!("DEBUG: Compilation completed");
122
123                    // Try evaluation (only if all previous stages succeeded)
124                    println!("DEBUG: Starting evaluation phase");
125                    let (output_section, execution_output, eval_success, final_result, assertion_count) = match parse_result {
126                        Ok(ast) => {
127                            println!("DEBUG: AST parsing successful, starting evaluation setup");
128                            // Note: Print capture and assertion counting already started before parsing
129
130                            // Get the directory of the test file for module imports
131                            let test_file_dir = std::path::Path::new(#full_path).parent().map(|p| p.to_path_buf());
132                            println!("DEBUG: Test file directory: {:?}", test_file_dir);
133
134                            println!("DEBUG: About to call evaluate_with_dir - this is where the stack overflow likely occurs");
135                            let result = rustleaf::eval::evaluate_with_dir(ast, test_file_dir);
136                            println!("DEBUG: evaluate_with_dir returned successfully");
137                            // Get all captured output (includes parser traces and print statements)
138                            let captured_output = rustleaf::core::get_captured_prints();
139                            let assertion_count = rustleaf::core::get_assertion_count();
140                            let execution_output = format!("{:#?}", result).replace("\\n", "\n");
141                            println!("DEBUG: Captured {} prints, {} assertions", captured_output.len(), assertion_count);
142
143                            let output_section = if captured_output.is_empty() {
144                                "None".to_string()
145                            } else {
146                                captured_output.join("\n")
147                            };
148
149                            let eval_success = result.is_ok();
150                            (output_section, execution_output, eval_success, result, assertion_count)
151                        }
152                        Err(parse_error) => {
153                            println!("DEBUG: Parse error occurred: {}", parse_error);
154                            // Even on parse error, get any captured output (parser traces)
155                            let captured_output = rustleaf::core::get_captured_prints();
156                            let assertion_count = rustleaf::core::get_assertion_count();
157
158                            let output_section = if captured_output.is_empty() {
159                                "None".to_string()
160                            } else {
161                                captured_output.join("\n")
162                            };
163
164                            let error_msg = format!("Parse error: {}", parse_error);
165                            let error_result = Err(anyhow::Error::msg(error_msg.clone()));
166                            (output_section, "Skipped due to parse error".to_string(), false, error_result, assertion_count)
167                        }
168                    };
169                    println!("DEBUG: Evaluation completed");
170
171                    // For panic tests, success means it should fail (red circle = expected behavior)
172                    // For normal tests, success means it should succeed (green circle = expected behavior)
173                    println!("DEBUG: Determining test result (eval_success: {}, assertion_count: {})", eval_success, assertion_count);
174                    let circle = if #is_panic_test {
175                        if eval_success { "🔴" } else { "🟢" }
176                    } else {
177                        if eval_success {
178                            // Yellow circle for passing tests with no assertions
179                            if assertion_count == 0 { "🟡" } else { "🟢" }
180                        } else {
181                            "🔴"
182                        }
183                    };
184                    println!("DEBUG: Test result circle: {}", circle);
185
186                    // Update the markdown file in-place with test results
187                    println!("DEBUG: Updating markdown file with results");
188                    let updated_md_content = update_markdown_with_results(
189                        md_content, &source, &circle, &output_section, &execution_output,
190                        &lex_output, &parse_output, &eval_output, assertion_count
191                    );
192                    std::fs::write(#full_path, updated_md_content).unwrap();
193                    println!("DEBUG: Markdown file updated successfully");
194
195                    // Return actual result - let cargo handle expectations
196                    println!("DEBUG: Test completed, returning final result");
197                    final_result.unwrap();
198                };
199
200                // No need for separate panic test handling since we handle it in the main test body now
201                let test_body = test_body;
202
203                match test_type {
204                    TestType::Ignore => quote! {
205                        #[test]
206                        #[ignore]
207                        fn #test_fn_name() {
208                            #test_body
209                        }
210                    },
211                    TestType::Panic => quote! {
212                        #[test]
213                        #[should_panic]
214                        fn #test_fn_name() {
215                            #test_body
216                        }
217                    },
218                    _ => quote! {
219                        #[test]
220                        fn #test_fn_name() {
221                            #test_body
222                        }
223                    },
224                }
225            });
226
227    let expanded = quote! {
228        #(#test_functions)*
229    };
230
231    TokenStream::from(expanded)
232}
233
234fn discover_rustleaf_files(
235    test_dir: &str,
236) -> Result<Vec<(String, String, TestType, String)>, std::io::Error> {
237    let mut test_files = Vec::new();
238    let test_path = Path::new(test_dir);
239
240    for entry in fs::read_dir(test_path)? {
241        let entry = entry?;
242        let path = entry.path();
243
244        if path.is_file() {
245            if let Some(extension) = path.extension() {
246                if extension == "md" {
247                    let file_path = path.to_string_lossy().to_string();
248
249                    // Generate test function name: strip "./tests/" and convert to function name
250                    let test_name = generate_test_name(&file_path);
251
252                    // Determine test type based on filename suffix
253                    let filename = path.file_name().unwrap().to_string_lossy();
254                    let test_type = if filename.ends_with("_panic.md") {
255                        TestType::Panic
256                    } else if filename.ends_with("_ignore.md") {
257                        TestType::Ignore
258                    } else {
259                        TestType::Normal
260                    };
261
262                    // For include_str!, construct path relative to where the macro is called
263                    // Extract just the subdirectory name from test_dir and filename
264                    let test_dir_name = Path::new(test_dir)
265                        .file_name()
266                        .unwrap_or_else(|| std::ffi::OsStr::new(""))
267                        .to_string_lossy();
268                    let include_path = format!("{test_dir_name}/{filename}");
269
270                    test_files.push((test_name, include_path, test_type, file_path));
271                }
272            }
273        }
274    }
275
276    test_files.sort_by(|a, b| a.0.cmp(&b.0)); // Sort by test name for consistent ordering
277    Ok(test_files)
278}
279
280fn generate_test_name(file_path: &str) -> String {
281    // Strip "./tests/" prefix if present
282    let relative_path = if let Some(stripped) = file_path.strip_prefix("./tests/") {
283        stripped // Remove "./tests/"
284    } else if let Some(stripped) = file_path.strip_prefix("tests/") {
285        stripped // Remove "tests/"
286    } else {
287        file_path
288    };
289
290    // Remove .md extension and convert path separators to underscores
291    let without_extension = if let Some(stem) = Path::new(relative_path).file_stem() {
292        let parent = Path::new(relative_path).parent().unwrap_or(Path::new(""));
293        if parent.as_os_str().is_empty() {
294            stem.to_string_lossy().to_string()
295        } else {
296            format!("{}/{}", parent.to_string_lossy(), stem.to_string_lossy())
297        }
298    } else {
299        relative_path.to_string()
300    };
301
302    // Convert path separators to underscores
303    without_extension.replace(['/', '\\'], "_")
304}