use std::collections::HashSet;
const SUPPORTED_IR_VERSION: (u8, u8, u8) = (1, 0, 0);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Program {
pub version: (u8, u8, u8),
pub term: Term,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidationReport {
pub valid: bool,
pub errors: Vec<String>,
pub nodes: usize,
pub max_depth: usize,
}
#[derive(Debug, Default)]
struct ValidationState {
errors: Vec<String>,
nodes: usize,
max_depth: usize,
}
pub fn validate_program(program: &Program) -> ValidationReport {
let mut state = ValidationState::default();
let mut scope = Vec::new();
validate_version(program.version, &mut state);
validate_term(&program.term, &mut scope, 1, &mut state);
state.errors.sort();
state.errors.dedup();
ValidationReport {
valid: state.errors.is_empty(),
errors: state.errors,
nodes: state.nodes,
max_depth: state.max_depth,
}
}
impl Program {
pub fn new(term: Term) -> Self {
Self {
version: SUPPORTED_IR_VERSION,
term,
}
}
pub fn render(&self) -> String {
format!(
"(program {}.{}.{} {})",
self.version.0,
self.version.1,
self.version.2,
self.term.render()
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Term {
Var(String),
Bool(bool),
Int(i64),
String(String),
ByteArray(String),
List(Vec<Term>),
Unit,
Lambda {
param: String,
body: Box<Term>,
},
Let {
name: String,
value: Box<Term>,
body: Box<Term>,
},
If {
condition: Box<Term>,
then_term: Box<Term>,
else_term: Box<Term>,
},
BuiltinCall {
name: String,
args: Vec<Term>,
},
Constr {
name: String,
tag: usize,
fields: Vec<Term>,
},
Match {
subject: Box<Term>,
arms: Vec<MatchArm>,
},
Trace {
message: Box<Term>,
then_term: Box<Term>,
},
Error(String),
Sequence {
first: Box<Term>,
then_term: Box<Term>,
},
}
impl Term {
pub fn render(&self) -> String {
match self {
Term::Var(name) => name.clone(),
Term::Bool(true) => "(con bool True)".to_string(),
Term::Bool(false) => "(con bool False)".to_string(),
Term::Int(value) => format!("(con integer {value})"),
Term::String(value) => format!("(con string \"{}\")", escape(value)),
Term::ByteArray(hex) => format!("(con bytes #{hex})"),
Term::List(items) => {
if items.is_empty() {
return "(list)".to_string();
}
let rendered_items = items.iter().map(Term::render).collect::<Vec<_>>().join(" ");
format!("(list {rendered_items})")
}
Term::Unit => "(con unit ())".to_string(),
Term::Lambda { param, body } => format!("(lam {param} {})", body.render()),
Term::Let { name, value, body } => {
format!("(let {name} {} {})", value.render(), body.render())
}
Term::If {
condition,
then_term,
else_term,
} => format!(
"(if {} {} {})",
condition.render(),
then_term.render(),
else_term.render()
),
Term::BuiltinCall { name, args } => {
let rendered_args = args.iter().map(Term::render).collect::<Vec<_>>().join(" ");
if rendered_args.is_empty() {
format!("(builtin {name})")
} else {
format!("(builtin {name} {rendered_args})")
}
}
Term::Constr { name, tag, fields } => {
let rendered_fields = fields
.iter()
.map(Term::render)
.collect::<Vec<_>>()
.join(" ");
if rendered_fields.is_empty() {
format!("(constr {tag} {name})")
} else {
format!("(constr {tag} {name} {rendered_fields})")
}
}
Term::Match { subject, arms } => {
let rendered_arms = arms
.iter()
.map(MatchArm::render)
.collect::<Vec<_>>()
.join(" ");
format!("(match {} {rendered_arms})", subject.render())
}
Term::Trace { message, then_term } => {
format!("(trace {} {})", message.render(), then_term.render())
}
Term::Error(message) => format!("(error \"{}\")", escape(message)),
Term::Sequence { first, then_term } => {
format!("(seq {} {})", first.render(), then_term.render())
}
}
}
}
fn validate_version(version: (u8, u8, u8), state: &mut ValidationState) {
if version == SUPPORTED_IR_VERSION {
return;
}
state.errors.push(format!(
"unsupported Aeri IR version {}.{}.{}, expected {}.{}.{}",
version.0,
version.1,
version.2,
SUPPORTED_IR_VERSION.0,
SUPPORTED_IR_VERSION.1,
SUPPORTED_IR_VERSION.2
));
}
fn validate_term(term: &Term, scope: &mut Vec<String>, depth: usize, state: &mut ValidationState) {
state.nodes += 1;
state.max_depth = state.max_depth.max(depth);
match term {
Term::Var(name) => {
validate_identifier("variable", name, state);
if !scope.iter().rev().any(|bound| bound == name) {
state.errors.push(format!("unbound variable '{name}'"));
}
}
Term::Bool(_) | Term::Int(_) | Term::String(_) | Term::Unit => (),
Term::ByteArray(hex) => validate_hex("byte array", hex, state),
Term::List(items) => {
for item in items {
validate_term(item, scope, depth + 1, state);
}
}
Term::Lambda { param, body } => {
validate_identifier("lambda parameter", param, state);
scope.push(param.clone());
validate_term(body, scope, depth + 1, state);
scope.pop();
}
Term::Let { name, value, body } => {
validate_identifier("let binding", name, state);
validate_term(value, scope, depth + 1, state);
scope.push(name.clone());
validate_term(body, scope, depth + 1, state);
scope.pop();
}
Term::If {
condition,
then_term,
else_term,
} => {
validate_term(condition, scope, depth + 1, state);
validate_term(then_term, scope, depth + 1, state);
validate_term(else_term, scope, depth + 1, state);
}
Term::BuiltinCall { name, args } => {
validate_builtin_call(name, args.len(), state);
for arg in args {
validate_term(arg, scope, depth + 1, state);
}
}
Term::Constr { name, fields, .. } => {
validate_identifier("constructor", name, state);
for field in fields {
validate_term(field, scope, depth + 1, state);
}
}
Term::Match { subject, arms } => {
validate_match_arms(arms, state);
validate_term(subject, scope, depth + 1, state);
for arm in arms {
let bindings = pattern_bindings(&arm.pattern, state);
for binding in &bindings {
scope.push(binding.clone());
}
validate_term(&arm.body, scope, depth + 1, state);
for _ in bindings {
scope.pop();
}
}
}
Term::Trace { message, then_term } => {
validate_term(message, scope, depth + 1, state);
validate_term(then_term, scope, depth + 1, state);
}
Term::Error(message) => validate_error(message, state),
Term::Sequence { first, then_term } => {
validate_term(first, scope, depth + 1, state);
validate_term(then_term, scope, depth + 1, state);
}
}
}
fn validate_match_arms(arms: &[MatchArm], state: &mut ValidationState) {
if arms.is_empty() {
state
.errors
.push("match expression needs at least one arm".to_string());
return;
}
let last_index = arms.len() - 1;
let mut constructors = HashSet::new();
let mut constructor_names = HashSet::new();
let mut constructor_tags = HashSet::new();
let mut bools = HashSet::new();
let mut ints = HashSet::new();
let mut bytes = HashSet::new();
let mut strings = HashSet::new();
let mut unit_seen = false;
for (index, arm) in arms.iter().enumerate() {
match &arm.pattern {
Pattern::Wildcard | Pattern::Binding(_) if index != last_index => {
state
.errors
.push("catch-all match arm must be last".to_string());
}
Pattern::Wildcard | Pattern::Binding(_) => (),
Pattern::Constructor { name, tag, .. } => {
if !constructors.insert((*tag, name.clone())) {
state.errors.push(format!(
"duplicate match arm for constructor '{name}#{tag}'"
));
}
if !constructor_names.insert(name.clone()) {
state
.errors
.push(format!("duplicate match arm for constructor '{name}'"));
}
if !constructor_tags.insert(*tag) {
state
.errors
.push(format!("duplicate match arm for constructor tag '{tag}'"));
}
}
Pattern::Bool(value) => {
if !bools.insert(*value) {
state
.errors
.push(format!("duplicate match arm for '{value}'"));
}
}
Pattern::Int(value) => {
if !ints.insert(*value) {
state
.errors
.push(format!("duplicate match arm for '{value}'"));
}
}
Pattern::ByteArray(hex) => {
if !bytes.insert(hex.clone()) {
state
.errors
.push(format!("duplicate match arm for '#{hex}'"));
}
}
Pattern::String(value) => {
if !strings.insert(value.clone()) {
state
.errors
.push(format!("duplicate match arm for string '{value}'"));
}
}
Pattern::Unit => {
if unit_seen {
state
.errors
.push("duplicate match arm for '()'".to_string());
}
unit_seen = true;
}
}
}
}
fn pattern_bindings(pattern: &Pattern, state: &mut ValidationState) -> Vec<String> {
validate_pattern(pattern, state);
let bindings = match pattern {
Pattern::Wildcard
| Pattern::Bool(_)
| Pattern::Int(_)
| Pattern::ByteArray(_)
| Pattern::String(_)
| Pattern::Unit => Vec::new(),
Pattern::Binding(name) => vec![name.clone()],
Pattern::Constructor { bindings, .. } => bindings.iter().flatten().cloned().collect(),
};
let mut seen = HashSet::new();
for binding in &bindings {
if !seen.insert(binding) {
state
.errors
.push(format!("duplicate pattern binding '{binding}'"));
}
}
bindings
}
fn validate_pattern(pattern: &Pattern, state: &mut ValidationState) {
match pattern {
Pattern::Wildcard
| Pattern::Bool(_)
| Pattern::Int(_)
| Pattern::String(_)
| Pattern::Unit => (),
Pattern::ByteArray(hex) => validate_hex("byte array pattern", hex, state),
Pattern::Binding(name) => validate_identifier("pattern binding", name, state),
Pattern::Constructor { name, bindings, .. } => {
validate_identifier("constructor pattern", name, state);
for binding in bindings.iter().flatten() {
validate_identifier("constructor pattern binding", binding, state);
}
}
}
}
fn validate_identifier(kind: &str, name: &str, state: &mut ValidationState) {
let mut chars = name.chars();
let Some(first) = chars.next() else {
state.errors.push(format!("{kind} name cannot be empty"));
return;
};
if !(first.is_ascii_alphabetic() || first == '_') {
state
.errors
.push(format!("{kind} '{name}' is not a valid IR identifier"));
return;
}
if !chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_') {
state
.errors
.push(format!("{kind} '{name}' is not a valid IR identifier"));
}
}
fn validate_hex(kind: &str, hex: &str, state: &mut ValidationState) {
if !hex.len().is_multiple_of(2) || !hex.chars().all(|ch| ch.is_ascii_hexdigit()) {
state
.errors
.push(format!("{kind} '#{hex}' is not valid hex"));
}
}
fn validate_builtin_call(name: &str, arity: usize, state: &mut ValidationState) {
let Some(expected) = builtin_arity(name) else {
state.errors.push(format!("unknown builtin '{name}'"));
return;
};
if arity != expected {
state.errors.push(format!(
"builtin '{name}' expects {expected} argument(s), got {arity}"
));
}
}
fn builtin_arity(name: &str) -> Option<usize> {
Some(match name {
"tx_signed_by" => 2,
"tx_paid_to" => 3,
"tx_mints" => 3,
"tx_after" | "tx_before" => 2,
"tx_spends" => 2,
"tx_has_datum" => 2,
"test_data" => 1,
"test_tx" => 9,
"datum_equals" => 2,
"sha2_256" | "blake2b_256" => 1,
"append_bytes" => 2,
"list_has_bytes"
| "list_has_int"
| "list_has_bool"
| "list_has_string"
| "list_has_data"
| "list_has_unit"
| "list_has"
| "list_has_custom_tag" => 2,
"list_len_bytes" | "list_len_int" | "list_len_bool" | "list_len_string"
| "list_len_data" | "list_len_unit" | "list_len" => 1,
"equalsAeriValue"
| "notEqualsAeriValue"
| "equalsListBool"
| "notEqualsListBool"
| "equalsListInteger"
| "notEqualsListInteger"
| "equalsListByteString"
| "notEqualsListByteString"
| "equalsListData"
| "notEqualsListData"
| "equalsListString"
| "notEqualsListString"
| "equalsListUnit"
| "notEqualsListUnit"
| "equalsListCustomTag"
| "notEqualsListCustomTag"
| "equalsCustomTag"
| "notEqualsCustomTag" => 2,
"equalsBool" | "notEqualsBool" => 2,
"equalsData" | "notEqualsData" => 2,
"equalsInteger" | "notEqualsInteger" => 2,
"equalsByteString" | "notEqualsByteString" => 2,
"equalsString" | "notEqualsString" => 2,
"lessThanInteger" | "lessThanEqualsInteger" => 2,
"greaterThanInteger" | "greaterThanEqualsInteger" => 2,
"addInteger" | "subtractInteger" | "multiplyInteger" => 2,
"quotientInteger" | "remainderInteger" => 2,
"not" | "negateInteger" => 1,
_ => return None,
})
}
fn validate_error(message: &str, state: &mut ValidationState) {
if matches!(message, "require failed" | "fail") {
return;
}
state.errors.push(format!(
"internal lowering error escaped into IR: {message}"
));
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MatchArm {
pub pattern: Pattern,
pub body: Term,
}
impl MatchArm {
fn render(&self) -> String {
format!("(case {} {})", self.pattern.render(), self.body.render())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Pattern {
Wildcard,
Binding(String),
Constructor {
name: String,
tag: usize,
bindings: Vec<Option<String>>,
},
Bool(bool),
Unit,
Int(i64),
ByteArray(String),
String(String),
}
impl Pattern {
fn render(&self) -> String {
match self {
Pattern::Wildcard => "_".to_string(),
Pattern::Binding(name) => name.clone(),
Pattern::Constructor {
name,
tag,
bindings,
} => {
let rendered_bindings = bindings
.iter()
.map(|binding| binding.as_deref().unwrap_or("_"))
.collect::<Vec<_>>()
.join(" ");
if rendered_bindings.is_empty() {
format!("{name}#{tag}")
} else {
format!("({name}#{tag} {rendered_bindings})")
}
}
Pattern::Bool(value) => value.to_string(),
Pattern::Unit => "()".to_string(),
Pattern::Int(value) => value.to_string(),
Pattern::ByteArray(hex) => format!("#{hex}"),
Pattern::String(value) => format!("\"{}\"", escape(value)),
}
}
}
fn escape(value: &str) -> String {
value
.chars()
.flat_map(|ch| match ch {
'\\' => "\\\\".chars().collect::<Vec<_>>(),
'"' => "\\\"".chars().collect(),
'\n' => "\\n".chars().collect(),
'\r' => "\\r".chars().collect(),
'\t' => "\\t".chars().collect(),
_ => vec![ch],
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_closed_validator_ir() {
let program = Program::new(Term::Lambda {
param: "redeemer".to_string(),
body: Box::new(Term::BuiltinCall {
name: "equalsData".to_string(),
args: vec![
Term::Var("redeemer".to_string()),
Term::ByteArray("01".to_string()),
],
}),
});
let report = validate_program(&program);
assert!(report.valid, "unexpected errors: {:?}", report.errors);
assert_eq!(report.nodes, 4);
assert!(report.max_depth >= 2);
}
#[test]
fn reports_unbound_variables() {
let program = Program::new(Term::Var("missing".to_string()));
let report = validate_program(&program);
assert!(!report.valid);
assert!(
report
.errors
.iter()
.any(|error| error.contains("unbound variable 'missing'"))
);
}
#[test]
fn reports_unsupported_ir_versions() {
let program = Program {
version: (9, 9, 9),
term: Term::Bool(true),
};
let report = validate_program(&program);
assert!(!report.valid);
assert!(
report
.errors
.iter()
.any(|error| error.contains("unsupported Aeri IR version 9.9.9"))
);
}
#[test]
fn reports_unknown_and_wrong_arity_builtins() {
let unknown = Program::new(Term::BuiltinCall {
name: "mystery".to_string(),
args: Vec::new(),
});
let wrong_arity = Program::new(Term::BuiltinCall {
name: "equalsData".to_string(),
args: vec![Term::ByteArray("01".to_string())],
});
let unknown_report = validate_program(&unknown);
let wrong_arity_report = validate_program(&wrong_arity);
assert!(!unknown_report.valid);
assert!(
unknown_report
.errors
.iter()
.any(|error| error.contains("unknown builtin 'mystery'"))
);
assert!(!wrong_arity_report.valid);
assert!(
wrong_arity_report
.errors
.iter()
.any(|error| error.contains("expects 2 argument(s), got 1"))
);
}
#[test]
fn reports_invalid_identifiers_and_hex_literals() {
let program = Program::new(Term::Lambda {
param: "bad-name".to_string(),
body: Box::new(Term::Match {
subject: Box::new(Term::Var("bad-name".to_string())),
arms: vec![MatchArm {
pattern: Pattern::Constructor {
name: "Bad-Constructor".to_string(),
tag: 0,
bindings: vec![Some("field-one".to_string())],
},
body: Term::ByteArray("0g".to_string()),
}],
}),
});
let report = validate_program(&program);
assert!(!report.valid);
assert!(
report
.errors
.iter()
.any(|error| error.contains("lambda parameter 'bad-name'"))
);
assert!(
report
.errors
.iter()
.any(|error| error.contains("variable 'bad-name'"))
);
assert!(
report
.errors
.iter()
.any(|error| error.contains("constructor pattern 'Bad-Constructor'"))
);
assert!(
report
.errors
.iter()
.any(|error| error.contains("constructor pattern binding 'field-one'"))
);
assert!(
report
.errors
.iter()
.any(|error| error.contains("byte array '#0g'"))
);
}
#[test]
fn reports_escaped_lowering_errors() {
let program = Program::new(Term::Error("unresolved call to nope".to_string()));
let report = validate_program(&program);
assert!(!report.valid);
assert!(
report
.errors
.iter()
.any(|error| error.contains("internal lowering error escaped into IR"))
);
}
#[test]
fn reports_invalid_match_arm_order_and_duplicates() {
let program = Program::new(Term::Lambda {
param: "redeemer".to_string(),
body: Box::new(Term::Match {
subject: Box::new(Term::Var("redeemer".to_string())),
arms: vec![
MatchArm {
pattern: Pattern::Binding("other".to_string()),
body: Term::Var("other".to_string()),
},
MatchArm {
pattern: Pattern::ByteArray("01".to_string()),
body: Term::ByteArray("01".to_string()),
},
MatchArm {
pattern: Pattern::ByteArray("01".to_string()),
body: Term::ByteArray("02".to_string()),
},
],
}),
});
let report = validate_program(&program);
assert!(!report.valid);
assert!(
report
.errors
.iter()
.any(|error| error.contains("catch-all match arm must be last"))
);
assert!(
report
.errors
.iter()
.any(|error| error.contains("duplicate match arm for '#01'"))
);
}
#[test]
fn reports_duplicate_constructor_match_arms() {
let program = Program::new(Term::Lambda {
param: "action".to_string(),
body: Box::new(Term::Match {
subject: Box::new(Term::Var("action".to_string())),
arms: vec![
MatchArm {
pattern: Pattern::Constructor {
name: "Spend".to_string(),
tag: 0,
bindings: Vec::new(),
},
body: Term::Bool(true),
},
MatchArm {
pattern: Pattern::Constructor {
name: "Spend".to_string(),
tag: 0,
bindings: Vec::new(),
},
body: Term::Bool(false),
},
],
}),
});
let report = validate_program(&program);
assert!(!report.valid);
assert!(
report
.errors
.iter()
.any(|error| error.contains("duplicate match arm for constructor 'Spend#0'"))
);
}
#[test]
fn reports_conflicting_constructor_match_identity() {
let program = Program::new(Term::Lambda {
param: "action".to_string(),
body: Box::new(Term::Match {
subject: Box::new(Term::Var("action".to_string())),
arms: vec![
MatchArm {
pattern: Pattern::Constructor {
name: "Spend".to_string(),
tag: 0,
bindings: Vec::new(),
},
body: Term::Bool(true),
},
MatchArm {
pattern: Pattern::Constructor {
name: "Spend".to_string(),
tag: 1,
bindings: Vec::new(),
},
body: Term::Bool(false),
},
MatchArm {
pattern: Pattern::Constructor {
name: "Close".to_string(),
tag: 0,
bindings: Vec::new(),
},
body: Term::Bool(false),
},
],
}),
});
let report = validate_program(&program);
assert!(!report.valid);
assert!(
report
.errors
.iter()
.any(|error| error.contains("duplicate match arm for constructor 'Spend'"))
);
assert!(
report
.errors
.iter()
.any(|error| error.contains("duplicate match arm for constructor tag '0'"))
);
}
#[test]
fn pattern_bindings_scope_match_arm_bodies() {
let program = Program::new(Term::Lambda {
param: "action".to_string(),
body: Box::new(Term::Match {
subject: Box::new(Term::Var("action".to_string())),
arms: vec![MatchArm {
pattern: Pattern::Constructor {
name: "Spend".to_string(),
tag: 0,
bindings: vec![Some("owner".to_string())],
},
body: Term::Var("owner".to_string()),
}],
}),
});
let report = validate_program(&program);
assert!(report.valid, "unexpected errors: {:?}", report.errors);
}
}