diff --git a/core/src/lib.rs b/core/src/lib.rs index 0f0a63d..3ffd7ed 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -9,12 +9,12 @@ use crate::eval::{EvalOptions, eval}; use crate::parser::{ReplInput, repl_parser}; #[cfg(feature = "repl")] use crate::term::Decl; -use crate::term::Term::Hole; +use crate::term::Term::{self, Con, Hole}; #[cfg(feature = "repl")] use crate::term::module::module; use crate::term::module::{default_modules, load_module_files}; -use crate::term::{ModulePath, mpt, strings_to_list_term}; -use crate::term::{Term, app}; +use crate::term::{Constructor, ModulePath, mpt, strings_to_list_term}; +use crate::term::{app, id}; pub mod eval; pub mod parser; @@ -182,3 +182,144 @@ pub fn vec_fmt(v: &Vec) -> String { .collect::>() .join(", ") } + +enum TestResult { + Pass, + Fail, + FailWithMessage(String), +} + +fn detect_test_result(term: &Term) -> TestResult { + match term { + Term::Ctx { term, .. } => detect_test_result(term), + Con(Constructor { + name, + typ_name, + args, + .. + }) => { + if typ_name == &mpt("Bool") { + if name == &id("true") { + return TestResult::Pass; + } else if name == &id("false") { + return TestResult::Fail; + } + } + if typ_name == &mpt("IO") { + if let Some(Some(inner)) = args.first() { + return detect_test_result(inner); + } + } + if typ_name == &mpt("Result") { + if name == &id("ok") { + return TestResult::Pass; + } else if name == &id("err") { + if let Some(Some(msg_term)) = args.first() { + let msg = extract_string_literal(msg_term); + return TestResult::FailWithMessage(msg.unwrap_or_else(|| msg_term.to_string())); + } + return TestResult::Fail; + } + } + TestResult::FailWithMessage(format!("unexpected result: {term}")) + } + _ => TestResult::FailWithMessage(format!("unexpected result: {term}")), + } +} + +fn extract_string_literal(term: &Term) -> Option { + match term { + Term::Ctx { term, .. } => extract_string_literal(term), + Term::Lit { + value: crate::term::Literal::Str { value }, + } => Some(value.clone()), + _ => None, + } +} + +pub fn run_tests(input: PathBuf, options: EvalOptions) -> Result<(), String> { + let path: ModulePath = input.into(); + let mut loaded = default_modules().map_err(|e| format!("{e}"))?; + let test_path = ModulePath::new(vec![id("std"), id("test")]); + loaded = load_module_files(&test_path, loaded).map_err(|e| format!("{e}"))?; + loaded = load_module_files(&path, loaded).map_err(|e| format!("{e}"))?; + let module = loaded + .get_module(&path) + .ok_or_else(|| format!("Module {path} not loaded"))?; + let loaded_scopes = loaded.scopes(); + let global = loaded_scopes.global(&path).expect("Module not loaded"); + if options.debug { + println!("{global}"); + } + + let test_defs: Vec<_> = module + .defs() + .into_iter() + .filter(|ctx| ctx.value().has_test_attr()) + .collect(); + + if test_defs.is_empty() { + return Err("No tests found".to_string()); + } + + let mut passed = 0; + let mut failed = 0; + let mut failures: Vec<(String, String)> = Vec::new(); + + for ctx in &test_defs { + let def = ctx.value(); + let name = def.name.to_string(); + let term = def.term.clone(); + + let (term, typ) = match type_check(term, Hole, &global.scope()) { + Ok(tt) => tt.to_tuple(), + Err(e) => { + failed += 1; + failures.push((name.clone(), format!("type error: {e}"))); + continue; + } + }; + + if options.debug { + println!("test {name} : {typ}"); + } + + let result = match eval(term, &global.scope(), &options) { + Ok(t) => t, + Err(e) => { + failed += 1; + failures.push((name.clone(), format!("eval error: {e}"))); + continue; + } + }; + + if options.debug { + println!(" eval: {result}"); + } + + match detect_test_result(&result) { + TestResult::Pass => { + passed += 1; + println!("PASS {name}"); + } + TestResult::Fail => { + failed += 1; + println!("FAIL {name}"); + } + TestResult::FailWithMessage(msg) => { + failed += 1; + println!("FAIL {name}: {msg}"); + failures.push((name.clone(), msg)); + } + } + } + + let total = passed + failed; + println!("{passed}/{total} tests passed"); + + if failed > 0 { + Err(format!("{failed} test(s) failed")) + } else { + Ok(()) + } +} diff --git a/core/src/main.rs b/core/src/main.rs index deef649..c0b3354 100644 --- a/core/src/main.rs +++ b/core/src/main.rs @@ -1,7 +1,7 @@ use std::path::PathBuf; use clap::{Parser, Subcommand}; -use monad_core::{eval::EvalOptions, run}; +use monad_core::{eval::EvalOptions, run, run_tests}; #[cfg(feature = "repl")] use monad_core::repl; @@ -21,6 +21,13 @@ enum Commands { #[arg(value_name = "ARGS", trailing_var_arg = true)] args: Vec, }, + + Test { + #[arg(value_name = "FILE")] + input: PathBuf, + #[arg(short, long, default_value_t = false)] + debug: bool, + }, } #[derive(Debug, Parser)] @@ -50,5 +57,15 @@ fn main() -> Result<(), String> { } result } + Commands::Test { input, debug } => { + let result = run_tests(input, EvalOptions { debug }); + match result { + Ok(_) => (), + Err(ref e) => { + println!("error: {e}") + } + } + result + } } } diff --git a/core/src/parser/test.rs b/core/src/parser/test.rs index 8819fef..77d4039 100644 --- a/core/src/parser/test.rs +++ b/core/src/parser/test.rs @@ -1392,3 +1392,23 @@ fn test_attr_arg_nested_groups() { _ => panic!("expected Def"), } } + +#[test] +fn test_attr_test() { + let s = r#"@[test] + def test_addition : Bool := + 1 + 1 == 2 + "# + .into(); + let (_, res) = decl_parser(s).unwrap(); + + let expected_attrs = vec![Attribute { + name: id("test"), + args: vec![], + }]; + + match res.value() { + Decl::Def(def) => assert_eq!(def.attributes, expected_attrs), + _ => panic!("expected Def"), + } +} diff --git a/core/src/term.rs b/core/src/term.rs index 571d0ab..017af7a 100644 --- a/core/src/term.rs +++ b/core/src/term.rs @@ -1493,6 +1493,9 @@ impl Def { pub fn to_typed_term(self) -> TypedTerm { typed_term(self.term, self.typ) } + pub fn has_test_attr(&self) -> bool { + self.attributes.iter().any(|a| a.name.as_str() == "test") + } } impl AsVarRef for Def { fn as_var_ref<'a>(&'a self) -> VarRef<'a> { diff --git a/examples/tests.mo b/examples/tests.mo new file mode 100644 index 0000000..0285b80 --- /dev/null +++ b/examples/tests.mo @@ -0,0 +1,26 @@ +use std.test +use io +open IO + +@[test] +def test_addition : Bool := + 1 + 2 == 3 + +@[test] +def test_subtraction : Bool := + 5 - 3 == 2 + +@[test] +def test_bool : Bool := + true && true + +@[test] +def test_list : Bool := + not (List.is_empty [1, 2, 3]) + +@[test] +def test_failing : Bool := + false + +def main (args: List String) : IO Unit := + println "not a test" diff --git a/std/test.mo b/std/test.mo new file mode 100644 index 0000000..bd8f022 --- /dev/null +++ b/std/test.mo @@ -0,0 +1,4 @@ +// Test module + +def Test.assert (condition : Bool) : Bool := condition +