use std::collections::HashMap;
use blake2::{
Blake2bVar,
digest::{Update, VariableOutput},
};
use serde::Serialize;
use sha2::{Digest, Sha256};
use crate::{
Result,
ast::{
BinaryOp, Block, Expr, ExprKind, Function, Item, MatchArm, Module, Pattern, Statement,
Test, UnaryOp,
},
diagnostic::{AeriError, Span},
parser::parse_module,
types::is_transaction_builtin,
};
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct TestReport {
pub results: Vec<TestResult>,
}
impl TestReport {
pub fn passed(&self) -> usize {
self.results.iter().filter(|result| result.passed).count()
}
pub fn failed(&self) -> usize {
self.results.len() - self.passed()
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct TestResult {
pub name: String,
pub expected_failure: bool,
pub passed: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub failure: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Value {
Bool(bool),
Int(i64),
Data(String),
String(String),
ByteArray(String),
List(Vec<Value>),
Constructor { name: String, fields: Vec<Value> },
Tx(TxFixture),
Unit,
Fail(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct TxFixture {
signers: Vec<String>,
payments: Vec<(String, i64)>,
mints: Vec<(String, i64)>,
valid_from: i64,
valid_until: i64,
spends: Vec<String>,
datums: Vec<String>,
}
pub fn run_tests_source(file: &str, source: &str) -> Result<TestReport> {
crate::compile_source(file, source)?;
let module = parse_module(file, source).map_err(|error| error.with_source(source))?;
Evaluator::new(file, source, &module).run_tests()
}
struct Evaluator<'a> {
file: &'a str,
source: &'a str,
constants: HashMap<&'a str, &'a Expr>,
functions: HashMap<&'a str, &'a Function>,
constructors: HashMap<&'a str, usize>,
tests: Vec<&'a Test>,
}
impl<'a> Evaluator<'a> {
fn new(file: &'a str, source: &'a str, module: &'a Module) -> Self {
let mut constants = HashMap::new();
let mut functions = HashMap::new();
let mut constructors = HashMap::new();
let mut tests = Vec::new();
for item in &module.items {
match item {
Item::Const(constant) => {
constants.insert(constant.name.as_str(), &constant.value);
}
Item::Function(function) => {
functions.insert(function.name.as_str(), function);
}
Item::Type(type_decl) => {
for variant in &type_decl.variants {
constructors.insert(variant.name.as_str(), variant.fields.len());
}
}
Item::Test(test) => tests.push(test),
Item::Validator(_) => (),
}
}
Self {
file,
source,
constants,
functions,
constructors,
tests,
}
}
fn run_tests(&self) -> Result<TestReport> {
let mut results = Vec::new();
for test in &self.tests {
let mut env = HashMap::new();
let value = self.eval_block(&test.body, &mut env)?;
let observed_failure = match value {
Value::Bool(true) => None,
Value::Bool(false) => Some("test returned false".to_string()),
Value::Fail(reason) => Some(reason),
_ => return Err(self.error(test.span, "test did not evaluate to Bool")),
};
let (passed, failure) = if test.should_fail {
match observed_failure {
Some(_) => (true, None),
None => (
false,
Some("expected test to fail but it passed".to_string()),
),
}
} else {
match observed_failure {
Some(reason) => (false, Some(reason)),
None => (true, None),
}
};
results.push(TestResult {
name: test.name.clone(),
expected_failure: test.should_fail,
passed,
failure,
});
}
Ok(TestReport { results })
}
fn eval_block(&self, block: &Block, env: &mut HashMap<String, Value>) -> Result<Value> {
let mut result = Value::Unit;
for statement in &block.statements {
match statement {
Statement::Let { name, value, .. } => {
let value = self.eval_expr(value, env)?;
if is_fail(&value) {
return Ok(value);
}
env.insert(name.clone(), value);
result = Value::Unit;
}
Statement::Require { condition, span } => {
let condition = self.eval_expr(condition, env)?;
if is_fail(&condition) {
return Ok(condition);
}
let Value::Bool(passed) = condition else {
return Err(self.error(*span, "require condition did not evaluate to Bool"));
};
if !passed {
return Ok(Value::Fail("require failed".to_string()));
}
result = Value::Unit;
}
Statement::Trace { message, .. } => {
let message = self.eval_expr(message, env)?;
if is_fail(&message) {
return Ok(message);
}
result = Value::Unit;
}
Statement::Return { value, .. } => return self.eval_expr(value, env),
Statement::Expr { value, .. } => {
result = self.eval_expr(value, env)?;
if is_fail(&result) {
return Ok(result);
}
}
}
}
Ok(result)
}
fn eval_expr(&self, expr: &Expr, env: &mut HashMap<String, Value>) -> Result<Value> {
match &expr.kind {
ExprKind::Bool(value) => Ok(Value::Bool(*value)),
ExprKind::Int(value) => Ok(Value::Int(*value)),
ExprKind::String(value) => Ok(Value::String(value.clone())),
ExprKind::ByteArray(hex) => Ok(Value::ByteArray(hex.clone())),
ExprKind::Unit => Ok(Value::Unit),
ExprKind::Fail => Ok(Value::Fail("fail expression reached".to_string())),
ExprKind::List(items) => {
let mut values = Vec::with_capacity(items.len());
for item in items {
let value = self.eval_expr(item, env)?;
if is_fail(&value) {
return Ok(value);
}
values.push(value);
}
Ok(Value::List(values))
}
ExprKind::Variable(name) => {
if let Some(value) = env.get(name) {
Ok(value.clone())
} else if let Some(expr) = self.constants.get(name.as_str()) {
let mut const_env = HashMap::new();
self.eval_expr(expr, &mut const_env)
} else if self.constructors.get(name.as_str()) == Some(&0) {
Ok(Value::Constructor {
name: name.clone(),
fields: Vec::new(),
})
} else {
Err(self.error(expr.span, format!("cannot evaluate '{name}'")))
}
}
ExprKind::Unary { op, expr } => {
let value = self.eval_expr(expr, env)?;
if is_fail(&value) {
return Ok(value);
}
match (op, value) {
(UnaryOp::Not, Value::Bool(value)) => Ok(Value::Bool(!value)),
(UnaryOp::Negate, Value::Int(value)) => value
.checked_neg()
.map(Value::Int)
.ok_or_else(|| self.error(expr.span, "integer overflow in pure test")),
_ => Err(self.error(expr.span, "invalid unary test expression")),
}
}
ExprKind::Binary { left, op, right } => {
self.eval_binary(expr.span, left, *op, right, env)
}
ExprKind::Call { callee, args } => self.eval_call(expr.span, callee, args, env),
ExprKind::If {
condition,
then_branch,
else_branch,
} => {
let condition_value = self.eval_expr(condition, env)?;
if is_fail(&condition_value) {
return Ok(condition_value);
}
let Value::Bool(condition) = condition_value else {
return Err(self.error(condition.span, "if condition did not evaluate to Bool"));
};
if condition {
self.eval_block(then_branch, &mut env.clone())
} else {
self.eval_block(else_branch, &mut env.clone())
}
}
ExprKind::Match { subject, arms } => {
let subject = self.eval_expr(subject, env)?;
if is_fail(&subject) {
return Ok(subject);
}
for arm in arms {
let mut arm_env = env.clone();
if self.match_arm(&subject, arm, &mut arm_env)? {
return self.eval_block(&arm.body, &mut arm_env);
}
}
Err(self.error(expr.span, "no match arm selected"))
}
}
}
fn eval_binary(
&self,
span: Span,
left: &Expr,
op: BinaryOp,
right: &Expr,
env: &mut HashMap<String, Value>,
) -> Result<Value> {
match op {
BinaryOp::And => {
let left_value = self.eval_expr(left, env)?;
if is_fail(&left_value) {
return Ok(left_value);
}
let Value::Bool(left) = left_value else {
return Err(self.error(left.span, "left side of && did not evaluate to Bool"));
};
if !left {
return Ok(Value::Bool(false));
}
let right_value = self.eval_expr(right, env)?;
if is_fail(&right_value) {
return Ok(right_value);
}
let Value::Bool(right) = right_value else {
return Err(self.error(right.span, "right side of && did not evaluate to Bool"));
};
Ok(Value::Bool(right))
}
BinaryOp::Or => {
let left_value = self.eval_expr(left, env)?;
if is_fail(&left_value) {
return Ok(left_value);
}
let Value::Bool(left) = left_value else {
return Err(self.error(left.span, "left side of || did not evaluate to Bool"));
};
if left {
return Ok(Value::Bool(true));
}
let right_value = self.eval_expr(right, env)?;
if is_fail(&right_value) {
return Ok(right_value);
}
let Value::Bool(right) = right_value else {
return Err(self.error(right.span, "right side of || did not evaluate to Bool"));
};
Ok(Value::Bool(right))
}
BinaryOp::Equal => {
let left = self.eval_expr(left, env)?;
let right = self.eval_expr(right, env)?;
if is_fail(&left) {
Ok(left)
} else if is_fail(&right) {
Ok(right)
} else {
Ok(Value::Bool(left == right))
}
}
BinaryOp::NotEqual => {
let left = self.eval_expr(left, env)?;
let right = self.eval_expr(right, env)?;
if is_fail(&left) {
Ok(left)
} else if is_fail(&right) {
Ok(right)
} else {
Ok(Value::Bool(left != right))
}
}
BinaryOp::Less
| BinaryOp::LessEqual
| BinaryOp::Greater
| BinaryOp::GreaterEqual
| BinaryOp::Add
| BinaryOp::Subtract
| BinaryOp::Multiply
| BinaryOp::Divide
| BinaryOp::Remainder => {
let left = self.eval_expr(left, env)?;
if is_fail(&left) {
return Ok(left);
}
let Value::Int(left) = left else {
return Err(self.error(span, "left operand did not evaluate to Int"));
};
let right = self.eval_expr(right, env)?;
if is_fail(&right) {
return Ok(right);
}
let Value::Int(right) = right else {
return Err(self.error(span, "right operand did not evaluate to Int"));
};
match op {
BinaryOp::Less => Ok(Value::Bool(left < right)),
BinaryOp::LessEqual => Ok(Value::Bool(left <= right)),
BinaryOp::Greater => Ok(Value::Bool(left > right)),
BinaryOp::GreaterEqual => Ok(Value::Bool(left >= right)),
BinaryOp::Add => checked_int(left.checked_add(right), || {
self.error(span, "integer overflow in pure test")
}),
BinaryOp::Subtract => checked_int(left.checked_sub(right), || {
self.error(span, "integer overflow in pure test")
}),
BinaryOp::Multiply => checked_int(left.checked_mul(right), || {
self.error(span, "integer overflow in pure test")
}),
BinaryOp::Divide if right == 0 => {
Err(self.error(span, "division by zero in pure test"))
}
BinaryOp::Divide => checked_int(left.checked_div(right), || {
self.error(span, "integer overflow in pure test")
}),
BinaryOp::Remainder if right == 0 => {
Err(self.error(span, "remainder by zero in pure test"))
}
BinaryOp::Remainder => checked_int(left.checked_rem(right), || {
self.error(span, "integer overflow in pure test")
}),
BinaryOp::And | BinaryOp::Or | BinaryOp::Equal | BinaryOp::NotEqual => {
unreachable!()
}
}
}
}
}
fn eval_call(
&self,
span: Span,
callee: &str,
args: &[Expr],
env: &mut HashMap<String, Value>,
) -> Result<Value> {
let values = args
.iter()
.map(|arg| self.eval_expr(arg, env))
.collect::<Result<Vec<_>>>()?;
if let Some(value) = values.iter().find(|value| is_fail(value)) {
return Ok(value.clone());
}
if let Some(fields) = self.constructors.get(callee) {
if *fields == values.len() {
return Ok(Value::Constructor {
name: callee.to_string(),
fields: values,
});
}
}
if let Some(function) = self.functions.get(callee) {
let mut call_env = HashMap::new();
for (param, value) in function.params.iter().zip(values) {
call_env.insert(param.name.clone(), value);
}
return self.eval_block(&function.body, &mut call_env);
}
match (callee, values.as_slice()) {
("test_data", [Value::ByteArray(bytes)]) => Ok(Value::Data(bytes.clone())),
(
"test_tx",
[
signers,
payment_addresses,
payment_amounts,
minted_assets,
minted_amounts,
Value::Int(valid_from),
Value::Int(valid_until),
spends,
datums,
],
) => {
let signers = byte_array_list(signers)
.ok_or_else(|| self.error(span, "test_tx signers must be a List<ByteArray>"))?;
let payment_addresses = byte_array_list(payment_addresses).ok_or_else(|| {
self.error(span, "test_tx payment addresses must be a List<ByteArray>")
})?;
let payment_amounts = int_list(payment_amounts).ok_or_else(|| {
self.error(span, "test_tx payment amounts must be a List<Int>")
})?;
if payment_addresses.len() != payment_amounts.len() {
return Err(self.error(
span,
"test_tx payment address and amount lists must have the same length",
));
}
let minted_assets = byte_array_list(minted_assets).ok_or_else(|| {
self.error(span, "test_tx minted assets must be a List<ByteArray>")
})?;
let minted_amounts = int_list(minted_amounts).ok_or_else(|| {
self.error(span, "test_tx minted amounts must be a List<Int>")
})?;
if minted_assets.len() != minted_amounts.len() {
return Err(self.error(
span,
"test_tx minted asset and amount lists must have the same length",
));
}
let spends = byte_array_list(spends)
.ok_or_else(|| self.error(span, "test_tx spends must be a List<ByteArray>"))?;
let datums = data_list(datums)
.ok_or_else(|| self.error(span, "test_tx datums must be a List<Data>"))?;
Ok(Value::Tx(TxFixture {
signers,
payments: payment_addresses.into_iter().zip(payment_amounts).collect(),
mints: minted_assets.into_iter().zip(minted_amounts).collect(),
valid_from: *valid_from,
valid_until: *valid_until,
spends,
datums,
}))
}
("tx_signed_by", [Value::Tx(ctx), Value::ByteArray(signer)]) => {
Ok(Value::Bool(ctx.signers.iter().any(|known| known == signer)))
}
(
"tx_paid_to",
[
Value::Tx(ctx),
Value::ByteArray(address),
Value::Int(amount),
],
) => Ok(Value::Bool(
total_amount(&ctx.payments, address) >= i128::from(*amount),
)),
("tx_mints", [Value::Tx(ctx), Value::ByteArray(asset), Value::Int(amount)]) => Ok(
Value::Bool(total_amount(&ctx.mints, asset) == i128::from(*amount)),
),
("tx_after", [Value::Tx(ctx), Value::Int(slot)]) => {
Ok(Value::Bool(ctx.valid_from >= *slot))
}
("tx_before", [Value::Tx(ctx), Value::Int(slot)]) => {
Ok(Value::Bool(ctx.valid_until <= *slot))
}
("tx_spends", [Value::Tx(ctx), Value::ByteArray(output_ref)]) => Ok(Value::Bool(
ctx.spends.iter().any(|known| known == output_ref),
)),
("tx_has_datum", [Value::Tx(ctx), Value::Data(datum)]) => {
Ok(Value::Bool(ctx.datums.iter().any(|known| known == datum)))
}
("append_bytes", [Value::ByteArray(left), Value::ByteArray(right)]) => {
Ok(Value::ByteArray(format!("{left}{right}")))
}
("list_has_bytes", [Value::List(items), Value::ByteArray(want)]) => Ok(Value::Bool(
items
.iter()
.any(|item| item == &Value::ByteArray(want.clone())),
)),
("list_has_int", [Value::List(items), Value::Int(want)]) => Ok(Value::Bool(
items.iter().any(|item| item == &Value::Int(*want)),
)),
("list_has_bool", [Value::List(items), Value::Bool(want)]) => Ok(Value::Bool(
items.iter().any(|item| item == &Value::Bool(*want)),
)),
("list_has_string", [Value::List(items), Value::String(want)]) => Ok(Value::Bool(
items
.iter()
.any(|item| item == &Value::String(want.clone())),
)),
("list_has_data", [Value::List(items), Value::Data(want)]) => Ok(Value::Bool(
items.iter().any(|item| item == &Value::Data(want.clone())),
)),
("list_has_unit", [Value::List(items), Value::Unit]) => {
Ok(Value::Bool(items.iter().any(|item| item == &Value::Unit)))
}
("list_has", [Value::List(items), want]) => {
Ok(Value::Bool(items.iter().any(|item| item == want)))
}
(
"list_len_bytes" | "list_len_int" | "list_len_bool" | "list_len_string"
| "list_len_data" | "list_len_unit" | "list_len",
[Value::List(items)],
) => Ok(Value::Int(items.len() as i64)),
("sha2_256", [Value::ByteArray(bytes)]) => Ok(Value::ByteArray(sha2_256(bytes))),
("blake2b_256", [Value::ByteArray(bytes)]) => Ok(Value::ByteArray(blake2b_256(bytes))),
("datum_equals", [left, right]) => Ok(Value::Bool(left == right)),
(name, _) if is_transaction_builtin(name) => Err(self.error(
span,
format!("'{callee}' needs a transaction context and cannot run in pure tests"),
)),
_ => Err(self.error(span, format!("cannot evaluate call to '{callee}'"))),
}
}
fn match_arm(
&self,
subject: &Value,
arm: &MatchArm,
env: &mut HashMap<String, Value>,
) -> Result<bool> {
match &arm.pattern {
Pattern::Wildcard { .. } => Ok(true),
Pattern::Variable { name, .. } => {
env.insert(name.clone(), subject.clone());
Ok(true)
}
Pattern::Constructor { name, bindings, .. } => {
let Value::Constructor { name: got, fields } = subject else {
return Ok(false);
};
if got != name || fields.len() != bindings.len() {
return Ok(false);
}
for (binding, value) in bindings.iter().zip(fields) {
if let Some(name) = &binding.name {
env.insert(name.clone(), value.clone());
}
}
Ok(true)
}
Pattern::Bool { value, .. } => Ok(subject == &Value::Bool(*value)),
Pattern::Int { value, .. } => Ok(subject == &Value::Int(*value)),
Pattern::ByteArray { hex, .. } => Ok(subject == &Value::ByteArray(hex.clone())),
Pattern::String { value, .. } => Ok(subject == &Value::String(value.clone())),
Pattern::Unit { .. } => Ok(subject == &Value::Unit),
}
}
fn error(&self, span: Span, message: impl Into<String>) -> AeriError {
AeriError::at_span(self.file, span, message).with_source(self.source)
}
}
fn checked_int(value: Option<i64>, error: impl FnOnce() -> AeriError) -> Result<Value> {
value.map(Value::Int).ok_or_else(error)
}
fn is_fail(value: &Value) -> bool {
matches!(value, Value::Fail(_))
}
fn byte_array_list(value: &Value) -> Option<Vec<String>> {
let Value::List(items) = value else {
return None;
};
items
.iter()
.map(|item| match item {
Value::ByteArray(bytes) => Some(bytes.clone()),
_ => None,
})
.collect()
}
fn int_list(value: &Value) -> Option<Vec<i64>> {
let Value::List(items) = value else {
return None;
};
items
.iter()
.map(|item| match item {
Value::Int(value) => Some(*value),
_ => None,
})
.collect()
}
fn data_list(value: &Value) -> Option<Vec<String>> {
let Value::List(items) = value else {
return None;
};
items
.iter()
.map(|item| match item {
Value::Data(data) => Some(data.clone()),
_ => None,
})
.collect()
}
fn total_amount(pairs: &[(String, i64)], key: &str) -> i128 {
pairs
.iter()
.filter_map(|(known, amount)| (known == key).then_some(i128::from(*amount)))
.sum()
}
fn sha2_256(hex: &str) -> String {
hex_encode(&Sha256::digest(hex_decode(hex)))
}
fn blake2b_256(hex: &str) -> String {
let mut output = [0; 32];
let mut hasher = Blake2bVar::new(output.len()).expect("valid Blake2b output size");
hasher.update(&hex_decode(hex));
hasher
.finalize_variable(&mut output)
.expect("fixed-size output matches Blake2b output size");
hex_encode(&output)
}
fn hex_decode(hex: &str) -> Vec<u8> {
hex.as_bytes()
.chunks_exact(2)
.map(|digits| {
let high = hex_value(digits[0]);
let low = hex_value(digits[1]);
(high << 4) | low
})
.collect()
}
fn hex_value(byte: u8) -> u8 {
if byte.is_ascii_digit() {
byte - b'0'
} else {
byte.to_ascii_lowercase() - b'a' + 10
}
}
fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut encoded = String::with_capacity(bytes.len() * 2);
for byte in bytes {
encoded.push(HEX[(byte >> 4) as usize] as char);
encoded.push(HEX[(byte & 0x0f) as usize] as char);
}
encoded
}