1use 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#[derive(Debug)]
15pub struct TestResult {
16 pub name: String,
18 pub passed: bool,
20 pub duration_ms: u64,
22 pub error_output: Option<String>,
24}
25
26#[derive(Debug, Default)]
28pub struct TestSummary {
29 pub total: usize,
31 pub passed: usize,
33 pub failed: usize,
35 pub compile_failures: usize,
37 pub file_results: Vec<FileTestResults>,
39}
40
41impl TestSummary {
42 pub fn has_failures(&self) -> bool {
44 self.failed > 0 || self.compile_failures > 0
45 }
46}
47
48#[derive(Debug)]
50pub struct FileTestResults {
51 pub path: PathBuf,
53 pub tests: Vec<TestResult>,
55 pub skipped: Vec<SkippedTest>,
60 pub compile_error: Option<String>,
62}
63
64impl FileTestResults {
65 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 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#[derive(Debug, Clone)]
94pub struct SkippedTest {
95 pub name: String,
97 pub reason: String,
100}
101
102pub struct TestRunner {
104 pub verbose: bool,
106 pub filter: Option<String>,
108 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 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 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 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 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 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 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 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 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 let results = self.parse_test_output(&stdout, test_names, compile_time);
338
339 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 let passed = output
381 .lines()
382 .any(|line| line.contains(test_name) && line.contains("... ok"));
383
384 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, error_output,
399 });
400 }
401
402 results
403 }
404
405 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 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 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 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
519fn sanitize_name(name: &str) -> String {
521 name.chars()
522 .map(|c| if c.is_alphanumeric() { c } else { '_' })
523 .collect()
524}
525
526fn 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
538fn format_effect_surface(eff: &Effect) -> String {
545 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 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
600fn 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;