feat: add Test effect and native testing framework

- Add Test effect with operations: assert, assertEqual, assertNotEqual,
  assertTrue, assertFalse, fail
- Implement Test effect handlers in interpreter with TestResults tracking
- Add values_equal method for comparing Value types in tests
- Update lux test command to discover and run test_* functions
- Create example test files: test_math.lux, test_lists.lux
- Add TESTING_DESIGN.md documentation
- Fix AST mismatches in C backend and compiler.rs for compatibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-14 01:20:30 -05:00
parent 9a42a7f540
commit ee9acce6ec
10 changed files with 866 additions and 107 deletions

View File

@@ -5,7 +5,7 @@
//! no garbage collector needed with Perceus-style reference counting.
use crate::ast::*;
use std::collections::{HashMap, HashSet};
use std::collections::HashSet;
use std::fmt::Write;
/// C code generation errors
@@ -81,8 +81,8 @@ impl CBackend {
Declaration::Function(f) => {
self.emit_function(f)?;
}
Declaration::Let { name, value, .. } => {
self.emit_global_let(name, value)?;
Declaration::Let(let_decl) => {
self.emit_global_let(&let_decl.name, &let_decl.value)?;
}
_ => {}
}
@@ -170,18 +170,18 @@ impl CBackend {
self.types_emitted.insert(name.clone());
match &type_decl.definition {
TypeDefinition::Record(fields) => {
TypeDef::Record(fields) => {
self.writeln(&format!("typedef struct {} {{", name));
self.indent += 1;
for field in fields {
let c_type = self.type_to_c(&field.field_type)?;
let c_type = self.type_to_c(&field.typ)?;
self.writeln(&format!("{} {};", c_type, field.name.name));
}
self.indent -= 1;
self.writeln(&format!("}} {};", name));
self.writeln("");
}
TypeDefinition::Adt(variants) => {
TypeDef::Enum(variants) => {
// Emit tag enum
self.writeln(&format!("typedef enum {}_Tag {{", name));
self.indent += 1;
@@ -195,12 +195,24 @@ impl CBackend {
// Emit variant structs
for variant in variants {
if !variant.fields.is_empty() {
let has_fields = !matches!(&variant.fields, VariantFields::Unit);
if has_fields {
self.writeln(&format!("typedef struct {}_{}_Data {{", name, variant.name.name));
self.indent += 1;
for (i, field) in variant.fields.iter().enumerate() {
let c_type = self.type_to_c(field)?;
self.writeln(&format!("{} field{};", c_type, i));
match &variant.fields {
VariantFields::Tuple(fields) => {
for (i, field) in fields.iter().enumerate() {
let c_type = self.type_to_c(field)?;
self.writeln(&format!("{} field{};", c_type, i));
}
}
VariantFields::Record(fields) => {
for field in fields {
let c_type = self.type_to_c(&field.typ)?;
self.writeln(&format!("{} {};", c_type, field.name.name));
}
}
VariantFields::Unit => {}
}
self.indent -= 1;
self.writeln(&format!("}} {}_{}_Data;", name, variant.name.name));
@@ -215,7 +227,8 @@ impl CBackend {
self.writeln("union {");
self.indent += 1;
for variant in variants {
if !variant.fields.is_empty() {
let has_fields = !matches!(&variant.fields, VariantFields::Unit);
if has_fields {
self.writeln(&format!("{}_{}_Data {};", name, variant.name.name, variant.name.name.to_lowercase()));
}
}
@@ -225,7 +238,7 @@ impl CBackend {
self.writeln(&format!("}} {};", name));
self.writeln("");
}
TypeDefinition::Alias(_) => {
TypeDef::Alias(_) => {
// Type aliases are handled during type resolution
}
}
@@ -270,7 +283,7 @@ impl CBackend {
}
let param_strs: Result<Vec<_>, _> = params.iter().map(|p| {
let c_type = self.type_expr_to_c(&p.param_type)?;
let c_type = self.type_expr_to_c(&p.typ)?;
Ok(format!("{} {}", c_type, p.name.name))
}).collect();
@@ -301,13 +314,10 @@ impl CBackend {
BinaryOp::Ge => ">=",
BinaryOp::And => "&&",
BinaryOp::Or => "||",
BinaryOp::Concat => {
return Ok(format!("lux_string_concat({}, {})", l, r));
BinaryOp::Pipe => {
// Pipe operator - for now, just call the right side with left as argument
return Ok(format!("{}({})", r, l));
}
_ => return Err(CGenError {
message: format!("Unsupported binary operator: {:?}", op),
span: None,
}),
};
Ok(format!("({} {} {})", l, op_str, r))
@@ -404,19 +414,8 @@ impl CBackend {
Ok(format!("{}.{}", obj, field.name))
}
Expr::Match { expr, arms, .. } => {
self.emit_match(expr, arms)
}
Expr::Constructor { name, args, .. } => {
// ADT constructor - need to determine the type
// For now, assume it's a simple constructor call
if args.is_empty() {
Ok(format!("/* {} */ 0", name.name))
} else {
let arg_strs: Result<Vec<_>, _> = args.iter().map(|a| self.emit_expr(a)).collect();
Ok(format!("/* {}({}) */", name.name, arg_strs?.join(", ")))
}
Expr::Match { scrutinee, arms, .. } => {
self.emit_match(scrutinee, arms)
}
_ => Err(CGenError {
@@ -462,9 +461,9 @@ impl CBackend {
fn pattern_to_condition(&self, pattern: &Pattern, scrutinee: &str) -> Result<String, CGenError> {
match pattern {
Pattern::Wildcard(_) => Ok("1".to_string()),
Pattern::Var(ident, _) => Ok(format!("(1) /* bind {} = {} */", ident.name, scrutinee)),
Pattern::Literal(lit, _) => {
let lit_val = self.emit_literal_value(lit)?;
Pattern::Var(ident) => Ok(format!("(1) /* bind {} = {} */", ident.name, scrutinee)),
Pattern::Literal(lit) => {
let lit_val = self.emit_literal_value(&lit.kind)?;
Ok(format!("{} == {}", scrutinee, lit_val))
}
Pattern::Constructor { name, .. } => {
@@ -504,7 +503,7 @@ impl CBackend {
// Check for top-level run expressions
let has_run = program.declarations.iter().any(|d| {
matches!(d, Declaration::Let { value, .. } if matches!(value.as_ref(), Expr::Run { .. }))
matches!(d, Declaration::Let(let_decl) if matches!(&let_decl.value, Expr::Run { .. }))
});
if has_main || has_run {
@@ -513,9 +512,9 @@ impl CBackend {
// Execute top-level let bindings with run expressions
for decl in &program.declarations {
if let Declaration::Let { name, value, .. } = decl {
if matches!(value.as_ref(), Expr::Run { .. }) {
if let Expr::Run { expr, .. } = value.as_ref() {
if let Declaration::Let(let_decl) = decl {
if matches!(&let_decl.value, Expr::Run { .. }) {
if let Expr::Run { expr, .. } = &let_decl.value {
if let Expr::Call { func, .. } = expr.as_ref() {
if let Expr::Var(fn_name) = func.as_ref() {
self.writeln(&format!("{}();", fn_name.name));
@@ -549,18 +548,23 @@ impl CBackend {
other => Ok(other.to_string()),
}
}
TypeExpr::Generic { name, .. } => {
TypeExpr::App(base, _) => {
// For now, use void* for generic types
match name.name.as_str() {
"List" => Ok("void*".to_string()),
"Option" => Ok("void*".to_string()),
_ => Ok("void*".to_string()),
if let TypeExpr::Named(name) = base.as_ref() {
match name.name.as_str() {
"List" => Ok("void*".to_string()),
"Option" => Ok("void*".to_string()),
_ => Ok("void*".to_string()),
}
} else {
Ok("void*".to_string())
}
}
TypeExpr::Unit => Ok("void".to_string()),
TypeExpr::Versioned { base, .. } => self.type_expr_to_c(base),
TypeExpr::Function { .. } => Ok("void*".to_string()),
TypeExpr::Tuple(_) => Ok("void*".to_string()),
TypeExpr::Record(_) => Ok("void*".to_string()),
_ => Ok("void*".to_string()),
}
}

View File

@@ -7,4 +7,5 @@
pub mod c_backend;
#[allow(unused_imports)]
pub use c_backend::CBackend;

View File

@@ -465,12 +465,13 @@ fn compile_expr(
}
expr => {
use crate::ast::LiteralKind;
let expr_type = match expr {
Expr::Literal(lit) => match lit {
Literal::String(_) => "String literal",
Literal::Float(_) => "Float literal",
Literal::Char(_) => "Char literal",
Literal::Unit => "Unit literal",
Expr::Literal(lit) => match &lit.kind {
LiteralKind::String(_) => "String literal",
LiteralKind::Float(_) => "Float literal",
LiteralKind::Char(_) => "Char literal",
LiteralKind::Unit => "Unit literal",
_ => "Literal",
},
Expr::EffectOp { effect, operation, .. } => {
@@ -485,17 +486,8 @@ fn compile_expr(
Expr::List { .. } => "List literal",
Expr::Record { .. } => "Record literal",
Expr::Tuple { .. } => "Tuple literal",
Expr::Index { .. } => "Index access",
Expr::Run { .. } => "Run expression (effects)",
Expr::Handle { .. } => "Handle expression (effects)",
Expr::Resume { .. } => "Resume expression (effects)",
Expr::Pipe { .. } => "Pipe operator",
Expr::Interpolation { .. } => "String interpolation",
Expr::Constructor { name, .. } => {
return Err(CompileError {
message: format!("ADT constructor '{}' - algebraic data types are not supported in JIT", name.name),
});
}
_ => "Unknown expression",
};
Err(CompileError {

View File

@@ -177,6 +177,40 @@ impl Value {
_ => None,
}
}
/// Compare two values for equality (for testing)
/// Returns true if the values are structurally equal
pub fn values_equal(a: &Value, b: &Value) -> bool {
match (a, b) {
(Value::Int(x), Value::Int(y)) => x == y,
(Value::Float(x), Value::Float(y)) => (x - y).abs() < f64::EPSILON,
(Value::Bool(x), Value::Bool(y)) => x == y,
(Value::String(x), Value::String(y)) => x == y,
(Value::Char(x), Value::Char(y)) => x == y,
(Value::Unit, Value::Unit) => true,
(Value::List(xs), Value::List(ys)) => {
xs.len() == ys.len() && xs.iter().zip(ys.iter()).all(|(x, y)| Value::values_equal(x, y))
}
(Value::Tuple(xs), Value::Tuple(ys)) => {
xs.len() == ys.len() && xs.iter().zip(ys.iter()).all(|(x, y)| Value::values_equal(x, y))
}
(Value::Record(xs), Value::Record(ys)) => {
xs.len() == ys.len() && xs.iter().all(|(k, v)| {
ys.get(k).map(|yv| Value::values_equal(v, yv)).unwrap_or(false)
})
}
(Value::Constructor { name: n1, fields: f1 }, Value::Constructor { name: n2, fields: f2 }) => {
n1 == n2 && f1.len() == f2.len() && f1.iter().zip(f2.iter()).all(|(x, y)| Value::values_equal(x, y))
}
(Value::Versioned { type_name: t1, version: v1, value: val1 },
Value::Versioned { type_name: t2, version: v2, value: val2 }) => {
t1 == t2 && v1 == v2 && Value::values_equal(val1, val2)
}
(Value::Json(j1), Value::Json(j2)) => j1 == j2,
// Functions and handlers cannot be compared for equality
_ => false,
}
}
}
/// Trait for extracting typed values from Value
@@ -545,6 +579,24 @@ pub struct Interpreter {
http_server: Arc<Mutex<Option<tiny_http::Server>>>,
/// Current HTTP request being handled (stored for respond operation)
current_http_request: Arc<Mutex<Option<tiny_http::Request>>>,
/// Test results for the Test effect
test_results: RefCell<TestResults>,
}
/// Results from running tests
#[derive(Debug, Clone, Default)]
pub struct TestResults {
pub passed: usize,
pub failed: usize,
pub failures: Vec<TestFailure>,
}
/// A single test failure
#[derive(Debug, Clone)]
pub struct TestFailure {
pub message: String,
pub expected: Option<String>,
pub actual: Option<String>,
}
impl Interpreter {
@@ -567,9 +619,20 @@ impl Interpreter {
in_handler_depth: 0,
http_server: Arc::new(Mutex::new(None)),
current_http_request: Arc::new(Mutex::new(None)),
test_results: RefCell::new(TestResults::default()),
}
}
/// Get the test results
pub fn get_test_results(&self) -> TestResults {
self.test_results.borrow().clone()
}
/// Reset test results for a new test run
pub fn reset_test_results(&self) {
*self.test_results.borrow_mut() = TestResults::default();
}
/// Set the initial value for the built-in State effect
pub fn set_state(&self, value: Value) {
*self.builtin_state.borrow_mut() = value;
@@ -3466,6 +3529,111 @@ impl Interpreter {
Ok(Value::Unit)
}
// Test effect for testing framework
("Test", "assert") => {
let condition = match request.args.first() {
Some(Value::Bool(b)) => *b,
_ => false,
};
let message = match request.args.get(1) {
Some(Value::String(s)) => s.clone(),
_ => "Assertion failed".to_string(),
};
if condition {
self.test_results.borrow_mut().passed += 1;
} else {
self.test_results.borrow_mut().failed += 1;
self.test_results.borrow_mut().failures.push(TestFailure {
message,
expected: None,
actual: None,
});
}
Ok(Value::Unit)
}
("Test", "assertEqual") => {
let expected = request.args.first().cloned().unwrap_or(Value::Unit);
let actual = request.args.get(1).cloned().unwrap_or(Value::Unit);
if Value::values_equal(&expected, &actual) {
self.test_results.borrow_mut().passed += 1;
} else {
self.test_results.borrow_mut().failed += 1;
self.test_results.borrow_mut().failures.push(TestFailure {
message: "Values not equal".to_string(),
expected: Some(format!("{}", expected)),
actual: Some(format!("{}", actual)),
});
}
Ok(Value::Unit)
}
("Test", "assertNotEqual") => {
let a = request.args.first().cloned().unwrap_or(Value::Unit);
let b = request.args.get(1).cloned().unwrap_or(Value::Unit);
if !Value::values_equal(&a, &b) {
self.test_results.borrow_mut().passed += 1;
} else {
self.test_results.borrow_mut().failed += 1;
self.test_results.borrow_mut().failures.push(TestFailure {
message: "Values should not be equal".to_string(),
expected: Some(format!("not {}", a)),
actual: Some(format!("{}", b)),
});
}
Ok(Value::Unit)
}
("Test", "assertTrue") => {
let condition = match request.args.first() {
Some(Value::Bool(b)) => *b,
_ => false,
};
if condition {
self.test_results.borrow_mut().passed += 1;
} else {
self.test_results.borrow_mut().failed += 1;
self.test_results.borrow_mut().failures.push(TestFailure {
message: "Expected true".to_string(),
expected: Some("true".to_string()),
actual: Some("false".to_string()),
});
}
Ok(Value::Unit)
}
("Test", "assertFalse") => {
let condition = match request.args.first() {
Some(Value::Bool(b)) => *b,
_ => true,
};
if !condition {
self.test_results.borrow_mut().passed += 1;
} else {
self.test_results.borrow_mut().failed += 1;
self.test_results.borrow_mut().failures.push(TestFailure {
message: "Expected false".to_string(),
expected: Some("false".to_string()),
actual: Some("true".to_string()),
});
}
Ok(Value::Unit)
}
("Test", "fail") => {
let message = match request.args.first() {
Some(Value::String(s)) => s.clone(),
_ => "Test failed".to_string(),
};
self.test_results.borrow_mut().failed += 1;
self.test_results.borrow_mut().failures.push(TestFailure {
message,
expected: None,
actual: None,
});
Ok(Value::Unit)
}
_ => Err(RuntimeError {
message: format!(
"Unhandled effect operation: {}.{}",

View File

@@ -1,6 +1,7 @@
//! Lux - A functional programming language with first-class effects
mod ast;
mod codegen;
mod compiler;
mod debugger;
mod diagnostics;
@@ -364,80 +365,208 @@ fn run_tests(args: &[String]) {
// Find test files
let pattern = args.first().map(|s| s.as_str());
// Look for test files in current directory and tests/ subdirectory
// Look for test files in multiple locations
let mut test_files = Vec::new();
for entry in fs::read_dir(".").into_iter().flatten().flatten() {
let path = entry.path();
if path.extension().map(|e| e == "lux").unwrap_or(false) {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.starts_with("test_") || name.ends_with("_test.lux") {
if pattern.map(|p| name.contains(p)).unwrap_or(true) {
test_files.push(path);
}
}
}
}
// Current directory
collect_test_files(".", pattern, &mut test_files);
// tests/ subdirectory
if Path::new("tests").is_dir() {
collect_test_files("tests", pattern, &mut test_files);
}
if Path::new("tests").is_dir() {
for entry in fs::read_dir("tests").into_iter().flatten().flatten() {
let path = entry.path();
if path.extension().map(|e| e == "lux").unwrap_or(false) {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if pattern.map(|p| name.contains(p)).unwrap_or(true) {
test_files.push(path);
}
}
}
// examples/ subdirectory (for example tests)
if Path::new("examples").is_dir() {
collect_test_files("examples", pattern, &mut test_files);
}
// If a specific file is given, use that
if let Some(p) = pattern {
if Path::new(p).is_file() {
test_files.clear();
test_files.push(std::path::PathBuf::from(p));
}
}
if test_files.is_empty() {
println!("No test files found.");
println!("Test files should be named test_*.lux or *_test.lux");
println!("Or contain functions named test_*");
return;
}
let mut passed = 0;
let mut failed = 0;
println!("Running tests...\n");
let mut total_passed = 0;
let mut total_failed = 0;
let mut all_failures = Vec::new();
for test_file in &test_files {
let path_str = test_file.to_string_lossy().to_string();
print!("Testing {}... ", path_str);
// Run the test file
let result = std::process::Command::new(std::env::current_exe().unwrap())
.arg(&path_str)
.output();
match result {
Ok(output) if output.status.success() => {
println!("OK");
passed += 1;
}
Ok(output) => {
println!("FAILED");
if !output.stderr.is_empty() {
eprintln!("{}", String::from_utf8_lossy(&output.stderr));
}
failed += 1;
}
// Read and parse the file
let source = match fs::read_to_string(test_file) {
Ok(s) => s,
Err(e) => {
println!("ERROR: {}", e);
failed += 1;
println!(" {} ... ERROR: {}", path_str, e);
total_failed += 1;
continue;
}
};
let program = match Parser::parse_source(&source) {
Ok(p) => p,
Err(e) => {
println!(" {} ... PARSE ERROR: {}", path_str, e);
total_failed += 1;
continue;
}
};
// Type check
let mut checker = typechecker::TypeChecker::new();
if let Err(errors) = checker.check_program(&program) {
println!(" {} ... TYPE ERROR", path_str);
for err in errors {
eprintln!(" {}", err);
}
total_failed += 1;
continue;
}
// Find test functions (functions starting with test_)
let test_funcs: Vec<_> = program.declarations.iter().filter_map(|d| {
if let ast::Declaration::Function(f) = d {
if f.name.name.starts_with("test_") {
return Some(f.name.name.clone());
}
}
None
}).collect();
if test_funcs.is_empty() {
// No test functions, run the whole file
let mut interp = Interpreter::new();
interp.reset_test_results();
match interp.run(&program) {
Ok(_) => {
let results = interp.get_test_results();
if results.failed == 0 && results.passed == 0 {
// No Test assertions, just check it runs
println!(" {} ... OK (no assertions)", path_str);
total_passed += 1;
} else if results.failed == 0 {
println!(" {} ... OK ({} assertions)", path_str, results.passed);
total_passed += results.passed;
} else {
println!(" {} ... FAILED ({} passed, {} failed)", path_str, results.passed, results.failed);
total_passed += results.passed;
total_failed += results.failed;
for failure in &results.failures {
all_failures.push((path_str.clone(), "".to_string(), failure.clone()));
}
}
}
Err(e) => {
println!(" {} ... RUNTIME ERROR: {}", path_str, e);
total_failed += 1;
}
}
} else {
// Run individual test functions
println!(" {}:", path_str);
for test_name in &test_funcs {
let mut interp = Interpreter::new();
interp.reset_test_results();
// First run the file to define all functions
if let Err(e) = interp.run(&program) {
println!(" {} ... ERROR: {}", test_name, e);
total_failed += 1;
continue;
}
// Call the test function
let call_source = format!("let testResult = run {}() with {{}}", test_name);
let call_program = match Parser::parse_source(&call_source) {
Ok(p) => p,
Err(e) => {
println!(" {} ... ERROR: {}", test_name, e);
total_failed += 1;
continue;
}
};
match interp.run(&call_program) {
Ok(_) => {
let results = interp.get_test_results();
if results.failed == 0 {
println!(" {} ... OK", test_name);
total_passed += 1;
} else {
println!(" {} ... FAILED", test_name);
total_failed += 1;
for failure in &results.failures {
all_failures.push((path_str.clone(), test_name.clone(), failure.clone()));
}
}
}
Err(e) => {
println!(" {} ... ERROR: {}", test_name, e);
total_failed += 1;
}
}
}
}
}
println!();
println!("Results: {} passed, {} failed", passed, failed);
// Print failure details
if !all_failures.is_empty() {
println!("\n--- Failures ---\n");
for (file, test, failure) in &all_failures {
if test.is_empty() {
println!("{}:", file);
} else {
println!("{} - {}:", file, test);
}
println!(" {}", failure.message);
if let Some(expected) = &failure.expected {
println!(" Expected: {}", expected);
}
if let Some(actual) = &failure.actual {
println!(" Actual: {}", actual);
}
println!();
}
}
if failed > 0 {
println!("Results: {} passed, {} failed", total_passed, total_failed);
if total_failed > 0 {
std::process::exit(1);
}
}
fn collect_test_files(dir: &str, pattern: Option<&str>, files: &mut Vec<std::path::PathBuf>) {
use std::fs;
for entry in fs::read_dir(dir).into_iter().flatten().flatten() {
let path = entry.path();
if path.extension().map(|e| e == "lux").unwrap_or(false) {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.starts_with("test_") || name.ends_with("_test.lux") {
if pattern.map(|p| name.contains(p)).unwrap_or(true) {
files.push(path);
}
}
}
}
}
}
fn watch_file(path: &str) {
use std::time::{Duration, SystemTime};
use std::path::Path;

View File

@@ -1532,7 +1532,7 @@ impl TypeChecker {
}
// Built-in effects are always available
let builtin_effects = ["Console", "Fail", "State", "Reader", "Random", "Time", "File", "Process", "Http", "HttpServer"];
let builtin_effects = ["Console", "Fail", "State", "Reader", "Random", "Time", "File", "Process", "Http", "HttpServer", "Test"];
let is_builtin = builtin_effects.contains(&effect.name.as_str());
// Track this effect for inference

View File

@@ -1123,6 +1123,56 @@ impl TypeEnv {
},
);
// Add Test effect for test framework
env.effects.insert(
"Test".to_string(),
EffectDef {
name: "Test".to_string(),
type_params: Vec::new(),
operations: vec![
EffectOpDef {
name: "assert".to_string(),
params: vec![
("condition".to_string(), Type::Bool),
("message".to_string(), Type::String),
],
return_type: Type::Unit,
},
EffectOpDef {
name: "assertEqual".to_string(),
params: vec![
("expected".to_string(), Type::Var(0)),
("actual".to_string(), Type::Var(0)),
],
return_type: Type::Unit,
},
EffectOpDef {
name: "assertNotEqual".to_string(),
params: vec![
("a".to_string(), Type::Var(0)),
("b".to_string(), Type::Var(0)),
],
return_type: Type::Unit,
},
EffectOpDef {
name: "assertTrue".to_string(),
params: vec![("condition".to_string(), Type::Bool)],
return_type: Type::Unit,
},
EffectOpDef {
name: "assertFalse".to_string(),
params: vec![("condition".to_string(), Type::Bool)],
return_type: Type::Unit,
},
EffectOpDef {
name: "fail".to_string(),
params: vec![("message".to_string(), Type::String)],
return_type: Type::Unit,
},
],
},
);
// Add Some and Ok, Err constructors
// Some : fn(a) -> Option<a>
let a = Type::var();