Skip to main content

seqc/
test_runner.rs

1//! Test runner for Seq test files
2//!
3//! Discovers and executes tests in `test-*.seq` files, reporting results.
4
5use crate::parser::Parser;
6use crate::types::{Effect, StackType};
7use crate::{CompilerConfig, compile_file_with_config};
8use std::fs;
9use std::path::{Path, PathBuf};
10use std::process::Command;
11use std::time::Instant;
12
13/// Result of running a single test
14#[derive(Debug)]
15pub struct TestResult {
16    /// Name of the test function
17    pub name: String,
18    /// Whether the test passed
19    pub passed: bool,
20    /// Duration in milliseconds
21    pub duration_ms: u64,
22    /// Error output if test failed
23    pub error_output: Option<String>,
24}
25
26/// Summary of all test results
27#[derive(Debug, Default)]
28pub struct TestSummary {
29    /// Total tests run
30    pub total: usize,
31    /// Tests passed
32    pub passed: usize,
33    /// Tests failed
34    pub failed: usize,
35    /// Files that failed to compile
36    pub compile_failures: usize,
37    /// Results by file
38    pub file_results: Vec<FileTestResults>,
39}
40
41impl TestSummary {
42    /// Returns true if any tests failed or any files failed to compile
43    pub fn has_failures(&self) -> bool {
44        self.failed > 0 || self.compile_failures > 0
45    }
46}
47
48/// Results for a single test file
49#[derive(Debug)]
50pub struct FileTestResults {
51    /// Path to the test file
52    pub path: PathBuf,
53    /// Individual test results
54    pub tests: Vec<TestResult>,
55    /// Words named `test-*` whose stack effect isn't `( -- )` and were
56    /// therefore not promoted to test entry points. Surfaced so the user
57    /// can tell the difference between "helper picked up by accident"
58    /// (issue #435) and "test silently disappeared".
59    pub skipped: Vec<SkippedTest>,
60    /// Compilation error if file failed to compile
61    pub compile_error: Option<String>,
62}
63
64/// A `test-*` word that was discovered by name but skipped because its
65/// stack effect isn't `( -- )`. Used for diagnostics, not execution.
66#[derive(Debug, Clone)]
67pub struct SkippedTest {
68    /// Word name as written in source
69    pub name: String,
70    /// Human-readable reason — either a surface stack effect like
71    /// `( Int Int -- Bool )` or "no stack effect declared".
72    pub reason: String,
73}
74
75/// Test runner configuration
76pub struct TestRunner {
77    /// Show verbose output
78    pub verbose: bool,
79    /// Filter pattern for test names
80    pub filter: Option<String>,
81    /// Compiler configuration
82    pub config: CompilerConfig,
83}
84
85impl TestRunner {
86    pub fn new(verbose: bool, filter: Option<String>) -> Self {
87        Self {
88            verbose,
89            filter,
90            config: CompilerConfig::default(),
91        }
92    }
93
94    /// Discover test files in the given paths
95    pub fn discover_test_files(&self, paths: &[PathBuf]) -> Vec<PathBuf> {
96        let mut test_files = Vec::new();
97
98        for path in paths {
99            if path.is_file() {
100                if self.is_test_file(path) {
101                    test_files.push(path.clone());
102                }
103            } else if path.is_dir() {
104                self.discover_in_directory(path, &mut test_files);
105            }
106        }
107
108        test_files.sort();
109        test_files
110    }
111
112    /// Validate explicit paths before discovery. Any path ending in
113    /// `.seq` is treated as a file path and must match `test-*.seq`;
114    /// directories are fine (descent already filters). The check is
115    /// name-based, not filesystem-state-based, so it's deterministic in
116    /// tests and surfaces the naming rule even before the file is
117    /// stat'd. Converts the previously silent zero-tests outcome on
118    /// misnamed files into a loud error.
119    pub fn validate_paths(&self, paths: &[PathBuf]) -> Result<(), String> {
120        for path in paths {
121            let looks_like_seq_file = path.extension().and_then(|e| e.to_str()) == Some("seq");
122            if looks_like_seq_file && !self.is_test_file(path) {
123                return Err(format!(
124                    "Test files must be named `test-*.seq`. Got: `{}`",
125                    path.display()
126                ));
127            }
128        }
129        Ok(())
130    }
131
132    fn is_test_file(&self, path: &Path) -> bool {
133        if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
134            name.starts_with("test-") && name.ends_with(".seq")
135        } else {
136            false
137        }
138    }
139
140    fn discover_in_directory(&self, dir: &Path, files: &mut Vec<PathBuf>) {
141        if let Ok(entries) = fs::read_dir(dir) {
142            for entry in entries.flatten() {
143                let path = entry.path();
144                if path.is_file() && self.is_test_file(&path) {
145                    files.push(path);
146                } else if path.is_dir() {
147                    self.discover_in_directory(&path, files);
148                }
149            }
150        }
151    }
152
153    /// Discover test functions in a source file.
154    ///
155    /// A word is a test entry point iff its name starts with `test-` AND
156    /// its declared stack effect is exactly `( -- )`. Words matching the
157    /// name pattern but with a different effect (e.g. a `test-flag`
158    /// helper with `( Int Int -- Bool )`) are reported as `skipped` so
159    /// the runner can tell the user why the synthetic main isn't calling
160    /// them — see issue #435 for the original confusion.
161    ///
162    /// Returns (test_names, skipped, has_main).
163    pub fn discover_test_functions(
164        &self,
165        source: &str,
166    ) -> Result<(Vec<String>, Vec<SkippedTest>, bool), String> {
167        let mut parser = Parser::new(source);
168        let program = parser.parse()?;
169
170        let has_main = program.words.iter().any(|w| w.name == "main");
171
172        let mut test_names: Vec<String> = Vec::new();
173        let mut skipped: Vec<SkippedTest> = Vec::new();
174
175        for w in &program.words {
176            if !w.name.starts_with("test-") {
177                continue;
178            }
179            if !self.matches_filter(&w.name) {
180                continue;
181            }
182            match &w.effect {
183                Some(eff) if is_unit_effect(eff) => {
184                    test_names.push(w.name.clone());
185                }
186                Some(eff) => {
187                    skipped.push(SkippedTest {
188                        name: w.name.clone(),
189                        reason: format_effect_surface(eff),
190                    });
191                }
192                None => {
193                    skipped.push(SkippedTest {
194                        name: w.name.clone(),
195                        reason: "no stack effect declared".to_string(),
196                    });
197                }
198            }
199        }
200
201        test_names.sort();
202        skipped.sort_by(|a, b| a.name.cmp(&b.name));
203        Ok((test_names, skipped, has_main))
204    }
205
206    fn matches_filter(&self, name: &str) -> bool {
207        match &self.filter {
208            Some(pattern) => name.contains(pattern),
209            None => true,
210        }
211    }
212
213    /// Run all tests in a file
214    pub fn run_file(&self, path: &Path) -> FileTestResults {
215        let source = match fs::read_to_string(path) {
216            Ok(s) => s,
217            Err(e) => {
218                return FileTestResults {
219                    path: path.to_path_buf(),
220                    tests: vec![],
221                    skipped: vec![],
222                    compile_error: Some(format!("Failed to read file: {}", e)),
223                };
224            }
225        };
226
227        let (test_names, skipped, has_main) = match self.discover_test_functions(&source) {
228            Ok(result) => result,
229            Err(e) => {
230                return FileTestResults {
231                    path: path.to_path_buf(),
232                    tests: vec![],
233                    skipped: vec![],
234                    compile_error: Some(format!("Parse error: {}", e)),
235                };
236            }
237        };
238
239        // Skip files that have their own main - they are standalone test suites
240        if has_main {
241            return FileTestResults {
242                path: path.to_path_buf(),
243                tests: vec![],
244                skipped,
245                compile_error: None,
246            };
247        }
248
249        if test_names.is_empty() {
250            return FileTestResults {
251                path: path.to_path_buf(),
252                tests: vec![],
253                skipped,
254                compile_error: None,
255            };
256        }
257
258        // Compile once and run all tests in the file
259        let mut results = self.run_all_tests_in_file(path, &source, &test_names);
260        results.skipped = skipped;
261        results
262    }
263
264    fn run_all_tests_in_file(
265        &self,
266        path: &Path,
267        source: &str,
268        test_names: &[String],
269    ) -> FileTestResults {
270        let start = Instant::now();
271
272        // Generate wrapper main that runs ALL tests in sequence.
273        //
274        // `test.set-name` after the user's test word guarantees the
275        // `test.finish` header matches the word name the parser discovered,
276        // even if the user called `test.init` with a different friendly
277        // name inside their test word. Without this, `collect_failure_block`
278        // (which keys on the word name) would orphan detail lines.
279        let mut test_calls = String::new();
280        for test_name in test_names {
281            test_calls.push_str(&format!(
282                "  \"{0}\" test.init {0} \"{0}\" test.set-name test.finish\n",
283                test_name
284            ));
285        }
286
287        let wrapper = format!(
288            r#"{}
289
290: main ( -- )
291{}  test.has-failures [ 1 os.exit ] [ ] if
292;
293"#,
294            source, test_calls
295        );
296
297        // Create temp file for the wrapper
298        let temp_dir = std::env::temp_dir();
299        let file_id = sanitize_name(&path.to_string_lossy());
300        let wrapper_path = temp_dir.join(format!("seq_test_{}.seq", file_id));
301        let binary_path = temp_dir.join(format!("seq_test_{}", file_id));
302
303        if let Err(e) = fs::write(&wrapper_path, &wrapper) {
304            return FileTestResults {
305                path: path.to_path_buf(),
306                tests: vec![],
307                skipped: vec![],
308                compile_error: Some(format!("Failed to write temp file: {}", e)),
309            };
310        }
311
312        // Compile the wrapper (ONE compilation for all tests in file)
313        if let Err(e) = compile_file_with_config(&wrapper_path, &binary_path, false, &self.config) {
314            let _ = fs::remove_file(&wrapper_path);
315            return FileTestResults {
316                path: path.to_path_buf(),
317                tests: vec![],
318                skipped: vec![],
319                compile_error: Some(format!("Compilation error: {}", e)),
320            };
321        }
322
323        // Run the compiled tests
324        let output = Command::new(&binary_path).output();
325
326        // Clean up temp files
327        let _ = fs::remove_file(&wrapper_path);
328        let _ = fs::remove_file(&binary_path);
329
330        let compile_time = start.elapsed().as_millis() as u64;
331
332        match output {
333            Ok(output) => {
334                let stdout = String::from_utf8_lossy(&output.stdout);
335                let stderr = String::from_utf8_lossy(&output.stderr);
336
337                // Parse output to determine which tests passed/failed
338                // Output format: "test-name ... ok" or "test-name ... FAILED"
339                let results = self.parse_test_output(&stdout, test_names, compile_time);
340
341                // If we couldn't parse results but process failed, mark all as failed
342                if results.iter().all(|r| r.passed) && !output.status.success() {
343                    return FileTestResults {
344                        path: path.to_path_buf(),
345                        tests: test_names
346                            .iter()
347                            .map(|name| TestResult {
348                                name: name.clone(),
349                                passed: false,
350                                duration_ms: 0,
351                                error_output: Some(format!("{}{}", stderr, stdout)),
352                            })
353                            .collect(),
354                        skipped: vec![],
355                        compile_error: None,
356                    };
357                }
358
359                FileTestResults {
360                    path: path.to_path_buf(),
361                    tests: results,
362                    skipped: vec![],
363                    compile_error: None,
364                }
365            }
366            Err(e) => FileTestResults {
367                path: path.to_path_buf(),
368                tests: vec![],
369                skipped: vec![],
370                compile_error: Some(format!("Failed to run tests: {}", e)),
371            },
372        }
373    }
374
375    fn parse_test_output(
376        &self,
377        output: &str,
378        test_names: &[String],
379        _compile_time: u64,
380    ) -> Vec<TestResult> {
381        let mut results = Vec::new();
382
383        for test_name in test_names {
384            // Look for "test-name ... ok" or "test-name ... FAILED"
385            let passed = output
386                .lines()
387                .any(|line| line.contains(test_name) && line.contains("... ok"));
388
389            // For failures, capture the FAILED header line plus any
390            // indented detail lines that immediately follow it (runtime
391            // emits `expected X, got Y`-style lines indented under the
392            // header on the same stdout stream).
393            let error_output = if !passed {
394                collect_failure_block(output, test_name)
395            } else {
396                None
397            };
398
399            results.push(TestResult {
400                name: test_name.clone(),
401                passed,
402                duration_ms: 0, // Individual timing not available in batch mode
403                error_output,
404            });
405        }
406
407        results
408    }
409
410    /// Run tests and return summary
411    pub fn run(&self, paths: &[PathBuf]) -> TestSummary {
412        let test_files = self.discover_test_files(paths);
413        let mut summary = TestSummary::default();
414
415        for path in test_files {
416            let file_results = self.run_file(&path);
417
418            // Track compilation failures
419            if file_results.compile_error.is_some() {
420                summary.compile_failures += 1;
421            }
422
423            for test in &file_results.tests {
424                summary.total += 1;
425                if test.passed {
426                    summary.passed += 1;
427                } else {
428                    summary.failed += 1;
429                }
430            }
431
432            summary.file_results.push(file_results);
433        }
434
435        summary
436    }
437
438    /// Print test results
439    pub fn print_results(&self, summary: &TestSummary) {
440        for file_result in &summary.file_results {
441            if let Some(ref error) = file_result.compile_error {
442                eprintln!("\nFailed to process {}:", file_result.path.display());
443                eprintln!("  {}", error);
444                continue;
445            }
446
447            if file_result.tests.is_empty() && file_result.skipped.is_empty() {
448                continue;
449            }
450
451            println!("\nRunning tests in {}...", file_result.path.display());
452
453            for test in &file_result.tests {
454                let status = if test.passed { "ok" } else { "FAILED" };
455                if self.verbose {
456                    println!("  {} ... {} ({}ms)", test.name, status, test.duration_ms);
457                } else {
458                    println!("  {} ... {}", test.name, status);
459                }
460            }
461
462            for s in &file_result.skipped {
463                println!(
464                    "  {} ... skipped — name starts with `test-` but stack effect is {}, not ( -- ). Rename if it's a helper; fix the signature if it's a test.",
465                    s.name, s.reason
466                );
467            }
468        }
469
470        // Print summary
471        println!("\n========================================");
472        if summary.compile_failures > 0 {
473            println!(
474                "Results: {} passed, {} failed, {} failed to compile",
475                summary.passed, summary.failed, summary.compile_failures
476            );
477        } else {
478            println!(
479                "Results: {} passed, {} failed",
480                summary.passed, summary.failed
481            );
482        }
483
484        // Print test failures in detail
485        let failures: Vec<_> = summary
486            .file_results
487            .iter()
488            .flat_map(|fr| fr.tests.iter().filter(|t| !t.passed).map(|t| (&fr.path, t)))
489            .collect();
490
491        if !failures.is_empty() {
492            println!("\nTEST FAILURES:\n");
493            for (path, test) in failures {
494                println!("{}::{}", path.display(), test.name);
495                if let Some(ref error) = test.error_output {
496                    for line in error.lines() {
497                        println!("  {}", line);
498                    }
499                }
500                println!();
501            }
502        }
503
504        // Print compilation failures in detail
505        let compile_failures: Vec<_> = summary
506            .file_results
507            .iter()
508            .filter(|fr| fr.compile_error.is_some())
509            .collect();
510
511        if !compile_failures.is_empty() {
512            println!("\nCOMPILATION FAILURES:\n");
513            for fr in compile_failures {
514                println!("{}:", fr.path.display());
515                if let Some(ref error) = fr.compile_error {
516                    for line in error.lines() {
517                        println!("  {}", line);
518                    }
519                }
520                println!();
521            }
522        }
523    }
524}
525
526/// Sanitize a test name for use as a filename
527fn sanitize_name(name: &str) -> String {
528    name.chars()
529        .map(|c| if c.is_alphanumeric() { c } else { '_' })
530        .collect()
531}
532
533/// True iff the effect is `( -- )` — neither side has concrete types
534/// pushed or popped, and no computational side effects. The parser
535/// implicitly row-polymorphises every effect (`( -- )` becomes
536/// `..rest -- ..rest`), so the check is "no `Cons` layer on either
537/// side" rather than literal `Empty`. The row-var name doesn't matter.
538fn is_unit_effect(eff: &Effect) -> bool {
539    fn no_concrete_types(st: &StackType) -> bool {
540        !matches!(st, StackType::Cons { .. })
541    }
542    no_concrete_types(&eff.inputs) && no_concrete_types(&eff.outputs) && eff.effects.is_empty()
543}
544
545/// Render an effect in surface syntax — `( Int Int -- Bool )` rather
546/// than the internal `(..rest Int Int) -- (..rest Bool)` printed by
547/// `Display for Effect`. The parser implicitly row-polymorphises every
548/// effect; if the same row variable sits at the bottom of both sides
549/// it's just the implicit one, and showing it would be noisier than
550/// what the user wrote in source. Used only for the skip diagnostic.
551fn format_effect_surface(eff: &Effect) -> String {
552    // Walk down a stack type to its bottom row var (if any) and the
553    // concrete top-down list of types stacked above it.
554    fn split(st: &StackType) -> (Option<&str>, Vec<String>) {
555        let mut types: Vec<String> = Vec::new();
556        let mut cur = st;
557        loop {
558            match cur {
559                StackType::Empty => return (None, types_bottom_first(types)),
560                StackType::RowVar(name) => {
561                    return (Some(name.as_str()), types_bottom_first(types));
562                }
563                StackType::Cons { rest, top } => {
564                    types.push(format!("{}", top));
565                    cur = rest;
566                }
567            }
568        }
569    }
570    fn types_bottom_first(mut top_down: Vec<String>) -> Vec<String> {
571        top_down.reverse();
572        top_down
573    }
574    let (in_rv, in_types) = split(&eff.inputs);
575    let (out_rv, out_types) = split(&eff.outputs);
576    // Hide the row variable when both sides share it (implicit
577    // polymorphism). Otherwise show it explicitly.
578    let show_row = in_rv != out_rv;
579
580    let render = |rv: Option<&str>, types: &[String]| -> String {
581        let mut parts: Vec<String> = Vec::new();
582        if show_row && let Some(name) = rv {
583            parts.push(format!("..{}", name));
584        }
585        parts.extend(types.iter().cloned());
586        parts.join(" ")
587    };
588    let inp = render(in_rv, &in_types);
589    let out = render(out_rv, &out_types);
590    let inp_sep = if inp.is_empty() { "" } else { " " };
591    let out_sep = if out.is_empty() { "" } else { " " };
592    if eff.effects.is_empty() {
593        format!("( {}{}-- {}{})", inp, inp_sep, out, out_sep)
594    } else {
595        let effs: Vec<String> = eff.effects.iter().map(|e| format!("{}", e)).collect();
596        format!(
597            "( {}{}-- {}{}| {} )",
598            inp,
599            inp_sep,
600            out,
601            out_sep,
602            effs.join(" ")
603        )
604    }
605}
606
607/// Given the full test-wrapper stdout and a test name, find the
608/// `<name> ... FAILED` header line plus any indented detail lines
609/// that immediately follow it, and return them as a single block.
610///
611/// An indented detail line is any line that begins with whitespace.
612/// Collection stops at the next non-indented line (typically the next
613/// test's header, or the pass/fail summary).
614///
615/// Matches the header exactly (`{name} ... FAILED`) so one test name
616/// being a substring of another (e.g. `add` vs `add-overflow`) cannot
617/// cross-attribute the block.
618fn collect_failure_block(output: &str, test_name: &str) -> Option<String> {
619    let header = format!("{} ... FAILED", test_name);
620    let mut lines = output.lines().peekable();
621    while let Some(line) = lines.next() {
622        if line == header {
623            let mut block = String::from(line);
624            while let Some(next) = lines.peek() {
625                if next.starts_with(char::is_whitespace) {
626                    block.push('\n');
627                    block.push_str(next);
628                    lines.next();
629                } else {
630                    break;
631                }
632            }
633            return Some(block);
634        }
635    }
636    None
637}
638
639#[cfg(test)]
640mod tests;