use std::{ io, path::{Path, PathBuf}, }; use { anyhow::anyhow, collect_mac::collect, futures::{join, prelude::*, stream, task::SpawnExt}, serde_derive::Deserialize, structopt::StructOpt, thiserror::Error, tokio::fs, }; use gluon::{ base::{ ast::{Expr, Pattern, SpannedExpr}, filename_to_module, metadata::BaseMetadata, symbol::Symbol, types::{ArcType, Type}, }, new_vm_async, vm::api::{de::De, generic::A, Getable, Hole, OpaqueValue, OwnedFunction, VmType, IO}, RootedThread, Thread, ThreadExt, }; #[derive(Debug, Error)] enum Error { #[error("{0}")] Error(anyhow::Error), #[error("{0}")] Io(io::Error), #[error("{0}")] Gluon(gluon::Error), #[error("{0}")] Message(String), } impl From for Error { fn from(d: String) -> Error { Error::Message(d) } } impl<'a> From<&'a str> for Error { fn from(d: &'a str) -> Error { Error::Message(d.to_string()) } } impl From for Error { fn from(d: anyhow::Error) -> Error { Error::Error(d) } } impl From for Error { fn from(d: io::Error) -> Error { Error::Io(d) } } impl From for Error { fn from(d: gluon::vm::Error) -> Error { gluon::Error::from(d).into() } } impl From for Error { fn from(d: gluon::Error) -> Error { Error::Gluon(d) } } #[derive(StructOpt)] #[structopt(about = "gluon tests")] pub struct Opt { #[structopt(long = "jobs")] #[structopt(help = "How many threads to run in parallel")] pub jobs: Option, #[structopt(name = "FILTER", help = "Filters which tests to run")] pub filter: Vec, } fn main() { let options = Opt::from_args(); let runtime = { let mut builder = tokio::runtime::Builder::new_multi_thread(); if let Some(jobs) = options.jobs { builder.worker_threads(jobs); } builder.build().unwrap() }; runtime.block_on(async move { if let Err(err) = main_(&options).await { eprintln!("{}", err); std::process::exit(1); } }) } fn test_files(path: &str) -> Result, Error> { let paths: Vec<_> = walkdir::WalkDir::new(path) .into_iter() .filter_map(|f| { f.ok().and_then(|f| { let path = f.path(); if path.extension().and_then(|e| e.to_str()) == Some("glu") { Some(path.to_owned()) } else { None } }) }) .collect(); assert!(!paths.is_empty(), "Expected test files"); Ok(paths) } macro_rules! define_test_type { ($name:ident $($args: ident)*) => { impl VmType for $name { type Type = $name; fn make_type(vm: &Thread) -> ArcType { let typ = concat!("std.test.", stringify!($name)); Type::app( vm.get_env().find_type_info(typ).unwrap().into_type(), collect![$($args::make_type(vm),)* Type::unit()], ) } } }; } type TestEff = OpaqueValue; type TestFn = OwnedFunction TestEff>; #[derive(Deserialize)] enum TestCase { Test(String, TestFn), Group(String, Vec), } define_test_type! { TestCase Hole } struct TestEffIO; define_test_type! { TestEffIO A } fn make_tensile_test(name: String, test: TestFn) -> tensile::Test { let mut test = ::std::panic::AssertUnwindSafe(test); tensile::test(name, { tensile::Future(::std::panic::AssertUnwindSafe(async move { let test = test.call_async(()).await?; let action = test.vm().get_global("std.test.run_io")?; let mut action: OwnedFunction) -> IO<()>> = action; action.call_async(test).await?; Ok(()) })) }) } impl TestCase { fn into_tensile_test(self) -> tensile::Test { match self { TestCase::Test(name, test) => { let child_thread = test.vm().new_thread().unwrap(); let test = TestFn::from_value(&child_thread, test.get_variant()); make_tensile_test(name, test) } TestCase::Group(name, tests) => tensile::Test::Group { name, tests: tests.into_iter().map(TestCase::into_tensile_test).collect(), }, } } } async fn catch_unwind_test( name: String, f: impl Future>, ) -> tensile::Test { std::panic::AssertUnwindSafe(f) .catch_unwind() .await .unwrap_or_else(|err| { let err = Error::from( err.downcast::() .map(|s| *s) .or_else(|e| e.downcast::<&str>().map(|s| String::from(&s[..]))) .unwrap_or_else(|_| "Unknown panic".to_string()), ); tensile::test(name, Err(err)) }) } async fn make_test<'t>(vm: &'t Thread, name: &str, filename: &Path) -> Result { let text = fs::read_to_string(filename).await?; let (De(test), _) = std::panic::AssertUnwindSafe(vm.run_expr_async(&name, &text)).await?; Ok(test) } async fn run_fail_test<'t>(vm: &'t Thread, name: &str, filename: &Path) -> Result<(), Error> { let source = fs::read_to_string(&filename).await?; let error_pattern = { let pat = "// ERROR"; assert!( source.starts_with(pat), "Failure tests must have an error pattern to match against" ); &source.lines().next().unwrap()[pat.len()..].trim() }; match vm.load_script_async(&name, &source).await { Ok(err) => Err(anyhow!( "Expected test '{}' to fail\n{:?}", filename.to_str().unwrap(), err ) .into()), Err(ref err) if !err.to_string().contains(error_pattern) => Err(anyhow!( "Error does not match the error pattern.\nExpected:\n\t`{}`\nActual:\n\t`{}`", error_pattern, err ) .into()), Err(_) => Ok(()), } } fn gather_doc_tests(expr: &SpannedExpr) -> Vec<(String, String)> { use gluon::base::ast::{walk_expr, Visitor}; fn make_test(comment: &str) -> String { let mut parser = pulldown_cmark::Parser::new(comment); let mut source = String::new(); loop { match parser.next() { Some(pulldown_cmark::Event::Start(pulldown_cmark::Tag::CodeBlock(_))) => (), None => break, _ => continue, } loop { match parser.next() { Some(pulldown_cmark::Event::End(pulldown_cmark::Tag::CodeBlock(_))) => { break; } Some(pulldown_cmark::Event::Text(content)) => { source.push_str(&content); } None => break, _ => continue, } } } source } struct DocVisitor(Vec<(String, String)>); impl DocVisitor { fn make_test_from_metadata(&mut self, name: &str, metadata: &BaseMetadata<'_>) { if let Some(comment) = &metadata.comment() { let source = make_test(&comment.content); if !source.is_empty() { self.0.push((format!("{}", name), String::from(source))); } } } } impl Visitor<'_, '_> for DocVisitor { type Ident = Symbol; fn visit_expr(&mut self, expr: &SpannedExpr<'_, Symbol>) { match &expr.value { Expr::LetBindings(binds, _) => { for bind in &**binds { if let Some(comment) = &bind.metadata.comment() { let source = make_test(&comment.content); if !source.is_empty() { let name = match &bind.name.value { Pattern::Ident(id) => id.name.declared_name(), _ => "Unknown", }; self.0.push((format!("{}", name), String::from(source))); } } } } Expr::TypeBindings(binds, _) => { for bind in &**binds { self.make_test_from_metadata( bind.name.value.declared_name(), &bind.metadata, ); } } Expr::Record { types, exprs, .. } => { for field in &**types { self.make_test_from_metadata(field.name.declared_name(), &field.metadata); } for field in &**exprs { self.make_test_from_metadata(field.name.declared_name(), &field.metadata); } } _ => (), } walk_expr(self, expr); } } let mut visitor = DocVisitor(Vec::new()); visitor.visit_expr(expr); visitor.0 } async fn run_doc_tests<'t>( vm: &'t Thread, name: &str, filename: &Path, ) -> Result>, Error> { let text = fs::read_to_string(filename).await?; let (expr, _, _) = vm.extract_metadata(&name, &text).await?; let convert_test_fn = vm.get_global:: TestFn>>("convert_test_fn")?; let tests = gather_doc_tests(&expr.expr()); Ok(tests .into_iter() .map(move |(test_name, test_source)| { let mut convert_test_fn = convert_test_fn.clone(); catch_unwind_test(test_name.clone(), async move { let vm = vm.new_thread().unwrap(); match vm .run_expr_async::(&test_name, &test_source) .and_then(|(test, _)| async { Ok(convert_test_fn.call_async(test).await?) }) .await { Ok(test) => make_tensile_test(test_name, test), Err(err) => { let err = ::std::panic::AssertUnwindSafe(err); tensile::test(test_name, || Err(err.0.into())) } } }) }) .collect::>() .collect() .await) } async fn main_(options: &Opt) -> Result<(), Error> { let _ = ::env_logger::try_init(); let file_filters = options .filter .iter() .filter(|f| f.starts_with("@")) .map(|f| f.trim_start_matches('@')) .collect::>(); let filter = options.filter.iter().find(|f| !f.starts_with("@")); let filter_fn = |filename: PathBuf| { let name = filename_to_module(filename.to_str().unwrap_or("")); if file_filters.is_empty() || file_filters.iter().any(|filter| name.contains(&filter[..])) { Some((filename, name)) } else { None } }; let vm = new_vm_async().await; vm.load_file_async("std/test.glu").await?; let iter = test_files("tests/pass")?.into_iter(); struct TokioSpawn; impl futures::task::Spawn for TokioSpawn { fn spawn_obj( &self, future: futures::task::FutureObj<'static, ()>, ) -> Result<(), futures::task::SpawnError> { tokio::spawn(future); Ok(()) } } let pool = TokioSpawn; let pass_tests_future = iter .filter_map(&filter_fn) .map(|(filename, name)| { let vm = vm.new_thread().unwrap(); let name2 = name.clone(); pool.spawn_with_handle(catch_unwind_test(name.clone(), async move { match make_test(&vm, &name, &filename).await { Ok(test) => test.into_tensile_test(), Err(err) => { let err = ::std::panic::AssertUnwindSafe(err); tensile::test(name2, || Err(err.0)) } } })) .expect("Could not spawn test future") }) .collect::>() .collect::>(); let fail_tests = test_files("tests/fail")? .into_iter() .filter(|filename| !filename.to_string_lossy().contains("deps")) .filter_map(&filter_fn) .map(|(filename, name)| { let vm = vm.new_thread().unwrap(); tensile::test( name.clone(), tensile::Future(std::panic::AssertUnwindSafe(async move { run_fail_test(&vm, &name, &filename).await })), ) }) .collect(); vm.load_script_async("convert_test_fn", r"\x -> \_ -> x") .await?; let doc_tests_future = test_files("std")? .into_iter() .filter_map(&filter_fn) .map(|(filename, name)| { let vm = vm.new_thread().unwrap(); pool.spawn_with_handle(catch_unwind_test(name.clone(), async move { match run_doc_tests(&vm, &name, &filename).await { Ok(tests) => tensile::group(name.clone(), tests), Err(err) => { let err = ::std::panic::AssertUnwindSafe(err); tensile::test(name.clone(), || Err(err.0)) } } })) .expect("Could not spawn test future") }) .collect::>() .collect::>(); let (pass_tests, doc_tests) = join!(pass_tests_future, doc_tests_future); let report = tensile::tokio_console_runner( tensile::group( "main", vec![ tensile::group("pass", pass_tests), tensile::group("fail", fail_tests), tensile::group("doc", doc_tests), ], ), &tensile::Options::default().filter(filter.map_or("", |s| &s[..])), ) .await?; if !report.passes() { return Err("Some tests failed".into()); } Ok(()) }