diff --git a/Cargo.lock b/Cargo.lock index 96fa1a15..27c650d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,7 +82,9 @@ name = "aiken-lang" version = "1.0.31-alpha" dependencies = [ "blst", + "built", "chumsky", + "cryptoxide", "hex", "indexmap 1.9.3", "indoc", @@ -93,6 +95,7 @@ dependencies = [ "ordinal", "owo-colors 3.5.0", "pallas-primitives", + "patricia_tree", "petgraph", "pretty_assertions", "serde", @@ -133,7 +136,6 @@ dependencies = [ "built", "camino", "ciborium", - "cryptoxide", "dirs", "fslock", "futures", @@ -152,7 +154,6 @@ dependencies = [ "pallas-crypto", "pallas-primitives", "pallas-traverse", - "patricia_tree", "petgraph", "pretty_assertions", "proptest", diff --git a/Cargo.toml b/Cargo.toml index 2c978863..701b6707 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,7 +48,7 @@ x86_64-unknown-linux-gnu = "ubuntu-22.04" [workspace.dependencies] walkdir = "2.3.2" insta = { version = "1.30.0", features = ["yaml", "json", "redactions"] } -miette = { version = "7.2.0", features = ["fancy"] } +miette = { version = "7.2.0" } pallas-addresses = "0.30.1" pallas-codec = { version = "0.30.1", features = ["num-bigint"] } pallas-crypto = "0.30.1" diff --git a/crates/aiken-lang/Cargo.toml b/crates/aiken-lang/Cargo.toml index 799c980f..cfba0881 100644 --- a/crates/aiken-lang/Cargo.toml +++ b/crates/aiken-lang/Cargo.toml @@ -15,6 +15,7 @@ rust-version = "1.66.1" [dependencies] blst = "0.3.11" +cryptoxide = "0.4.4" hex = "0.4.3" indexmap = "1.9.2" indoc = "2.0.1" @@ -24,6 +25,7 @@ num-bigint = "0.4.3" ordinal = "0.3.2" owo-colors = { version = "3.5.0", features = ["supports-colors"] } pallas-primitives.workspace = true +patricia_tree = "0.8.0" petgraph = "0.6.3" serde = { version = "1.0.197", features = ["derive", "rc"] } strum = "0.24.1" @@ -43,3 +45,6 @@ chumsky = { version = "0.9.2", features = [ indoc = "2.0.1" insta.workspace = true pretty_assertions = "1.3.0" + +[build-dependencies] +built = { version = "0.7.1", features = ["git2"] } diff --git a/crates/aiken-lang/build.rs b/crates/aiken-lang/build.rs new file mode 100644 index 00000000..d8f91cb9 --- /dev/null +++ b/crates/aiken-lang/build.rs @@ -0,0 +1,3 @@ +fn main() { + built::write_built_file().expect("Failed to acquire build-time information"); +} diff --git a/crates/aiken-lang/src/lib.rs b/crates/aiken-lang/src/lib.rs index c0ed6894..9eabbd89 100644 --- a/crates/aiken-lang/src/lib.rs +++ b/crates/aiken-lang/src/lib.rs @@ -14,7 +14,10 @@ pub mod line_numbers; pub mod parser; pub mod plutus_version; pub mod pretty; +pub mod test_framework; pub mod tipo; +pub mod utils; +pub mod version; #[derive(Debug, Default, Clone)] pub struct IdGenerator { diff --git a/crates/aiken-lang/src/test_framework.rs b/crates/aiken-lang/src/test_framework.rs new file mode 100644 index 00000000..1445cba3 --- /dev/null +++ b/crates/aiken-lang/src/test_framework.rs @@ -0,0 +1,1289 @@ +use crate::{ + ast::{BinOp, DataTypeKey, IfBranch, OnTestFailure, Span, TypedArg, TypedDataType, TypedTest}, + expr::{TypedExpr, UntypedExpr}, + format::Formatter, + gen_uplc::CodeGenerator, + plutus_version::PlutusVersion, + tipo::{convert_opaque_type, Type}, +}; +use cryptoxide::{blake2b::Blake2b, digest::Digest}; +use indexmap::IndexMap; +use itertools::Itertools; +use owo_colors::{OwoColorize, Stream}; +use pallas_primitives::alonzo::{Constr, PlutusData}; +use patricia_tree::PatriciaMap; +use std::{ + borrow::Borrow, collections::BTreeMap, convert::TryFrom, fmt::Debug, ops::Deref, path::PathBuf, + rc::Rc, +}; +use uplc::{ + ast::{Constant, Data, Name, NamedDeBruijn, Program, Term}, + machine::{cost_model::ExBudget, eval_result::EvalResult}, +}; +use vec1::{vec1, Vec1}; + +/// ----- Test ----------------------------------------------------------------- +/// +/// Aiken supports two kinds of tests: unit and property. A unit test is a simply +/// UPLC program which returns must be a lambda that returns a boolean. +/// +/// A property on the other-hand is a template for generating tests, which is also +/// a lambda but that takes an extra argument. The argument is generated from a +/// fuzzer which is meant to yield random values in a pseudo-random (albeit seeded) +/// sequence. On failures, the value that caused a failure is simplified using an +/// approach similar to what's described in MiniThesis, +/// which is a simplified version of Hypothesis, a property-based testing framework +/// with integrated shrinking. +/// +/// Our approach could perhaps be called "microthesis", as it implements a subset of +/// minithesis. More specifically, we do not currently support pre-conditions, nor +/// targets. +/// +#[derive(Debug, Clone)] +pub enum Test { + UnitTest(UnitTest), + PropertyTest(PropertyTest), +} + +unsafe impl Send for Test {} + +impl Test { + pub fn unit_test( + generator: &mut CodeGenerator<'_>, + test: TypedTest, + module_name: String, + input_path: PathBuf, + ) -> Test { + let program = generator.generate_raw(&test.body, &[], &module_name); + + let assertion = match test.body.try_into() { + Err(..) => None, + Ok(Assertion { bin_op, head, tail }) => { + let as_constant = |generator: &mut CodeGenerator<'_>, side| { + Program::::try_from(generator.generate_raw( + &side, + &[], + &module_name, + )) + .expect("failed to convert assertion operaand to NamedDeBruijn") + .eval(ExBudget::max()) + .unwrap_constant() + .map(|cst| (cst, side.tipo())) + }; + + // Assertion at this point is evaluated so it's not just a normal assertion + Some(Assertion { + bin_op, + head: as_constant(generator, head.expect("cannot be Err at this point")), + tail: tail + .expect("cannot be Err at this point") + .try_mapped(|e| as_constant(generator, e)), + }) + } + }; + + Test::UnitTest(UnitTest { + input_path, + module: module_name, + name: test.name, + program, + assertion, + on_test_failure: test.on_test_failure, + }) + } + + pub fn property_test( + input_path: PathBuf, + module: String, + name: String, + on_test_failure: OnTestFailure, + program: Program, + fuzzer: Fuzzer, + ) -> Test { + Test::PropertyTest(PropertyTest { + input_path, + module, + name, + program, + on_test_failure, + fuzzer, + }) + } + + pub fn from_function_definition( + generator: &mut CodeGenerator<'_>, + test: TypedTest, + module_name: String, + input_path: PathBuf, + ) -> Test { + if test.arguments.is_empty() { + Self::unit_test(generator, test, module_name, input_path) + } else { + let parameter = test.arguments.first().unwrap().to_owned(); + + let via = parameter.via.clone(); + + let type_info = parameter.arg.tipo.clone(); + + let stripped_type_info = convert_opaque_type(&type_info, generator.data_types(), true); + + let program = generator.clone().generate_raw( + &test.body, + &[TypedArg { + tipo: stripped_type_info.clone(), + ..parameter.clone().into() + }], + &module_name, + ); + + // NOTE: We need not to pass any parameter to the fuzzer here because the fuzzer + // argument is a Data constructor which needs not any conversion. So we can just safely + // apply onto it later. + let fuzzer = generator.clone().generate_raw(&via, &[], &module_name); + + Self::property_test( + input_path, + module_name, + test.name, + test.on_test_failure, + program, + Fuzzer { + program: fuzzer, + stripped_type_info, + type_info, + }, + ) + } + } +} + +/// ----- UnitTest ----------------------------------------------------------------- +/// +#[derive(Debug, Clone)] +pub struct UnitTest { + pub input_path: PathBuf, + pub module: String, + pub name: String, + pub on_test_failure: OnTestFailure, + pub program: Program, + pub assertion: Option)>>, +} + +unsafe impl Send for UnitTest {} + +impl UnitTest { + pub fn run(self, plutus_version: &PlutusVersion) -> TestResult<(Constant, Rc), T> { + let mut eval_result = Program::::try_from(self.program.clone()) + .unwrap() + .eval_version(ExBudget::max(), &plutus_version.into()); + + let success = !eval_result.failed(match self.on_test_failure { + OnTestFailure::SucceedEventually | OnTestFailure::SucceedImmediately => true, + OnTestFailure::FailImmediately => false, + }); + + TestResult::UnitTestResult(UnitTestResult { + success, + test: self.to_owned(), + spent_budget: eval_result.cost(), + traces: eval_result.logs(), + assertion: self.assertion, + }) + } +} + +/// ----- PropertyTest ----------------------------------------------------------------- +/// +#[derive(Debug, Clone)] +pub struct PropertyTest { + pub input_path: PathBuf, + pub module: String, + pub name: String, + pub on_test_failure: OnTestFailure, + pub program: Program, + pub fuzzer: Fuzzer, +} + +unsafe impl Send for PropertyTest {} + +#[derive(Debug, Clone)] +pub struct Fuzzer { + pub program: Program, + + pub type_info: Rc, + + /// A version of the Fuzzer's type that has gotten rid of + /// all erasable opaque type. This is needed in order to + /// generate Plutus data with the appropriate shape. + pub stripped_type_info: Rc, +} + +#[derive(Debug, Clone, thiserror::Error, miette::Diagnostic)] +#[error("Fuzzer exited unexpectedly: {uplc_error}")] +pub struct FuzzerError { + traces: Vec, + uplc_error: uplc::machine::Error, +} + +impl PropertyTest { + pub const DEFAULT_MAX_SUCCESS: usize = 100; + + /// Run a property test from a given seed. The property is run at most DEFAULT_MAX_SUCCESS times. It + /// may stops earlier on failure; in which case a 'counterexample' is returned. + pub fn run( + self, + seed: u32, + n: usize, + plutus_version: &PlutusVersion, + ) -> TestResult { + let mut labels = BTreeMap::new(); + let mut remaining = n; + + let (traces, counterexample, iterations) = match self.run_n_times( + &mut remaining, + Prng::from_seed(seed), + &mut labels, + plutus_version, + ) { + Ok(None) => (Vec::new(), Ok(None), n), + Ok(Some(counterexample)) => ( + self.eval(&counterexample.value, plutus_version) + .logs() + .into_iter() + .filter(|s| PropertyTest::extract_label(s).is_none()) + .collect(), + Ok(Some(counterexample.value)), + n - remaining, + ), + Err(FuzzerError { traces, uplc_error }) => ( + traces + .into_iter() + .filter(|s| PropertyTest::extract_label(s).is_none()) + .collect(), + Err(uplc_error), + n - remaining + 1, + ), + }; + + TestResult::PropertyTestResult(PropertyTestResult { + test: self, + counterexample, + iterations, + labels, + traces, + }) + } + + fn run_n_times<'a>( + &'a self, + remaining: &mut usize, + initial_prng: Prng, + labels: &mut BTreeMap, + plutus_version: &'a PlutusVersion, + ) -> Result>, FuzzerError> { + let mut prng = initial_prng; + let mut counterexample = None; + + while *remaining > 0 && counterexample.is_none() { + (prng, counterexample) = self.run_once(prng, labels, plutus_version)?; + *remaining -= 1; + } + + Ok(counterexample) + } + + fn run_once<'a>( + &'a self, + prng: Prng, + labels: &mut BTreeMap, + plutus_version: &'a PlutusVersion, + ) -> Result<(Prng, Option>), FuzzerError> { + use OnTestFailure::*; + + let (next_prng, value) = prng + .sample(&self.fuzzer.program)? + .expect("A seeded PRNG returned 'None' which indicates a fuzzer is ill-formed and implemented wrongly; please contact library's authors."); + + let mut result = self.eval(&value, plutus_version); + + for s in result.logs() { + // NOTE: There may be other log outputs that interefere with labels. So *by + // convention*, we treat as label strings that starts with a NUL byte, which + // should be a guard sufficient to prevent inadvertent clashes. + if let Some(label) = PropertyTest::extract_label(&s) { + labels + .entry(label) + .and_modify(|count| *count += 1) + .or_insert(1); + } + } + + let is_failure = result.failed(false); + + let is_success = !is_failure; + + let keep_counterexample = match self.on_test_failure { + FailImmediately | SucceedImmediately => is_failure, + SucceedEventually => is_success, + }; + + if keep_counterexample { + let mut counterexample = Counterexample { + value, + choices: next_prng.choices(), + cache: Cache::new(|choices| { + match Prng::from_choices(choices).sample(&self.fuzzer.program) { + Err(..) => Status::Invalid, + Ok(None) => Status::Invalid, + Ok(Some((_, value))) => { + let result = self.eval(&value, plutus_version); + + let is_failure = result.failed(false); + + match self.on_test_failure { + FailImmediately | SucceedImmediately => { + if is_failure { + Status::Keep(value) + } else { + Status::Ignore + } + } + + SucceedEventually => { + if is_failure { + Status::Ignore + } else { + Status::Keep(value) + } + } + } + } + } + }), + }; + + if !counterexample.choices.is_empty() { + counterexample.simplify(); + } + + Ok((next_prng, Some(counterexample))) + } else { + Ok((next_prng, None)) + } + } + + pub fn eval(&self, value: &PlutusData, plutus_version: &PlutusVersion) -> EvalResult { + let program = self.program.apply_data(value.clone()); + + Program::::try_from(program) + .unwrap() + .eval_version(ExBudget::max(), &plutus_version.into()) + } + + fn extract_label(s: &str) -> Option { + if s.starts_with('\0') { + Some(s.split_at(1).1.to_string()) + } else { + None + } + } +} + +/// ----- PRNG ----------------------------------------------------------------- +/// +/// A Pseudo-random generator (PRNG) used to produce random values for fuzzers. +/// Note that the randomness isn't actually managed by the Rust framework, it +/// entirely relies on properties of hashing algorithm on-chain (e.g. blake2b). +/// +/// The PRNG can have two forms: +/// +/// 1. Seeded: which occurs during the initial run of a property. Each time a +/// number is drawn from the PRNG, a new seed is created. We retain all the +/// choices drawn in a _choices_ vector. +/// +/// 2. Replayed: which is used to replay a Prng sequenced from a list of known +/// choices. This happens when shrinking an example. Instead of trying to +/// shrink the value directly, we shrink the PRNG sequence with the hope that +/// it will generate a smaller value. This implies that generators tend to +/// generate smaller values when drawing smaller numbers. +/// +#[derive(Debug)] +pub enum Prng { + Seeded { choices: Vec, uplc: PlutusData }, + Replayed { choices: Vec, uplc: PlutusData }, +} + +impl Prng { + /// Constructor tag for Prng's 'Seeded' + const SEEDED: u64 = 0; + /// Constructor tag for Prng's 'Replayed' + const REPLAYED: u64 = 1; + + /// Constructor tag for Option's 'Some' + const SOME: u64 = 0; + /// Constructor tag for Option's 'None' + const NONE: u64 = 1; + + pub fn uplc(&self) -> PlutusData { + match self { + Prng::Seeded { uplc, .. } => uplc.clone(), + Prng::Replayed { uplc, .. } => uplc.clone(), + } + } + + pub fn choices(&self) -> Vec { + match self { + Prng::Seeded { choices, .. } => { + let mut choices = choices.to_vec(); + choices.reverse(); + choices + } + Prng::Replayed { choices, .. } => choices.to_vec(), + } + } + + /// Construct a Pseudo-random number generator from a seed. + pub fn from_seed(seed: u32) -> Prng { + let mut digest = [0u8; 32]; + let mut context = Blake2b::new(32); + context.input(&seed.to_be_bytes()[..]); + context.result(&mut digest); + + Prng::Seeded { + choices: vec![], + uplc: Data::constr( + Prng::SEEDED, + vec![ + Data::bytestring(digest.to_vec()), // Prng's seed + Data::bytestring(vec![]), // Random choices + ], + ), + } + } + + /// Construct a Pseudo-random number generator from a pre-defined list of choices. + pub fn from_choices(choices: &[u8]) -> Prng { + Prng::Replayed { + uplc: Data::constr( + Prng::REPLAYED, + vec![ + Data::integer(choices.len().into()), + Data::bytestring(choices.iter().rev().cloned().collect::>()), + ], + ), + choices: choices.to_vec(), + } + } + + /// Generate a pseudo-random value from a fuzzer using the given PRNG. + pub fn sample( + &self, + fuzzer: &Program, + ) -> Result, FuzzerError> { + let program = Program::::try_from(fuzzer.apply_data(self.uplc())).unwrap(); + let mut result = program.eval(ExBudget::max()); + result + .result() + .map_err(|uplc_error| FuzzerError { + traces: result.logs(), + uplc_error, + }) + .map(Prng::from_result) + } + + /// Obtain a Prng back from a fuzzer execution. As a reminder, fuzzers have the following + /// signature: + /// + /// `type Fuzzer = fn(Prng) -> Option<(Prng, a)>` + /// + /// In nominal scenarios (i.e. when the fuzzer is made from a seed and evolve pseudo-randomly), + /// it cannot yield 'None'. When replayed however, we can't easily guarantee that the changes + /// made during shrinking aren't breaking underlying invariants (if only, because we run out of + /// values to replay). In such case, the replayed sequence is simply invalid and the fuzzer + /// aborted altogether with 'None'. + pub fn from_result(result: Term) -> Option<(Self, PlutusData)> { + /// Interpret the given 'PlutusData' as one of two Prng constructors. + fn as_prng(cst: &PlutusData) -> Prng { + if let PlutusData::Constr(Constr { tag, fields, .. }) = cst { + if *tag == 121 + Prng::SEEDED { + if let [PlutusData::BoundedBytes(bytes), PlutusData::BoundedBytes(choices)] = + &fields[..] + { + return Prng::Seeded { + choices: choices.to_vec(), + uplc: PlutusData::Constr(Constr { + tag: 121 + Prng::SEEDED, + fields: vec![ + PlutusData::BoundedBytes(bytes.to_owned()), + // Clear choices between seeded runs, to not + // accumulate ALL choices ever made. + PlutusData::BoundedBytes(vec![].into()), + ], + any_constructor: None, + }), + }; + } + } + + if *tag == 121 + Prng::REPLAYED { + if let [PlutusData::BigInt(..), PlutusData::BoundedBytes(choices)] = &fields[..] + { + return Prng::Replayed { + choices: choices.to_vec(), + uplc: cst.clone(), + }; + } + } + } + + unreachable!("malformed Prng: {cst:#?}") + } + + if let Term::Constant(rc) = &result { + if let Constant::Data(PlutusData::Constr(Constr { tag, fields, .. })) = &rc.borrow() { + if *tag == 121 + Prng::SOME { + if let [PlutusData::Array(elems)] = &fields[..] { + if let [new_seed, value] = &elems[..] { + return Some((as_prng(new_seed), value.clone())); + } + } + } + + // May occurs when replaying a fuzzer from a shrinked sequence of + // choices. If we run out of choices, or a choice end up being + // invalid as per the expectation, the fuzzer can't go further and + // fail. + if *tag == 121 + Prng::NONE { + return None; + } + } + } + + unreachable!("Fuzzer yielded a malformed result? {result:#?}") + } +} + +/// ----- Counterexample ----------------------------------------------------------------- +/// +/// A counterexample is constructed from a test failure. It holds a value, and a sequence +/// of random choices that led to this value. It holds a reference to the underlying +/// property and fuzzer. In many cases, a counterexample can be simplified (a.k.a "shrinked") +/// into a smaller counterexample. +pub struct Counterexample<'a> { + pub value: PlutusData, + pub choices: Vec, + pub cache: Cache<'a, PlutusData>, +} + +impl<'a> Counterexample<'a> { + fn consider(&mut self, choices: &[u8]) -> bool { + if choices == self.choices { + return true; + } + + match self.cache.get(choices) { + Status::Invalid | Status::Ignore => false, + Status::Keep(value) => { + // If these new choices are shorter or smaller, then we pick them + // as new choices and inform that it's been an improvement. + if choices.len() <= self.choices.len() || choices < &self.choices[..] { + self.value = value; + self.choices = choices.to_vec(); + true + } else { + false + } + } + } + } + + /// Try to simplify a 'Counterexample' by manipulating the random sequence of generated values + /// (a.k.a. choices). While the implementation is quite involved, the strategy is rather simple + /// at least conceptually: + /// + /// Each time a (seeded) fuzzer generates a new value and a new seed, it also stores the + /// generated value in a vector, which we call 'choices'. If we re-run the test case with this + /// exact choice sequence, we end up with the exact same outcome. + /// + /// But, we can tweak chunks of this sequence in hope to generate a _smaller sequence_, thus + /// generally resulting in a _smaller counterexample_. Each transformations is applied on + /// chunks of size 8, 4, 2 and 1; until we no longer make progress (i.e. hit a fix point). + /// + /// As per MiniThesis, we consider the following transformations: + /// + /// - Deleting chunks + /// - Transforming chunks into sequence of zeroes + /// - Replacing chunks of values with smaller values + /// - Sorting chunks in ascending order + /// - Swapping nearby pairs + /// - Redistributing values between nearby pairs + fn simplify(&mut self) { + let mut prev; + + loop { + prev = self.choices.clone(); + + // First try deleting each choice we made in chunks. We try longer chunks because this + // allows us to delete whole composite elements: e.g. deleting an element from a + // generated list requires us to delete both the choice of whether to include it and + // also the element itself, which may involve more than one choice. + let mut k = 8; + while k > 0 { + let (mut i, mut underflow) = if self.choices.len() < k { + (0, true) + } else { + (self.choices.len() - k, false) + }; + + while !underflow { + if i >= self.choices.len() { + (i, underflow) = i.overflowing_sub(1); + continue; + } + + let j = i + k; + + let mut choices = [ + &self.choices[..i], + if j < self.choices.len() { + &self.choices[j..] + } else { + &[] + }, + ] + .concat(); + + if !self.consider(&choices) { + // Perform an extra reduction step that decrease the size of choices near + // the end, to cope with dependencies between choices, e.g. drawing a + // number as a list length, and then drawing that many elements. + // + // This isn't perfect, but allows to make progresses in many cases. + if i > 0 && choices[i - 1] > 0 { + choices[i - 1] -= 1; + if self.consider(&choices) { + i += 1; + }; + } + + (i, underflow) = i.overflowing_sub(1); + } + } + + k /= 2 + } + + if !self.choices.is_empty() { + // Now we try replacing region of choices with zeroes. Note that unlike the above we + // skip k = 1 because we handle that in the next step. Often (but not always) a block + // of all zeroes is the smallest value that a region can be. + let mut k = 8; + while k > 1 { + let mut i = self.choices.len(); + while i >= k { + let ivs = (i - k..i).map(|j| (j, 0)).collect::>(); + i -= if self.replace(ivs) { k } else { 1 } + } + k /= 2 + } + + // Replace choices with smaller value, by doing a binary search. This will replace n + // with 0 or n - 1, if possible, but will also more efficiently replace it with, a + // smaller number than doing multiple subtractions would. + let (mut i, mut underflow) = (self.choices.len() - 1, false); + while !underflow { + self.binary_search_replace(0, self.choices[i], |v| vec![(i, v)]); + (i, underflow) = i.overflowing_sub(1); + } + + // Sort out of orders chunks in ascending order + let mut k = 8; + while k > 1 { + let mut i = self.choices.len() - 1; + while i >= k { + let (from, to) = (i - k, i); + self.replace( + (from..to) + .zip(self.choices[from..to].iter().cloned().sorted()) + .collect(), + ); + i -= 1; + } + k /= 2 + } + + // Try adjusting nearby pairs by: + // + // - Swapping them if they are out-of-order + // - Redistributing values between them. + for k in [2, 1] { + let mut j = self.choices.len() - 1; + while j >= k { + let i = j - k; + + // Swap + if self.choices[i] > self.choices[j] { + self.replace(vec![(i, self.choices[j]), (j, self.choices[i])]); + } + + let iv = self.choices[i]; + let jv = self.choices[j]; + + // Replace + if iv > 0 && jv <= u8::MAX - iv { + self.binary_search_replace(0, iv, |v| vec![(i, v), (j, jv + (iv - v))]); + } + + j -= 1 + } + } + } + + // If we've reached a fixed point, then we cannot shrink further. We've reached a + // (local) minimum, which is as good as a counterexample we'll get with this approach. + if prev.as_slice() == self.choices.as_slice() { + break; + } + } + } + + /// Try to replace a value with a smaller value by doing a binary search between + /// two extremes. This converges relatively fast in order to shrink down values. + fn binary_search_replace(&mut self, lo: u8, hi: u8, f: F) -> u8 + where + F: Fn(u8) -> Vec<(usize, u8)>, + { + if self.replace(f(lo)) { + return lo; + } + + let mut lo = lo; + let mut hi = hi; + + while lo + 1 < hi { + let mid = lo + (hi - lo) / 2; + if self.replace(f(mid)) { + hi = mid; + } else { + lo = mid; + } + } + + hi + } + + // Replace values in the choices vector, based on the index-value list provided + // and consider the resulting choices. + fn replace(&mut self, ivs: Vec<(usize, u8)>) -> bool { + let mut choices = self.choices.clone(); + + for (i, v) in ivs { + if i >= choices.len() { + return false; + } + choices[i] = v; + } + + self.consider(&choices) + } +} + +/// ----- Cache ----------------------------------------------------------------------- +/// +/// A simple cache as a Patricia-trie to look for already explored options. The simplification +/// steps does often generate the same paths and the generation of new test values as well as the +/// properties can take a significant time. +/// +/// Yet, sequences have interesting properties: +/// +/// 1. The generation and test execution is entirely deterministic. +/// +/// +pub struct Cache<'a, T> { + db: PatriciaMap>, + #[allow(clippy::type_complexity)] + run: Box Status + 'a>, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Status { + Keep(T), + Ignore, + Invalid, +} + +impl<'a, T> Cache<'a, T> +where + T: PartialEq + Clone, +{ + pub fn new(run: F) -> Cache<'a, T> + where + F: Fn(&[u8]) -> Status + 'a, + { + Cache { + db: PatriciaMap::new(), + run: Box::new(run), + } + } + + pub fn size(&self) -> usize { + self.db.len() + } + + pub fn get(&mut self, choices: &[u8]) -> Status { + if let Some((prefix, status)) = self.db.get_longest_common_prefix(choices) { + let status = status.clone(); + if status != Status::Invalid || prefix == choices { + return status; + } + } + + let status = self.run.deref()(choices); + + // Clear longer path on non-invalid cases, as we will never reach them + // again due to a now-shorter prefix found. + // + // This hopefully keeps the cache under a reasonable size as we prune + // the tree as we discover shorter paths. + if status != Status::Invalid { + let keys = self + .db + .iter_prefix(choices) + .map(|(k, _)| k) + .collect::>(); + for k in keys { + self.db.remove(k); + } + } + + self.db.insert(choices, status.clone()); + + status + } +} + +// ---------------------------------------------------------------------------- +// +// TestResult +// +// ---------------------------------------------------------------------------- + +#[derive(Debug)] +pub enum TestResult { + UnitTestResult(UnitTestResult), + PropertyTestResult(PropertyTestResult), +} + +unsafe impl Send for TestResult {} + +impl TestResult<(Constant, Rc), PlutusData> { + pub fn reify( + self, + data_types: &IndexMap<&DataTypeKey, &TypedDataType>, + ) -> TestResult { + match self { + TestResult::UnitTestResult(test) => TestResult::UnitTestResult(test.reify(data_types)), + TestResult::PropertyTestResult(test) => { + TestResult::PropertyTestResult(test.reify(data_types)) + } + } + } +} + +impl TestResult { + pub fn is_success(&self) -> bool { + match self { + TestResult::UnitTestResult(UnitTestResult { success, .. }) => *success, + TestResult::PropertyTestResult(PropertyTestResult { + counterexample: Err(..), + .. + }) => false, + TestResult::PropertyTestResult(PropertyTestResult { + counterexample: Ok(counterexample), + test, + .. + }) => match test.on_test_failure { + OnTestFailure::FailImmediately | OnTestFailure::SucceedEventually => { + counterexample.is_none() + } + OnTestFailure::SucceedImmediately => counterexample.is_some(), + }, + } + } + + pub fn module(&self) -> &str { + match self { + TestResult::UnitTestResult(UnitTestResult { ref test, .. }) => test.module.as_str(), + TestResult::PropertyTestResult(PropertyTestResult { ref test, .. }) => { + test.module.as_str() + } + } + } + + pub fn title(&self) -> &str { + match self { + TestResult::UnitTestResult(UnitTestResult { ref test, .. }) => test.name.as_str(), + TestResult::PropertyTestResult(PropertyTestResult { ref test, .. }) => { + test.name.as_str() + } + } + } + + pub fn traces(&self) -> &[String] { + match self { + TestResult::UnitTestResult(UnitTestResult { ref traces, .. }) + | TestResult::PropertyTestResult(PropertyTestResult { ref traces, .. }) => { + traces.as_slice() + } + } + } +} + +#[derive(Debug)] +pub struct UnitTestResult { + pub success: bool, + pub spent_budget: ExBudget, + pub traces: Vec, + pub test: UnitTest, + pub assertion: Option>, +} + +unsafe impl Send for UnitTestResult {} + +impl UnitTestResult<(Constant, Rc)> { + pub fn reify( + self, + data_types: &IndexMap<&DataTypeKey, &TypedDataType>, + ) -> UnitTestResult { + UnitTestResult { + success: self.success, + spent_budget: self.spent_budget, + traces: self.traces, + test: self.test, + assertion: self.assertion.and_then(|assertion| { + // No need to spend time/cpu on reifying assertions for successful + // tests since they aren't shown. + if self.success { + return None; + } + + Some(Assertion { + bin_op: assertion.bin_op, + head: assertion.head.map(|(cst, tipo)| { + UntypedExpr::reify_constant(data_types, cst, &tipo) + .expect("failed to reify assertion operand?") + }), + tail: assertion.tail.map(|xs| { + xs.mapped(|(cst, tipo)| { + UntypedExpr::reify_constant(data_types, cst, &tipo) + .expect("failed to reify assertion operand?") + }) + }), + }) + }), + } + } +} + +#[derive(Debug)] +pub struct PropertyTestResult { + pub test: PropertyTest, + pub counterexample: Result, uplc::machine::Error>, + pub iterations: usize, + pub labels: BTreeMap, + pub traces: Vec, +} + +unsafe impl Send for PropertyTestResult {} + +impl PropertyTestResult { + pub fn reify( + self, + data_types: &IndexMap<&DataTypeKey, &TypedDataType>, + ) -> PropertyTestResult { + PropertyTestResult { + counterexample: self.counterexample.map(|ok| { + ok.map(|counterexample| { + UntypedExpr::reify_data(data_types, counterexample, &self.test.fuzzer.type_info) + .expect("Failed to reify counterexample?") + }) + }), + iterations: self.iterations, + test: self.test, + labels: self.labels, + traces: self.traces, + } + } +} + +#[derive(Debug, Clone)] +pub struct Assertion { + pub bin_op: BinOp, + pub head: Result, + pub tail: Result, ()>, +} + +impl TryFrom for Assertion { + type Error = (); + + fn try_from(body: TypedExpr) -> Result { + match body { + TypedExpr::BinOp { + name, + tipo, + left, + right, + .. + } if tipo == Type::bool() => { + // 'and' and 'or' are left-associative operators. + match (*right).clone().try_into() { + Ok(Assertion { + bin_op, + head: Ok(head), + tail: Ok(tail), + .. + }) if bin_op == name => { + let mut both = vec1![head]; + both.extend(tail); + Ok(Assertion { + bin_op: name, + head: Ok(*left), + tail: Ok(both), + }) + } + _ => Ok(Assertion { + bin_op: name, + head: Ok(*left), + tail: Ok(vec1![*right]), + }), + } + } + + // NOTE drill through trace-if-false operators for better errors. + TypedExpr::If { + branches, + final_else, + .. + } => { + if let [IfBranch { + condition, body, .. + }] = &branches[..] + { + let then_is_true = match body { + TypedExpr::Var { + name, constructor, .. + } => name == "True" && constructor.tipo == Type::bool(), + _ => false, + }; + + let else_is_wrapped_false = match *final_else { + TypedExpr::Trace { then, .. } => match *then { + TypedExpr::Var { + name, constructor, .. + } => name == "False" && constructor.tipo == Type::bool(), + _ => false, + }, + _ => false, + }; + + if then_is_true && else_is_wrapped_false { + return condition.to_owned().try_into(); + } + } + + Err(()) + } + + TypedExpr::Trace { then, .. } => (*then).try_into(), + + TypedExpr::Sequence { expressions, .. } | TypedExpr::Pipeline { expressions, .. } => { + if let Ok(Assertion { + bin_op, + head: Ok(head), + tail: Ok(tail), + }) = expressions.last().unwrap().to_owned().try_into() + { + let replace = |expr| { + let mut expressions = expressions.clone(); + expressions.pop(); + expressions.push(expr); + TypedExpr::Sequence { + expressions, + location: Span::empty(), + } + }; + + Ok(Assertion { + bin_op, + head: Ok(replace(head)), + tail: Ok(tail.mapped(replace)), + }) + } else { + Err(()) + } + } + _ => Err(()), + } + } +} + +impl Assertion { + #[allow(clippy::just_underscores_and_digits)] + pub fn to_string(&self, stream: Stream, expect_failure: bool) -> String { + let red = |s: &str| { + format!("× {s}") + .if_supports_color(stream, |s| s.red()) + .if_supports_color(stream, |s| s.bold()) + .to_string() + }; + + // head did not map to a constant + if self.head.is_err() { + return red("program failed"); + } + + // any value in tail did not map to a constant + if self.tail.is_err() { + return red("program failed"); + } + + fn fmt_side(side: &UntypedExpr, stream: Stream) -> String { + let __ = "│".if_supports_color(stream, |s| s.red()); + + Formatter::new() + .expr(side, false) + .to_pretty_string(60) + .lines() + .map(|line| format!("{__} {line}")) + .collect::>() + .join("\n") + } + + let left = fmt_side(self.head.as_ref().unwrap(), stream); + + let tail = self.tail.as_ref().unwrap(); + + let right = fmt_side(tail.first(), stream); + + format!( + "{}{}{}", + red("expected"), + if expect_failure && self.bin_op == BinOp::Or { + " neither\n" + .if_supports_color(stream, |s| s.red()) + .if_supports_color(stream, |s| s.bold()) + .to_string() + } else { + "\n".to_string() + }, + if expect_failure { + match self.bin_op { + BinOp::And => [ + left, + red("and"), + [ + tail.mapped_ref(|s| fmt_side(s, stream)) + .join(format!("\n{}\n", red("and")).as_str()), + if tail.len() > 1 { + red("to not all be true") + } else { + red("to not both be true") + }, + ] + .join("\n"), + ], + BinOp::Or => [ + left, + red("nor"), + [ + tail.mapped_ref(|s| fmt_side(s, stream)) + .join(format!("\n{}\n", red("nor")).as_str()), + red("to be true"), + ] + .join("\n"), + ], + BinOp::Eq => [left, red("to not equal"), right], + BinOp::NotEq => [left, red("to not be different"), right], + BinOp::LtInt => [left, red("to not be lower than"), right], + BinOp::LtEqInt => [left, red("to not be lower than or equal to"), right], + BinOp::GtInt => [left, red("to not be greater than"), right], + BinOp::GtEqInt => [left, red("to not be greater than or equal to"), right], + _ => unreachable!("unexpected non-boolean binary operator in assertion?"), + } + .join("\n") + } else { + match self.bin_op { + BinOp::And => [ + left, + red("and"), + [ + tail.mapped_ref(|s| fmt_side(s, stream)) + .join(format!("\n{}\n", red("and")).as_str()), + if tail.len() > 1 { + red("to all be true") + } else { + red("to both be true") + }, + ] + .join("\n"), + ], + BinOp::Or => [ + left, + red("or"), + [ + tail.mapped_ref(|s| fmt_side(s, stream)) + .join(format!("\n{}\n", red("or")).as_str()), + red("to be true"), + ] + .join("\n"), + ], + BinOp::Eq => [left, red("to equal"), right], + BinOp::NotEq => [left, red("to not equal"), right], + BinOp::LtInt => [left, red("to be lower than"), right], + BinOp::LtEqInt => [left, red("to be lower than or equal to"), right], + BinOp::GtInt => [left, red("to be greater than"), right], + BinOp::GtEqInt => [left, red("to be greater than or equal to"), right], + _ => unreachable!("unexpected non-boolean binary operator in assertion?"), + } + .join("\n") + } + ) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_cache() { + let called = std::cell::RefCell::new(0); + + let mut cache = Cache::new(|choices| { + called.replace_with(|n| *n + 1); + + match choices { + [0, 0, 0] => Status::Keep(true), + _ => { + if choices.len() <= 2 { + Status::Invalid + } else { + Status::Ignore + } + } + } + }); + + assert_eq!(cache.get(&[1, 1]), Status::Invalid); // Fn executed + assert_eq!(cache.get(&[1, 1, 2, 3]), Status::Ignore); // Fn executed + assert_eq!(cache.get(&[1, 1, 2]), Status::Ignore); // Fnexecuted + assert_eq!(cache.get(&[1, 1, 2, 2]), Status::Ignore); // Cached result + assert_eq!(cache.get(&[1, 1, 2, 1]), Status::Ignore); // Cached result + assert_eq!(cache.get(&[0, 1, 2]), Status::Ignore); // Fn executed + assert_eq!(cache.get(&[0, 0, 0]), Status::Keep(true)); // Fn executed + assert_eq!(cache.get(&[0, 0, 0]), Status::Keep(true)); // Cached result + + assert_eq!(called.borrow().deref().to_owned(), 5, "execution calls"); + assert_eq!(cache.size(), 4, "cache size"); + } +} diff --git a/crates/aiken-project/src/utils/indexmap.rs b/crates/aiken-lang/src/utils/indexmap.rs similarity index 100% rename from crates/aiken-project/src/utils/indexmap.rs rename to crates/aiken-lang/src/utils/indexmap.rs diff --git a/crates/aiken-project/src/utils/mod.rs b/crates/aiken-lang/src/utils/mod.rs similarity index 100% rename from crates/aiken-project/src/utils/mod.rs rename to crates/aiken-lang/src/utils/mod.rs diff --git a/crates/aiken-lang/src/utils/version.rs b/crates/aiken-lang/src/utils/version.rs new file mode 100644 index 00000000..e69de29b diff --git a/crates/aiken-lang/src/version.rs b/crates/aiken-lang/src/version.rs new file mode 100644 index 00000000..45604c45 --- /dev/null +++ b/crates/aiken-lang/src/version.rs @@ -0,0 +1,15 @@ +pub fn compiler_version(include_commit_hash: bool) -> String { + if include_commit_hash { + format!( + "v{}+{}", + built_info::PKG_VERSION, + built_info::GIT_COMMIT_HASH_SHORT.unwrap_or("unknown") + ) + } else { + format!("v{}", built_info::PKG_VERSION,) + } +} + +mod built_info { + include!(concat!(env!("OUT_DIR"), "/built.rs")); +} diff --git a/crates/aiken-lsp/src/server/lsp_project.rs b/crates/aiken-lsp/src/server/lsp_project.rs index efd36ab6..9ab0367f 100644 --- a/crates/aiken-lsp/src/server/lsp_project.rs +++ b/crates/aiken-lsp/src/server/lsp_project.rs @@ -1,8 +1,5 @@ -use aiken_lang::{ast::Tracing, line_numbers::LineNumbers}; -use aiken_project::{ - config::Config, error::Error as ProjectError, module::CheckedModule, - test_framework::PropertyTest, Project, -}; +use aiken_lang::{ast::Tracing, line_numbers::LineNumbers, test_framework::PropertyTest}; +use aiken_project::{config::Config, error::Error as ProjectError, module::CheckedModule, Project}; use std::{collections::HashMap, path::PathBuf}; #[derive(Debug)] diff --git a/crates/aiken-project/Cargo.toml b/crates/aiken-project/Cargo.toml index 7b71c7b8..bad168a3 100644 --- a/crates/aiken-project/Cargo.toml +++ b/crates/aiken-project/Cargo.toml @@ -19,7 +19,6 @@ aiken-lang = { path = "../aiken-lang", version = "1.0.31-alpha" } askama = { version = "0.12.0", features = ["urlencode"] } camino = "1.1.9" ciborium = "0.2.2" -cryptoxide = "0.4.4" dirs = "4.0.0" fslock = "0.2.1" futures = "0.3.26" @@ -27,7 +26,7 @@ hex = "0.4.3" ignore = "0.4.20" indexmap = "1.9.2" itertools = "0.10.5" -miette.workspace = true +miette = { version = "7.2.0", features = ["fancy"] } notify = "6.1.1" num-bigint = "0.4.4" owo-colors = { version = "3.5.0", features = ["supports-colors"] } @@ -36,7 +35,6 @@ pallas-codec.workspace = true pallas-crypto.workspace = true pallas-primitives.workspace = true pallas-traverse.workspace = true -patricia_tree = "0.8.0" petgraph = "0.6.3" pulldown-cmark = { version = "0.12.0", default-features = false, features = ["html"] } rayon = "1.7.0" diff --git a/crates/aiken-project/src/config.rs b/crates/aiken-project/src/config.rs index 46464816..4f5e7153 100644 --- a/crates/aiken-project/src/config.rs +++ b/crates/aiken-project/src/config.rs @@ -1,5 +1,4 @@ use crate::{github::repo::LatestRelease, package_name::PackageName, paths, Error}; -pub use aiken_lang::plutus_version::PlutusVersion; use aiken_lang::{ ast::{ Annotation, ByteArrayFormatPreference, Constant, ModuleConstant, Span, UntypedDefinition, @@ -7,6 +6,7 @@ use aiken_lang::{ expr::UntypedExpr, parser::token::Base, }; +pub use aiken_lang::{plutus_version::PlutusVersion, version::compiler_version}; use miette::NamedSource; use semver::Version; use serde::{ @@ -355,18 +355,6 @@ mod built_info { include!(concat!(env!("OUT_DIR"), "/built.rs")); } -pub fn compiler_version(include_commit_hash: bool) -> String { - if include_commit_hash { - format!( - "v{}+{}", - built_info::PKG_VERSION, - built_info::GIT_COMMIT_HASH_SHORT.unwrap_or("unknown") - ) - } else { - format!("v{}", built_info::PKG_VERSION,) - } -} - pub fn compiler_info() -> String { format!( r#" diff --git a/crates/aiken-project/src/error.rs b/crates/aiken-project/src/error.rs index 05e030e3..83abec07 100644 --- a/crates/aiken-project/src/error.rs +++ b/crates/aiken-project/src/error.rs @@ -3,6 +3,7 @@ use aiken_lang::{ ast::{self, Span}, error::ExtraData, parser::error::ParseError, + test_framework::{PropertyTestResult, TestResult, UnitTestResult}, tipo, }; use miette::{ @@ -162,6 +163,28 @@ impl Error { errors } + + pub fn from_test_result(result: &TestResult, verbose: bool) -> Self { + let (name, path, src) = match result { + TestResult::UnitTestResult(UnitTestResult { test, .. }) => ( + test.name.to_string(), + test.input_path.to_path_buf(), + test.program.to_pretty(), + ), + TestResult::PropertyTestResult(PropertyTestResult { test, .. }) => ( + test.name.to_string(), + test.input_path.to_path_buf(), + test.program.to_pretty(), + ), + }; + + Error::TestFailure { + name, + path, + src, + verbose, + } + } } impl Debug for Error { diff --git a/crates/aiken-project/src/lib.rs b/crates/aiken-project/src/lib.rs index 2c2df06d..32a57a91 100644 --- a/crates/aiken-project/src/lib.rs +++ b/crates/aiken-project/src/lib.rs @@ -12,10 +12,10 @@ pub mod package_name; pub mod paths; pub mod pretty; pub mod telemetry; -pub mod test_framework; -pub mod utils; pub mod watch; +mod test_framework; + #[cfg(test)] mod tests; @@ -40,8 +40,9 @@ use aiken_lang::{ format::{Formatter, MAX_COLUMNS}, gen_uplc::CodeGenerator, line_numbers::LineNumbers, + test_framework::{Test, TestResult}, tipo::{Type, TypeInfo}, - IdGenerator, + utils, IdGenerator, }; use export::Export; use indexmap::IndexMap; @@ -58,7 +59,6 @@ use std::{ rc::Rc, }; use telemetry::EventListener; -use test_framework::{Test, TestResult}; use uplc::{ ast::{Constant, Name, Program}, PlutusData, @@ -419,7 +419,7 @@ where if e.is_success() { None } else { - Some(e.into_error(verbose)) + Some(Error::from_test_result(e, verbose)) } }) .collect(); diff --git a/crates/aiken-project/src/telemetry.rs b/crates/aiken-project/src/telemetry.rs index 3c7fe883..cf946294 100644 --- a/crates/aiken-project/src/telemetry.rs +++ b/crates/aiken-project/src/telemetry.rs @@ -1,8 +1,10 @@ -use crate::{ - pretty, +use crate::pretty; +use aiken_lang::{ + ast::OnTestFailure, + expr::UntypedExpr, + format::Formatter, test_framework::{PropertyTestResult, TestResult, UnitTestResult}, }; -use aiken_lang::{ast::OnTestFailure, expr::UntypedExpr, format::Formatter}; use owo_colors::{OwoColorize, Stream::Stderr}; use std::{collections::BTreeMap, fmt::Display, path::PathBuf}; use uplc::machine::cost_model::ExBudget; diff --git a/crates/aiken-project/src/test_framework.rs b/crates/aiken-project/src/test_framework.rs index 884a3595..cffab008 100644 --- a/crates/aiken-project/src/test_framework.rs +++ b/crates/aiken-project/src/test_framework.rs @@ -1,1280 +1,5 @@ -use aiken_lang::ast::OnTestFailure; -pub(crate) use aiken_lang::{ - ast::{BinOp, DataTypeKey, IfBranch, Span, TypedArg, TypedDataType, TypedTest}, - expr::{TypedExpr, UntypedExpr}, - format::Formatter, - gen_uplc::CodeGenerator, - plutus_version::PlutusVersion, - tipo::{convert_opaque_type, Type}, -}; -use cryptoxide::{blake2b::Blake2b, digest::Digest}; -use indexmap::IndexMap; -use itertools::Itertools; -use owo_colors::{OwoColorize, Stream}; -use pallas_primitives::alonzo::{Constr, PlutusData}; -use patricia_tree::PatriciaMap; -use std::{ - borrow::Borrow, collections::BTreeMap, convert::TryFrom, ops::Deref, path::PathBuf, rc::Rc, -}; -use uplc::{ - ast::{Constant, Data, Name, NamedDeBruijn, Program, Term}, - machine::{cost_model::ExBudget, eval_result::EvalResult}, -}; -use vec1::{vec1, Vec1}; - -/// ----- Test ----------------------------------------------------------------- -/// -/// Aiken supports two kinds of tests: unit and property. A unit test is a simply -/// UPLC program which returns must be a lambda that returns a boolean. -/// -/// A property on the other-hand is a template for generating tests, which is also -/// a lambda but that takes an extra argument. The argument is generated from a -/// fuzzer which is meant to yield random values in a pseudo-random (albeit seeded) -/// sequence. On failures, the value that caused a failure is simplified using an -/// approach similar to what's described in MiniThesis, -/// which is a simplified version of Hypothesis, a property-based testing framework -/// with integrated shrinking. -/// -/// Our approach could perhaps be called "microthesis", as it implements a subset of -/// minithesis. More specifically, we do not currently support pre-conditions, nor -/// targets. -/// -#[derive(Debug, Clone)] -pub enum Test { - UnitTest(UnitTest), - PropertyTest(PropertyTest), -} - -unsafe impl Send for Test {} - -impl Test { - pub fn unit_test( - generator: &mut CodeGenerator<'_>, - test: TypedTest, - module_name: String, - input_path: PathBuf, - ) -> Test { - let program = generator.generate_raw(&test.body, &[], &module_name); - - let assertion = match test.body.try_into() { - Err(..) => None, - Ok(Assertion { bin_op, head, tail }) => { - let as_constant = |generator: &mut CodeGenerator<'_>, side| { - Program::::try_from(generator.generate_raw( - &side, - &[], - &module_name, - )) - .expect("failed to convert assertion operaand to NamedDeBruijn") - .eval(ExBudget::max()) - .unwrap_constant() - .map(|cst| (cst, side.tipo())) - }; - - // Assertion at this point is evaluated so it's not just a normal assertion - Some(Assertion { - bin_op, - head: as_constant(generator, head.expect("cannot be Err at this point")), - tail: tail - .expect("cannot be Err at this point") - .try_mapped(|e| as_constant(generator, e)), - }) - } - }; - - Test::UnitTest(UnitTest { - input_path, - module: module_name, - name: test.name, - program, - assertion, - on_test_failure: test.on_test_failure, - }) - } - - pub fn property_test( - input_path: PathBuf, - module: String, - name: String, - on_test_failure: OnTestFailure, - program: Program, - fuzzer: Fuzzer, - ) -> Test { - Test::PropertyTest(PropertyTest { - input_path, - module, - name, - program, - on_test_failure, - fuzzer, - }) - } - - pub fn from_function_definition( - generator: &mut CodeGenerator<'_>, - test: TypedTest, - module_name: String, - input_path: PathBuf, - ) -> Test { - if test.arguments.is_empty() { - Self::unit_test(generator, test, module_name, input_path) - } else { - let parameter = test.arguments.first().unwrap().to_owned(); - - let via = parameter.via.clone(); - - let type_info = parameter.arg.tipo.clone(); - - let stripped_type_info = convert_opaque_type(&type_info, generator.data_types(), true); - - let program = generator.clone().generate_raw( - &test.body, - &[TypedArg { - tipo: stripped_type_info.clone(), - ..parameter.clone().into() - }], - &module_name, - ); - - // NOTE: We need not to pass any parameter to the fuzzer here because the fuzzer - // argument is a Data constructor which needs not any conversion. So we can just safely - // apply onto it later. - let fuzzer = generator.clone().generate_raw(&via, &[], &module_name); - - Self::property_test( - input_path, - module_name, - test.name, - test.on_test_failure, - program, - Fuzzer { - program: fuzzer, - stripped_type_info, - type_info, - }, - ) - } - } -} - -/// ----- UnitTest ----------------------------------------------------------------- -/// -#[derive(Debug, Clone)] -pub struct UnitTest { - pub input_path: PathBuf, - pub module: String, - pub name: String, - pub on_test_failure: OnTestFailure, - pub program: Program, - pub assertion: Option)>>, -} - -unsafe impl Send for UnitTest {} - -impl UnitTest { - pub fn run(self, plutus_version: &PlutusVersion) -> TestResult<(Constant, Rc), T> { - let mut eval_result = Program::::try_from(self.program.clone()) - .unwrap() - .eval_version(ExBudget::max(), &plutus_version.into()); - - let success = !eval_result.failed(match self.on_test_failure { - OnTestFailure::SucceedEventually | OnTestFailure::SucceedImmediately => true, - OnTestFailure::FailImmediately => false, - }); - - TestResult::UnitTestResult(UnitTestResult { - success, - test: self.to_owned(), - spent_budget: eval_result.cost(), - traces: eval_result.logs(), - assertion: self.assertion, - }) - } -} - -/// ----- PropertyTest ----------------------------------------------------------------- -/// -#[derive(Debug, Clone)] -pub struct PropertyTest { - pub input_path: PathBuf, - pub module: String, - pub name: String, - pub on_test_failure: OnTestFailure, - pub program: Program, - pub fuzzer: Fuzzer, -} - -unsafe impl Send for PropertyTest {} - -#[derive(Debug, Clone)] -pub struct Fuzzer { - pub program: Program, - - pub type_info: Rc, - - /// A version of the Fuzzer's type that has gotten rid of - /// all erasable opaque type. This is needed in order to - /// generate Plutus data with the appropriate shape. - pub stripped_type_info: Rc, -} - -#[derive(Debug, Clone, thiserror::Error, miette::Diagnostic)] -#[error("Fuzzer exited unexpectedly: {uplc_error}")] -pub struct FuzzerError { - traces: Vec, - uplc_error: uplc::machine::Error, -} - -impl PropertyTest { - pub const DEFAULT_MAX_SUCCESS: usize = 100; - - /// Run a property test from a given seed. The property is run at most DEFAULT_MAX_SUCCESS times. It - /// may stops earlier on failure; in which case a 'counterexample' is returned. - pub fn run( - self, - seed: u32, - n: usize, - plutus_version: &PlutusVersion, - ) -> TestResult { - let mut labels = BTreeMap::new(); - let mut remaining = n; - - let (traces, counterexample, iterations) = match self.run_n_times( - &mut remaining, - Prng::from_seed(seed), - &mut labels, - plutus_version, - ) { - Ok(None) => (Vec::new(), Ok(None), n), - Ok(Some(counterexample)) => ( - self.eval(&counterexample.value, plutus_version) - .logs() - .into_iter() - .filter(|s| PropertyTest::extract_label(s).is_none()) - .collect(), - Ok(Some(counterexample.value)), - n - remaining, - ), - Err(FuzzerError { traces, uplc_error }) => ( - traces - .into_iter() - .filter(|s| PropertyTest::extract_label(s).is_none()) - .collect(), - Err(uplc_error), - n - remaining + 1, - ), - }; - - TestResult::PropertyTestResult(PropertyTestResult { - test: self, - counterexample, - iterations, - labels, - traces, - }) - } - - fn run_n_times<'a>( - &'a self, - remaining: &mut usize, - initial_prng: Prng, - labels: &mut BTreeMap, - plutus_version: &'a PlutusVersion, - ) -> Result>, FuzzerError> { - let mut prng = initial_prng; - let mut counterexample = None; - - while *remaining > 0 && counterexample.is_none() { - (prng, counterexample) = self.run_once(prng, labels, plutus_version)?; - *remaining -= 1; - } - - Ok(counterexample) - } - - fn run_once<'a>( - &'a self, - prng: Prng, - labels: &mut BTreeMap, - plutus_version: &'a PlutusVersion, - ) -> Result<(Prng, Option>), FuzzerError> { - use OnTestFailure::*; - - let (next_prng, value) = prng - .sample(&self.fuzzer.program)? - .expect("A seeded PRNG returned 'None' which indicates a fuzzer is ill-formed and implemented wrongly; please contact library's authors."); - - let mut result = self.eval(&value, plutus_version); - - for s in result.logs() { - // NOTE: There may be other log outputs that interefere with labels. So *by - // convention*, we treat as label strings that starts with a NUL byte, which - // should be a guard sufficient to prevent inadvertent clashes. - if let Some(label) = PropertyTest::extract_label(&s) { - labels - .entry(label) - .and_modify(|count| *count += 1) - .or_insert(1); - } - } - - let is_failure = result.failed(false); - - let is_success = !is_failure; - - let keep_counterexample = match self.on_test_failure { - FailImmediately | SucceedImmediately => is_failure, - SucceedEventually => is_success, - }; - - if keep_counterexample { - let mut counterexample = Counterexample { - value, - choices: next_prng.choices(), - cache: Cache::new(|choices| { - match Prng::from_choices(choices).sample(&self.fuzzer.program) { - Err(..) => Status::Invalid, - Ok(None) => Status::Invalid, - Ok(Some((_, value))) => { - let result = self.eval(&value, plutus_version); - - let is_failure = result.failed(false); - - match self.on_test_failure { - FailImmediately | SucceedImmediately => { - if is_failure { - Status::Keep(value) - } else { - Status::Ignore - } - } - - SucceedEventually => { - if is_failure { - Status::Ignore - } else { - Status::Keep(value) - } - } - } - } - } - }), - }; - - if !counterexample.choices.is_empty() { - counterexample.simplify(); - } - - Ok((next_prng, Some(counterexample))) - } else { - Ok((next_prng, None)) - } - } - - pub fn eval(&self, value: &PlutusData, plutus_version: &PlutusVersion) -> EvalResult { - let program = self.program.apply_data(value.clone()); - - Program::::try_from(program) - .unwrap() - .eval_version(ExBudget::max(), &plutus_version.into()) - } - - fn extract_label(s: &str) -> Option { - if s.starts_with('\0') { - Some(s.split_at(1).1.to_string()) - } else { - None - } - } -} - -/// ----- PRNG ----------------------------------------------------------------- -/// -/// A Pseudo-random generator (PRNG) used to produce random values for fuzzers. -/// Note that the randomness isn't actually managed by the Rust framework, it -/// entirely relies on properties of hashing algorithm on-chain (e.g. blake2b). -/// -/// The PRNG can have two forms: -/// -/// 1. Seeded: which occurs during the initial run of a property. Each time a -/// number is drawn from the PRNG, a new seed is created. We retain all the -/// choices drawn in a _choices_ vector. -/// -/// 2. Replayed: which is used to replay a Prng sequenced from a list of known -/// choices. This happens when shrinking an example. Instead of trying to -/// shrink the value directly, we shrink the PRNG sequence with the hope that -/// it will generate a smaller value. This implies that generators tend to -/// generate smaller values when drawing smaller numbers. -/// -#[derive(Debug)] -pub enum Prng { - Seeded { choices: Vec, uplc: PlutusData }, - Replayed { choices: Vec, uplc: PlutusData }, -} - -impl Prng { - /// Constructor tag for Prng's 'Seeded' - const SEEDED: u64 = 0; - /// Constructor tag for Prng's 'Replayed' - const REPLAYED: u64 = 1; - - /// Constructor tag for Option's 'Some' - const SOME: u64 = 0; - /// Constructor tag for Option's 'None' - const NONE: u64 = 1; - - pub fn uplc(&self) -> PlutusData { - match self { - Prng::Seeded { uplc, .. } => uplc.clone(), - Prng::Replayed { uplc, .. } => uplc.clone(), - } - } - - pub fn choices(&self) -> Vec { - match self { - Prng::Seeded { choices, .. } => { - let mut choices = choices.to_vec(); - choices.reverse(); - choices - } - Prng::Replayed { choices, .. } => choices.to_vec(), - } - } - - /// Construct a Pseudo-random number generator from a seed. - pub fn from_seed(seed: u32) -> Prng { - let mut digest = [0u8; 32]; - let mut context = Blake2b::new(32); - context.input(&seed.to_be_bytes()[..]); - context.result(&mut digest); - - Prng::Seeded { - choices: vec![], - uplc: Data::constr( - Prng::SEEDED, - vec![ - Data::bytestring(digest.to_vec()), // Prng's seed - Data::bytestring(vec![]), // Random choices - ], - ), - } - } - - /// Construct a Pseudo-random number generator from a pre-defined list of choices. - pub fn from_choices(choices: &[u8]) -> Prng { - Prng::Replayed { - uplc: Data::constr( - Prng::REPLAYED, - vec![ - Data::integer(choices.len().into()), - Data::bytestring(choices.iter().rev().cloned().collect::>()), - ], - ), - choices: choices.to_vec(), - } - } - - /// Generate a pseudo-random value from a fuzzer using the given PRNG. - pub fn sample( - &self, - fuzzer: &Program, - ) -> Result, FuzzerError> { - let program = Program::::try_from(fuzzer.apply_data(self.uplc())).unwrap(); - let mut result = program.eval(ExBudget::max()); - result - .result() - .map_err(|uplc_error| FuzzerError { - traces: result.logs(), - uplc_error, - }) - .map(Prng::from_result) - } - - /// Obtain a Prng back from a fuzzer execution. As a reminder, fuzzers have the following - /// signature: - /// - /// `type Fuzzer = fn(Prng) -> Option<(Prng, a)>` - /// - /// In nominal scenarios (i.e. when the fuzzer is made from a seed and evolve pseudo-randomly), - /// it cannot yield 'None'. When replayed however, we can't easily guarantee that the changes - /// made during shrinking aren't breaking underlying invariants (if only, because we run out of - /// values to replay). In such case, the replayed sequence is simply invalid and the fuzzer - /// aborted altogether with 'None'. - pub fn from_result(result: Term) -> Option<(Self, PlutusData)> { - /// Interpret the given 'PlutusData' as one of two Prng constructors. - fn as_prng(cst: &PlutusData) -> Prng { - if let PlutusData::Constr(Constr { tag, fields, .. }) = cst { - if *tag == 121 + Prng::SEEDED { - if let [PlutusData::BoundedBytes(bytes), PlutusData::BoundedBytes(choices)] = - &fields[..] - { - return Prng::Seeded { - choices: choices.to_vec(), - uplc: PlutusData::Constr(Constr { - tag: 121 + Prng::SEEDED, - fields: vec![ - PlutusData::BoundedBytes(bytes.to_owned()), - // Clear choices between seeded runs, to not - // accumulate ALL choices ever made. - PlutusData::BoundedBytes(vec![].into()), - ], - any_constructor: None, - }), - }; - } - } - - if *tag == 121 + Prng::REPLAYED { - if let [PlutusData::BigInt(..), PlutusData::BoundedBytes(choices)] = &fields[..] - { - return Prng::Replayed { - choices: choices.to_vec(), - uplc: cst.clone(), - }; - } - } - } - - unreachable!("malformed Prng: {cst:#?}") - } - - if let Term::Constant(rc) = &result { - if let Constant::Data(PlutusData::Constr(Constr { tag, fields, .. })) = &rc.borrow() { - if *tag == 121 + Prng::SOME { - if let [PlutusData::Array(elems)] = &fields[..] { - if let [new_seed, value] = &elems[..] { - return Some((as_prng(new_seed), value.clone())); - } - } - } - - // May occurs when replaying a fuzzer from a shrinked sequence of - // choices. If we run out of choices, or a choice end up being - // invalid as per the expectation, the fuzzer can't go further and - // fail. - if *tag == 121 + Prng::NONE { - return None; - } - } - } - - unreachable!("Fuzzer yielded a malformed result? {result:#?}") - } -} - -/// ----- Counterexample ----------------------------------------------------------------- -/// -/// A counterexample is constructed from a test failure. It holds a value, and a sequence -/// of random choices that led to this value. It holds a reference to the underlying -/// property and fuzzer. In many cases, a counterexample can be simplified (a.k.a "shrinked") -/// into a smaller counterexample. -pub struct Counterexample<'a> { - pub value: PlutusData, - pub choices: Vec, - pub cache: Cache<'a, PlutusData>, -} - -impl<'a> Counterexample<'a> { - fn consider(&mut self, choices: &[u8]) -> bool { - if choices == self.choices { - return true; - } - - match self.cache.get(choices) { - Status::Invalid | Status::Ignore => false, - Status::Keep(value) => { - // If these new choices are shorter or smaller, then we pick them - // as new choices and inform that it's been an improvement. - if choices.len() <= self.choices.len() || choices < &self.choices[..] { - self.value = value; - self.choices = choices.to_vec(); - true - } else { - false - } - } - } - } - - /// Try to simplify a 'Counterexample' by manipulating the random sequence of generated values - /// (a.k.a. choices). While the implementation is quite involved, the strategy is rather simple - /// at least conceptually: - /// - /// Each time a (seeded) fuzzer generates a new value and a new seed, it also stores the - /// generated value in a vector, which we call 'choices'. If we re-run the test case with this - /// exact choice sequence, we end up with the exact same outcome. - /// - /// But, we can tweak chunks of this sequence in hope to generate a _smaller sequence_, thus - /// generally resulting in a _smaller counterexample_. Each transformations is applied on - /// chunks of size 8, 4, 2 and 1; until we no longer make progress (i.e. hit a fix point). - /// - /// As per MiniThesis, we consider the following transformations: - /// - /// - Deleting chunks - /// - Transforming chunks into sequence of zeroes - /// - Replacing chunks of values with smaller values - /// - Sorting chunks in ascending order - /// - Swapping nearby pairs - /// - Redistributing values between nearby pairs - fn simplify(&mut self) { - let mut prev; - - loop { - prev = self.choices.clone(); - - // First try deleting each choice we made in chunks. We try longer chunks because this - // allows us to delete whole composite elements: e.g. deleting an element from a - // generated list requires us to delete both the choice of whether to include it and - // also the element itself, which may involve more than one choice. - let mut k = 8; - while k > 0 { - let (mut i, mut underflow) = if self.choices.len() < k { - (0, true) - } else { - (self.choices.len() - k, false) - }; - - while !underflow { - if i >= self.choices.len() { - (i, underflow) = i.overflowing_sub(1); - continue; - } - - let j = i + k; - - let mut choices = [ - &self.choices[..i], - if j < self.choices.len() { - &self.choices[j..] - } else { - &[] - }, - ] - .concat(); - - if !self.consider(&choices) { - // Perform an extra reduction step that decrease the size of choices near - // the end, to cope with dependencies between choices, e.g. drawing a - // number as a list length, and then drawing that many elements. - // - // This isn't perfect, but allows to make progresses in many cases. - if i > 0 && choices[i - 1] > 0 { - choices[i - 1] -= 1; - if self.consider(&choices) { - i += 1; - }; - } - - (i, underflow) = i.overflowing_sub(1); - } - } - - k /= 2 - } - - if !self.choices.is_empty() { - // Now we try replacing region of choices with zeroes. Note that unlike the above we - // skip k = 1 because we handle that in the next step. Often (but not always) a block - // of all zeroes is the smallest value that a region can be. - let mut k = 8; - while k > 1 { - let mut i = self.choices.len(); - while i >= k { - let ivs = (i - k..i).map(|j| (j, 0)).collect::>(); - i -= if self.replace(ivs) { k } else { 1 } - } - k /= 2 - } - - // Replace choices with smaller value, by doing a binary search. This will replace n - // with 0 or n - 1, if possible, but will also more efficiently replace it with, a - // smaller number than doing multiple subtractions would. - let (mut i, mut underflow) = (self.choices.len() - 1, false); - while !underflow { - self.binary_search_replace(0, self.choices[i], |v| vec![(i, v)]); - (i, underflow) = i.overflowing_sub(1); - } - - // Sort out of orders chunks in ascending order - let mut k = 8; - while k > 1 { - let mut i = self.choices.len() - 1; - while i >= k { - let (from, to) = (i - k, i); - self.replace( - (from..to) - .zip(self.choices[from..to].iter().cloned().sorted()) - .collect(), - ); - i -= 1; - } - k /= 2 - } - - // Try adjusting nearby pairs by: - // - // - Swapping them if they are out-of-order - // - Redistributing values between them. - for k in [2, 1] { - let mut j = self.choices.len() - 1; - while j >= k { - let i = j - k; - - // Swap - if self.choices[i] > self.choices[j] { - self.replace(vec![(i, self.choices[j]), (j, self.choices[i])]); - } - - let iv = self.choices[i]; - let jv = self.choices[j]; - - // Replace - if iv > 0 && jv <= u8::MAX - iv { - self.binary_search_replace(0, iv, |v| vec![(i, v), (j, jv + (iv - v))]); - } - - j -= 1 - } - } - } - - // If we've reached a fixed point, then we cannot shrink further. We've reached a - // (local) minimum, which is as good as a counterexample we'll get with this approach. - if prev.as_slice() == self.choices.as_slice() { - break; - } - } - } - - /// Try to replace a value with a smaller value by doing a binary search between - /// two extremes. This converges relatively fast in order to shrink down values. - fn binary_search_replace(&mut self, lo: u8, hi: u8, f: F) -> u8 - where - F: Fn(u8) -> Vec<(usize, u8)>, - { - if self.replace(f(lo)) { - return lo; - } - - let mut lo = lo; - let mut hi = hi; - - while lo + 1 < hi { - let mid = lo + (hi - lo) / 2; - if self.replace(f(mid)) { - hi = mid; - } else { - lo = mid; - } - } - - hi - } - - // Replace values in the choices vector, based on the index-value list provided - // and consider the resulting choices. - fn replace(&mut self, ivs: Vec<(usize, u8)>) -> bool { - let mut choices = self.choices.clone(); - - for (i, v) in ivs { - if i >= choices.len() { - return false; - } - choices[i] = v; - } - - self.consider(&choices) - } -} - -/// ----- Cache ----------------------------------------------------------------------- -/// -/// A simple cache as a Patricia-trie to look for already explored options. The simplification -/// steps does often generate the same paths and the generation of new test values as well as the -/// properties can take a significant time. -/// -/// Yet, sequences have interesting properties: -/// -/// 1. The generation and test execution is entirely deterministic. -/// -/// -pub struct Cache<'a, T> { - db: PatriciaMap>, - #[allow(clippy::type_complexity)] - run: Box Status + 'a>, -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum Status { - Keep(T), - Ignore, - Invalid, -} - -impl<'a, T> Cache<'a, T> -where - T: PartialEq + Clone, -{ - pub fn new(run: F) -> Cache<'a, T> - where - F: Fn(&[u8]) -> Status + 'a, - { - Cache { - db: PatriciaMap::new(), - run: Box::new(run), - } - } - - pub fn size(&self) -> usize { - self.db.len() - } - - pub fn get(&mut self, choices: &[u8]) -> Status { - if let Some((prefix, status)) = self.db.get_longest_common_prefix(choices) { - let status = status.clone(); - if status != Status::Invalid || prefix == choices { - return status; - } - } - - let status = self.run.deref()(choices); - - // Clear longer path on non-invalid cases, as we will never reach them - // again due to a now-shorter prefix found. - // - // This hopefully keeps the cache under a reasonable size as we prune - // the tree as we discover shorter paths. - if status != Status::Invalid { - let keys = self - .db - .iter_prefix(choices) - .map(|(k, _)| k) - .collect::>(); - for k in keys { - self.db.remove(k); - } - } - - self.db.insert(choices, status.clone()); - - status - } -} - -// ---------------------------------------------------------------------------- -// -// TestResult -// -// ---------------------------------------------------------------------------- - -#[derive(Debug)] -pub enum TestResult { - UnitTestResult(UnitTestResult), - PropertyTestResult(PropertyTestResult), -} - -unsafe impl Send for TestResult {} - -impl TestResult<(Constant, Rc), PlutusData> { - pub fn reify( - self, - data_types: &IndexMap<&DataTypeKey, &TypedDataType>, - ) -> TestResult { - match self { - TestResult::UnitTestResult(test) => TestResult::UnitTestResult(test.reify(data_types)), - TestResult::PropertyTestResult(test) => { - TestResult::PropertyTestResult(test.reify(data_types)) - } - } - } -} - -impl TestResult { - pub fn is_success(&self) -> bool { - match self { - TestResult::UnitTestResult(UnitTestResult { success, .. }) => *success, - TestResult::PropertyTestResult(PropertyTestResult { - counterexample: Err(..), - .. - }) => false, - TestResult::PropertyTestResult(PropertyTestResult { - counterexample: Ok(counterexample), - test, - .. - }) => match test.on_test_failure { - OnTestFailure::FailImmediately | OnTestFailure::SucceedEventually => { - counterexample.is_none() - } - OnTestFailure::SucceedImmediately => counterexample.is_some(), - }, - } - } - - pub fn module(&self) -> &str { - match self { - TestResult::UnitTestResult(UnitTestResult { ref test, .. }) => test.module.as_str(), - TestResult::PropertyTestResult(PropertyTestResult { ref test, .. }) => { - test.module.as_str() - } - } - } - - pub fn title(&self) -> &str { - match self { - TestResult::UnitTestResult(UnitTestResult { ref test, .. }) => test.name.as_str(), - TestResult::PropertyTestResult(PropertyTestResult { ref test, .. }) => { - test.name.as_str() - } - } - } - - pub fn traces(&self) -> &[String] { - match self { - TestResult::UnitTestResult(UnitTestResult { ref traces, .. }) - | TestResult::PropertyTestResult(PropertyTestResult { ref traces, .. }) => { - traces.as_slice() - } - } - } - - pub fn into_error(&self, verbose: bool) -> crate::Error { - let (name, path, src) = match self { - TestResult::UnitTestResult(UnitTestResult { test, .. }) => ( - test.name.to_string(), - test.input_path.to_path_buf(), - test.program.to_pretty(), - ), - TestResult::PropertyTestResult(PropertyTestResult { test, .. }) => ( - test.name.to_string(), - test.input_path.to_path_buf(), - test.program.to_pretty(), - ), - }; - crate::Error::TestFailure { - name, - path, - src, - verbose, - } - } -} - -#[derive(Debug)] -pub struct UnitTestResult { - pub success: bool, - pub spent_budget: ExBudget, - pub traces: Vec, - pub test: UnitTest, - pub assertion: Option>, -} - -unsafe impl Send for UnitTestResult {} - -impl UnitTestResult<(Constant, Rc)> { - pub fn reify( - self, - data_types: &IndexMap<&DataTypeKey, &TypedDataType>, - ) -> UnitTestResult { - UnitTestResult { - success: self.success, - spent_budget: self.spent_budget, - traces: self.traces, - test: self.test, - assertion: self.assertion.and_then(|assertion| { - // No need to spend time/cpu on reifying assertions for successful - // tests since they aren't shown. - if self.success { - return None; - } - - Some(Assertion { - bin_op: assertion.bin_op, - head: assertion.head.map(|(cst, tipo)| { - UntypedExpr::reify_constant(data_types, cst, &tipo) - .expect("failed to reify assertion operand?") - }), - tail: assertion.tail.map(|xs| { - xs.mapped(|(cst, tipo)| { - UntypedExpr::reify_constant(data_types, cst, &tipo) - .expect("failed to reify assertion operand?") - }) - }), - }) - }), - } - } -} - -#[derive(Debug)] -pub struct PropertyTestResult { - pub test: PropertyTest, - pub counterexample: Result, uplc::machine::Error>, - pub iterations: usize, - pub labels: BTreeMap, - pub traces: Vec, -} - -unsafe impl Send for PropertyTestResult {} - -impl PropertyTestResult { - pub fn reify( - self, - data_types: &IndexMap<&DataTypeKey, &TypedDataType>, - ) -> PropertyTestResult { - PropertyTestResult { - counterexample: self.counterexample.map(|ok| { - ok.map(|counterexample| { - UntypedExpr::reify_data(data_types, counterexample, &self.test.fuzzer.type_info) - .expect("Failed to reify counterexample?") - }) - }), - iterations: self.iterations, - test: self.test, - labels: self.labels, - traces: self.traces, - } - } -} - -#[derive(Debug, Clone)] -pub struct Assertion { - pub bin_op: BinOp, - pub head: Result, - pub tail: Result, ()>, -} - -impl TryFrom for Assertion { - type Error = (); - - fn try_from(body: TypedExpr) -> Result { - match body { - TypedExpr::BinOp { - name, - tipo, - left, - right, - .. - } if tipo == Type::bool() => { - // 'and' and 'or' are left-associative operators. - match (*right).clone().try_into() { - Ok(Assertion { - bin_op, - head: Ok(head), - tail: Ok(tail), - .. - }) if bin_op == name => { - let mut both = vec1![head]; - both.extend(tail); - Ok(Assertion { - bin_op: name, - head: Ok(*left), - tail: Ok(both), - }) - } - _ => Ok(Assertion { - bin_op: name, - head: Ok(*left), - tail: Ok(vec1![*right]), - }), - } - } - - // NOTE drill through trace-if-false operators for better errors. - TypedExpr::If { - branches, - final_else, - .. - } => { - if let [IfBranch { - condition, body, .. - }] = &branches[..] - { - let then_is_true = match body { - TypedExpr::Var { - name, constructor, .. - } => name == "True" && constructor.tipo == Type::bool(), - _ => false, - }; - - let else_is_wrapped_false = match *final_else { - TypedExpr::Trace { then, .. } => match *then { - TypedExpr::Var { - name, constructor, .. - } => name == "False" && constructor.tipo == Type::bool(), - _ => false, - }, - _ => false, - }; - - if then_is_true && else_is_wrapped_false { - return condition.to_owned().try_into(); - } - } - - Err(()) - } - - TypedExpr::Trace { then, .. } => (*then).try_into(), - - TypedExpr::Sequence { expressions, .. } | TypedExpr::Pipeline { expressions, .. } => { - if let Ok(Assertion { - bin_op, - head: Ok(head), - tail: Ok(tail), - }) = expressions.last().unwrap().to_owned().try_into() - { - let replace = |expr| { - let mut expressions = expressions.clone(); - expressions.pop(); - expressions.push(expr); - TypedExpr::Sequence { - expressions, - location: Span::empty(), - } - }; - - Ok(Assertion { - bin_op, - head: Ok(replace(head)), - tail: Ok(tail.mapped(replace)), - }) - } else { - Err(()) - } - } - _ => Err(()), - } - } -} - -impl Assertion { - #[allow(clippy::just_underscores_and_digits)] - pub fn to_string(&self, stream: Stream, expect_failure: bool) -> String { - let red = |s: &str| { - format!("× {s}") - .if_supports_color(stream, |s| s.red()) - .if_supports_color(stream, |s| s.bold()) - .to_string() - }; - - // head did not map to a constant - if self.head.is_err() { - return red("program failed"); - } - - // any value in tail did not map to a constant - if self.tail.is_err() { - return red("program failed"); - } - - fn fmt_side(side: &UntypedExpr, stream: Stream) -> String { - let __ = "│".if_supports_color(stream, |s| s.red()); - - Formatter::new() - .expr(side, false) - .to_pretty_string(60) - .lines() - .map(|line| format!("{__} {line}")) - .collect::>() - .join("\n") - } - - let left = fmt_side(self.head.as_ref().unwrap(), stream); - - let tail = self.tail.as_ref().unwrap(); - - let right = fmt_side(tail.first(), stream); - - format!( - "{}{}{}", - red("expected"), - if expect_failure && self.bin_op == BinOp::Or { - " neither\n" - .if_supports_color(stream, |s| s.red()) - .if_supports_color(stream, |s| s.bold()) - .to_string() - } else { - "\n".to_string() - }, - if expect_failure { - match self.bin_op { - BinOp::And => [ - left, - red("and"), - [ - tail.mapped_ref(|s| fmt_side(s, stream)) - .join(format!("\n{}\n", red("and")).as_str()), - if tail.len() > 1 { - red("to not all be true") - } else { - red("to not both be true") - }, - ] - .join("\n"), - ], - BinOp::Or => [ - left, - red("nor"), - [ - tail.mapped_ref(|s| fmt_side(s, stream)) - .join(format!("\n{}\n", red("nor")).as_str()), - red("to be true"), - ] - .join("\n"), - ], - BinOp::Eq => [left, red("to not equal"), right], - BinOp::NotEq => [left, red("to not be different"), right], - BinOp::LtInt => [left, red("to not be lower than"), right], - BinOp::LtEqInt => [left, red("to not be lower than or equal to"), right], - BinOp::GtInt => [left, red("to not be greater than"), right], - BinOp::GtEqInt => [left, red("to not be greater than or equal to"), right], - _ => unreachable!("unexpected non-boolean binary operator in assertion?"), - } - .join("\n") - } else { - match self.bin_op { - BinOp::And => [ - left, - red("and"), - [ - tail.mapped_ref(|s| fmt_side(s, stream)) - .join(format!("\n{}\n", red("and")).as_str()), - if tail.len() > 1 { - red("to all be true") - } else { - red("to both be true") - }, - ] - .join("\n"), - ], - BinOp::Or => [ - left, - red("or"), - [ - tail.mapped_ref(|s| fmt_side(s, stream)) - .join(format!("\n{}\n", red("or")).as_str()), - red("to be true"), - ] - .join("\n"), - ], - BinOp::Eq => [left, red("to equal"), right], - BinOp::NotEq => [left, red("to not equal"), right], - BinOp::LtInt => [left, red("to be lower than"), right], - BinOp::LtEqInt => [left, red("to be lower than or equal to"), right], - BinOp::GtInt => [left, red("to be greater than"), right], - BinOp::GtEqInt => [left, red("to be greater than or equal to"), right], - _ => unreachable!("unexpected non-boolean binary operator in assertion?"), - } - .join("\n") - } - ) - } -} - #[cfg(test)] mod test { - use super::*; use crate::{ module::{CheckedModule, CheckedModules}, utils, @@ -1286,6 +11,7 @@ mod test { line_numbers::LineNumbers, parser::{self, extra::ModuleExtra}, plutus_version::PlutusVersion, + test_framework::*, IdGenerator, }; use indoc::indoc; @@ -1812,36 +538,4 @@ mod test { "Dict([(#\"2cd15ed0\", Dict([]))])" ); } - - #[test] - fn test_cache() { - let called = std::cell::RefCell::new(0); - - let mut cache = Cache::new(|choices| { - called.replace_with(|n| *n + 1); - - match choices { - [0, 0, 0] => Status::Keep(true), - _ => { - if choices.len() <= 2 { - Status::Invalid - } else { - Status::Ignore - } - } - } - }); - - assert_eq!(cache.get(&[1, 1]), Status::Invalid); // Fn executed - assert_eq!(cache.get(&[1, 1, 2, 3]), Status::Ignore); // Fn executed - assert_eq!(cache.get(&[1, 1, 2]), Status::Ignore); // Fnexecuted - assert_eq!(cache.get(&[1, 1, 2, 2]), Status::Ignore); // Cached result - assert_eq!(cache.get(&[1, 1, 2, 1]), Status::Ignore); // Cached result - assert_eq!(cache.get(&[0, 1, 2]), Status::Ignore); // Fn executed - assert_eq!(cache.get(&[0, 0, 0]), Status::Keep(true)); // Fn executed - assert_eq!(cache.get(&[0, 0, 0]), Status::Keep(true)); // Cached result - - assert_eq!(called.borrow().deref().to_owned(), 5, "execution calls"); - assert_eq!(cache.size(), 4, "cache size"); - } } diff --git a/crates/aiken/src/cmd/check.rs b/crates/aiken/src/cmd/check.rs index d83c437f..86480183 100644 --- a/crates/aiken/src/cmd/check.rs +++ b/crates/aiken/src/cmd/check.rs @@ -1,9 +1,9 @@ use super::build::{filter_traces_parser, trace_level_parser}; -use aiken_lang::ast::{TraceLevel, Tracing}; -use aiken_project::{ +use aiken_lang::{ + ast::{TraceLevel, Tracing}, test_framework::PropertyTest, - watch::{self, watch_project, with_project}, }; +use aiken_project::watch::{self, watch_project, with_project}; use rand::prelude::*; use std::{path::PathBuf, process};