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