diff --git a/CHANGELOG.md b/CHANGELOG.md index 28657458..a86bd3af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ ## [next] - 2022-MM-DD +### Added + +- **aiken-lang**: integrated unit tests + + Aiken now supports writing unit tests directly in source files using the new + `test` keyword. Tests are functions with no arguments that are implicitly typed + to `bool`. For example: + + ```gleam + test foo () { + 1 + 1 == 2 + } + ``` + +- **aiken**: new `--skip-tests` flag for the `check` command + +### Changed + +- **aiken**: `check` now also runs and reports on any `test` found in the project + +### Removed + +N/A + ## [v0.0.26] - 2022-11-23 ### Added diff --git a/Cargo.lock b/Cargo.lock index e49dff86..07455dc5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -59,6 +59,7 @@ dependencies = [ "ignore", "indoc", "miette", + "owo-colors", "pallas-addresses", "pallas-codec", "pallas-crypto", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 11855d96..a6f9215c 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -15,6 +15,7 @@ hex = "0.4.3" ignore = "0.4.18" indoc = "1.0" miette = { version = "5.3.0", features = ["fancy"] } +owo-colors = "3.5.0" pallas-addresses = "0.14.0" pallas-codec = "0.14.0" pallas-crypto = "0.14.0" diff --git a/crates/cli/src/cmd/check.rs b/crates/cli/src/cmd/check.rs index 9f99e617..1bbc1126 100644 --- a/crates/cli/src/cmd/check.rs +++ b/crates/cli/src/cmd/check.rs @@ -6,8 +6,17 @@ pub struct Args { /// Path to project #[clap(short, long)] directory: Option, + + /// Skip tests; run only the type-checker + #[clap(short, long)] + skip_tests: bool, } -pub fn exec(Args { directory }: Args) -> miette::Result<()> { - crate::with_project(directory, |p| p.check()) +pub fn exec( + Args { + directory, + skip_tests, + }: Args, +) -> miette::Result<()> { + crate::with_project(directory, |p| p.check(skip_tests)) } diff --git a/crates/cli/src/cmd/uplc/eval.rs b/crates/cli/src/cmd/uplc/eval.rs index 8d251fb0..70c86858 100644 --- a/crates/cli/src/cmd/uplc/eval.rs +++ b/crates/cli/src/cmd/uplc/eval.rs @@ -61,7 +61,9 @@ pub fn exec( program = program.apply_term(&term); } - let (term, cost, logs) = program.eval(); + let budget = ExBudget::default(); + + let (term, cost, logs) = program.eval(budget); match term { Ok(term) => { @@ -74,8 +76,6 @@ pub fn exec( } } - let budget = ExBudget::default(); - println!( "\nCosts\n-----\ncpu: {}\nmemory: {}", budget.cpu - cost.cpu, diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index d5789d46..0034bf3e 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -1,13 +1,19 @@ -pub mod cmd; +use std::{env, path::PathBuf}; -use aiken_project::{config::Config, Project}; +use aiken_project::{ + config::Config, + telemetry::{self, TestInfo}, + Project, +}; use miette::IntoDiagnostic; -use std::env; -use std::path::PathBuf; +use owo_colors::OwoColorize; +use uplc::machine::cost_model::ExBudget; + +pub mod cmd; pub fn with_project(directory: Option, mut action: A) -> miette::Result<()> where - A: FnMut(&mut Project) -> Result<(), aiken_project::error::Error>, + A: FnMut(&mut Project) -> Result<(), aiken_project::error::Error>, { let project_path = if let Some(d) = directory { d @@ -17,7 +23,7 @@ where let config = Config::load(project_path.clone()).into_diagnostic()?; - let mut project = Project::new(config, project_path); + let mut project = Project::new(config, project_path, Terminal::default()); let build_result = action(&mut project); @@ -30,10 +36,129 @@ where if let Err(err) = build_result { err.report(); - miette::bail!("failed: {} error(s), {warning_count} warning(s)", err.len(),); + miette::bail!("Failed: {} error(s), {warning_count} warning(s)", err.len(),); }; - println!("\nfinished with {warning_count} warning(s)\n"); + println!("\nFinished with {warning_count} warning(s)\n"); Ok(()) } + +#[derive(Debug, Default, Clone, Copy)] +pub struct Terminal; + +impl telemetry::EventListener for Terminal { + fn handle_event(&self, event: telemetry::Event) { + match event { + telemetry::Event::StartingCompilation { + name, + version, + root, + } => { + println!( + "{} {} {} ({})", + "Compiling".bold().purple(), + name.bold(), + version, + root.to_str().unwrap_or("").bright_blue() + ); + } + telemetry::Event::ParsingProjectFiles => { + println!("{}", "...Parsing project files".bold().purple()); + } + telemetry::Event::TypeChecking => { + println!("{}", "...Type-checking project".bold().purple()); + } + telemetry::Event::GeneratingUPLC { output_path } => { + println!( + "{} in {}", + "...Generating Untyped Plutus Core".bold().purple(), + output_path.to_str().unwrap_or("").bright_blue() + ); + } + telemetry::Event::RunningTests => { + println!("{}\n", "...Running tests".bold().purple()); + } + telemetry::Event::FinishedTests { tests } => { + let (max_mem, max_cpu) = tests.iter().fold( + (0, 0), + |(max_mem, max_cpu), TestInfo { spent_budget, .. }| { + if spent_budget.mem >= max_mem && spent_budget.cpu >= max_cpu { + (spent_budget.mem, spent_budget.cpu) + } else if spent_budget.mem > max_mem { + (spent_budget.mem, max_cpu) + } else if spent_budget.cpu > max_cpu { + (max_mem, spent_budget.cpu) + } else { + (max_mem, max_cpu) + } + }, + ); + + let max_mem = max_mem.to_string().len() as i32; + let max_cpu = max_cpu.to_string().len() as i32; + + for test_info in &tests { + println!("{}", fmt_test(test_info, max_mem, max_cpu)) + } + + let (n_passed, n_failed) = + tests + .iter() + .fold((0, 0), |(n_passed, n_failed), test_info| { + if test_info.is_passing { + (n_passed + 1, n_failed) + } else { + (n_passed, n_failed + 1) + } + }); + + println!( + "{}", + format!( + "\n Summary: {} test(s), {}; {}.", + tests.len(), + format!("{} passed", n_passed).bright_green(), + format!("{} failed", n_failed).bright_red() + ) + .bold() + ) + } + } + } +} + +fn fmt_test(test_info: &TestInfo, max_mem: i32, max_cpu: i32) -> String { + let TestInfo { + is_passing, + test, + spent_budget, + } = test_info; + + let ExBudget { mem, cpu } = spent_budget; + + format!( + " [{}] [mem: {}, cpu: {}] {}::{}", + if *is_passing { + "PASS".bold().green().to_string() + } else { + "FAIL".bold().red().to_string() + }, + pad_left(mem.to_string(), max_mem, " "), + pad_left(cpu.to_string(), max_cpu, " "), + test.module.blue(), + test.name.bright_blue() + ) +} + +fn pad_left(mut text: String, n: i32, delimiter: &str) -> String { + let diff = n - text.len() as i32; + + if diff.is_positive() { + for _ in 0..diff { + text.insert_str(0, delimiter); + } + } + + text +} diff --git a/crates/lang/src/ast.rs b/crates/lang/src/ast.rs index 93a70889..3afbdd82 100644 --- a/crates/lang/src/ast.rs +++ b/crates/lang/src/ast.rs @@ -138,6 +138,8 @@ pub enum Definition { Use(Use), ModuleConstant(ModuleConstant), + + Test(Function), } impl Definition { @@ -147,7 +149,8 @@ impl Definition { | Definition::Use(Use { location, .. }) | Definition::TypeAlias(TypeAlias { location, .. }) | Definition::DataType(DataType { location, .. }) - | Definition::ModuleConstant(ModuleConstant { location, .. }) => *location, + | Definition::ModuleConstant(ModuleConstant { location, .. }) + | Definition::Test(Function { location, .. }) => *location, } } @@ -157,7 +160,8 @@ impl Definition { Definition::Fn(Function { doc, .. }) | Definition::TypeAlias(TypeAlias { doc, .. }) | Definition::DataType(DataType { doc, .. }) - | Definition::ModuleConstant(ModuleConstant { doc, .. }) => { + | Definition::ModuleConstant(ModuleConstant { doc, .. }) + | Definition::Test(Function { doc, .. }) => { let _ = std::mem::replace(doc, Some(new_doc)); } } diff --git a/crates/lang/src/format.rs b/crates/lang/src/format.rs index 71e76d10..2d5b7853 100644 --- a/crates/lang/src/format.rs +++ b/crates/lang/src/format.rs @@ -215,7 +215,23 @@ impl<'comments> Formatter<'comments> { return_annotation, end_position, .. - }) => self.definition_fn(public, name, args, return_annotation, body, *end_position), + }) => self.definition_fn( + public, + "fn", + name, + args, + return_annotation, + body, + *end_position, + ), + + Definition::Test(Function { + name, + arguments: args, + body, + end_position, + .. + }) => self.definition_fn(&false, "test", name, args, &None, body, *end_position), Definition::TypeAlias(TypeAlias { alias, @@ -493,9 +509,11 @@ impl<'comments> Formatter<'comments> { commented(doc, comments) } + #[allow(clippy::too_many_arguments)] fn definition_fn<'a>( &mut self, public: &'a bool, + keyword: &'a str, name: &'a str, args: &'a [UntypedArg], return_annotation: &'a Option, @@ -504,7 +522,8 @@ impl<'comments> Formatter<'comments> { ) -> Document<'a> { // Fn name and args let head = pub_(*public) - .append("fn ") + .append(keyword) + .append(" ") .append(name) .append(wrap_args(args.iter().map(|e| (self.fn_arg(e), false)))); diff --git a/crates/lang/src/parser.rs b/crates/lang/src/parser.rs index d92e7e31..d2c98f1c 100644 --- a/crates/lang/src/parser.rs +++ b/crates/lang/src/parser.rs @@ -74,6 +74,7 @@ fn module_parser() -> impl Parser, Error = ParseEr data_parser(), type_alias_parser(), fn_parser(), + test_parser(), constant_parser(), )) .repeated() @@ -266,6 +267,36 @@ pub fn fn_parser() -> impl Parser impl Parser { + just(Token::Test) + .ignore_then(select! {Token::Name {name} => name}) + .then_ignore(just(Token::LeftParen)) + .then_ignore(just(Token::RightParen)) + .map_with_span(|name, span| (name, span)) + .then( + expr_seq_parser() + .or_not() + .delimited_by(just(Token::LeftBrace), just(Token::RightBrace)), + ) + .map_with_span(|((name, span_end), body), span| { + ast::UntypedDefinition::Test(ast::Function { + arguments: vec![], + body: body.unwrap_or(expr::UntypedExpr::Todo { + kind: TodoKind::EmptyFunction, + location: span, + label: None, + }), + doc: None, + location: span_end, + end_position: span.end - 1, + name, + public: true, + return_annotation: None, + return_type: (), + }) + }) +} + fn constant_parser() -> impl Parser { pub_parser() .or_not() diff --git a/crates/lang/src/parser/lexer.rs b/crates/lang/src/parser/lexer.rs index 1d140384..d845b741 100644 --- a/crates/lang/src/parser/lexer.rs +++ b/crates/lang/src/parser/lexer.rs @@ -67,6 +67,7 @@ pub fn lexer() -> impl Parser, Error = ParseError> { "check" => Token::Assert, "const" => Token::Const, "fn" => Token::Fn, + "test" => Token::Test, "if" => Token::If, "else" => Token::Else, "is" => Token::Is, diff --git a/crates/lang/src/parser/token.rs b/crates/lang/src/parser/token.rs index 8faaa339..0811268a 100644 --- a/crates/lang/src/parser/token.rs +++ b/crates/lang/src/parser/token.rs @@ -68,6 +68,7 @@ pub enum Token { Opaque, Pub, Use, + Test, Todo, Trace, Type, @@ -145,6 +146,7 @@ impl fmt::Display for Token { Token::Todo => "todo", Token::Trace => "try", Token::Type => "type", + Token::Test => "test", }; write!(f, "\"{}\"", s) } diff --git a/crates/lang/src/tipo/environment.rs b/crates/lang/src/tipo/environment.rs index ca9b6055..4f70deff 100644 --- a/crates/lang/src/tipo/environment.rs +++ b/crates/lang/src/tipo/environment.rs @@ -255,6 +255,7 @@ impl<'a> Environment<'a> { definition @ (Definition::TypeAlias { .. } | Definition::DataType { .. } | Definition::Use { .. } + | Definition::Test { .. } | Definition::ModuleConstant { .. }) => definition, } } @@ -911,7 +912,10 @@ impl<'a> Environment<'a> { } } - Definition::Fn { .. } | Definition::Use { .. } | Definition::ModuleConstant { .. } => {} + Definition::Fn { .. } + | Definition::Test { .. } + | Definition::Use { .. } + | Definition::ModuleConstant { .. } => {} } Ok(()) @@ -990,6 +994,24 @@ impl<'a> Environment<'a> { } } + Definition::Test(Function { name, location, .. }) => { + hydrators.insert(name.clone(), Hydrator::new()); + let arg_types = vec![]; + let return_type = builtins::bool(); + self.insert_variable( + name.clone(), + ValueConstructorVariant::ModuleFn { + name: name.clone(), + field_map: None, + module: module_name.to_owned(), + arity: 0, + location: *location, + builtin: None, + }, + function(arg_types, return_type), + ); + } + Definition::DataType(DataType { location, public, diff --git a/crates/lang/src/tipo/infer.rs b/crates/lang/src/tipo/infer.rs index 907b731d..9fc64e0a 100644 --- a/crates/lang/src/tipo/infer.rs +++ b/crates/lang/src/tipo/infer.rs @@ -6,6 +6,7 @@ use crate::{ RecordConstructorArg, TypeAlias, TypedDefinition, TypedModule, UntypedDefinition, UntypedModule, Use, }, + builtins, builtins::function, parser::token::Token, IdGenerator, @@ -66,8 +67,8 @@ impl UntypedModule { for def in self.definitions().cloned() { match def { Definition::ModuleConstant { .. } => consts.push(def), - Definition::Fn { .. } + | Definition::Test { .. } | Definition::TypeAlias { .. } | Definition::DataType { .. } | Definition::Use { .. } => not_consts.push(def), @@ -233,6 +234,17 @@ fn infer_definition( })) } + Definition::Test(f) => { + if let Definition::Fn(f) = + infer_definition(Definition::Fn(f), module_name, hydrators, environment)? + { + environment.unify(f.return_type.clone(), builtins::bool(), f.location)?; + Ok(Definition::Test(f)) + } else { + unreachable!("test defintion inferred as something else than a function?") + } + } + Definition::TypeAlias(TypeAlias { doc, location, diff --git a/crates/project/src/error.rs b/crates/project/src/error.rs index 46548e41..a1bc12f9 100644 --- a/crates/project/src/error.rs +++ b/crates/project/src/error.rs @@ -293,7 +293,7 @@ impl Diagnostic for Warning { fn code<'a>(&'a self) -> Option> { match self { - Warning::Type { .. } => Some(Box::new("aiken::typecheck")), + Warning::Type { .. } => Some(Box::new("aiken::check")), } } } diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index ab44c08d..59ce0be1 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -9,6 +9,7 @@ pub mod error; pub mod format; pub mod module; pub mod script; +pub mod telemetry; use aiken_lang::{ ast::{Definition, Function, ModuleKind, TypedFunction}, @@ -25,12 +26,17 @@ use pallas::{ use pallas_traverse::ComputeHash; use script::Script; use serde_json::json; -use uplc::ast::{DeBruijn, Program}; +use telemetry::{EventListener, TestInfo}; +use uplc::{ + ast::{DeBruijn, Program}, + machine::cost_model::ExBudget, +}; use crate::{ config::Config, error::{Error, Warning}, module::{CheckedModule, CheckedModules, ParsedModule, ParsedModules}, + telemetry::Event, }; #[derive(Debug)] @@ -47,7 +53,10 @@ pub const MINT: &str = "mint"; pub const WITHDRAWL: &str = "withdrawl"; pub const VALIDATOR_NAMES: [&str; 4] = [SPEND, CERT, MINT, WITHDRAWL]; -pub struct Project { +pub struct Project +where + T: EventListener, +{ config: Config, defined_modules: HashMap, id_gen: IdGenerator, @@ -55,10 +64,14 @@ pub struct Project { root: PathBuf, sources: Vec, pub warnings: Vec, + event_listener: T, } -impl Project { - pub fn new(config: Config, root: PathBuf) -> Project { +impl Project +where + T: EventListener, +{ + pub fn new(config: Config, root: PathBuf, event_listener: T) -> Project { let id_gen = IdGenerator::new(); let mut module_types = HashMap::new(); @@ -74,34 +87,60 @@ impl Project { root, sources: vec![], warnings: vec![], + event_listener, } } pub fn build(&mut self, uplc: bool) -> Result<(), Error> { - self.compile(true, uplc) + self.compile(true, uplc, false) } - pub fn check(&mut self) -> Result<(), Error> { - self.compile(false, false) + pub fn check(&mut self, skip_tests: bool) -> Result<(), Error> { + self.compile(false, false, !skip_tests) } - pub fn compile(&mut self, uplc_gen: bool, uplc_dump: bool) -> Result<(), Error> { + pub fn compile( + &mut self, + uplc_gen: bool, + uplc_dump: bool, + run_tests: bool, + ) -> Result<(), Error> { + self.event_listener + .handle_event(Event::StartingCompilation { + root: self.root.clone(), + name: self.config.name.clone(), + version: self.config.version.clone(), + }); + + self.event_listener.handle_event(Event::ParsingProjectFiles); + self.read_source_files()?; let parsed_modules = self.parse_sources()?; let processing_sequence = parsed_modules.sequence()?; + self.event_listener.handle_event(Event::TypeChecking); + let mut checked_modules = self.type_check(parsed_modules, processing_sequence)?; let validators = self.validate_validators(&mut checked_modules)?; + // TODO: In principle, uplc_gen and run_tests can't be true together. We probably want to + // model the options differently to make it obvious at the type-level. if uplc_gen { + self.event_listener.handle_event(Event::GeneratingUPLC { + output_path: self.output_path(), + }); let programs = self.code_gen(validators, &checked_modules)?; - self.write_build_outputs(programs, uplc_dump)?; } + if run_tests { + let tests = self.test_gen(&checked_modules)?; + self.run_tests(tests); + } + Ok(()) } @@ -336,6 +375,7 @@ impl Project { func, ); } + Definition::Test(_) => {} Definition::TypeAlias(ta) => { type_aliases.insert((module.name.clone(), ta.alias.clone()), ta); } @@ -385,11 +425,135 @@ impl Project { Ok(programs) } - fn write_build_outputs(&self, programs: Vec