From ee9acce6ecda5673fb01b6078855e47bac1ce64e Mon Sep 17 00:00:00 2001 From: Brandon Lucas Date: Sat, 14 Feb 2026 01:20:30 -0500 Subject: [PATCH] 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 --- docs/TESTING_DESIGN.md | 307 +++++++++++++++++++++++++++++++++++++++ examples/test_lists.lux | 57 ++++++++ examples/test_math.lux | 51 +++++++ src/codegen/c_backend.rs | 94 ++++++------ src/codegen/mod.rs | 1 + src/compiler.rs | 20 +-- src/interpreter.rs | 168 +++++++++++++++++++++ src/main.rs | 223 ++++++++++++++++++++++------ src/typechecker.rs | 2 +- src/types.rs | 50 +++++++ 10 files changed, 866 insertions(+), 107 deletions(-) create mode 100644 docs/TESTING_DESIGN.md create mode 100644 examples/test_lists.lux create mode 100644 examples/test_math.lux diff --git a/docs/TESTING_DESIGN.md b/docs/TESTING_DESIGN.md new file mode 100644 index 0000000..77788b3 --- /dev/null +++ b/docs/TESTING_DESIGN.md @@ -0,0 +1,307 @@ +# Lux Testing Framework Design + +## Overview + +Lux needs a native testing framework that leverages its effect system. The key insight: **testing is an effect** - assertions, test discovery, and reporting are all side effects. + +## Survey of Testing Approaches + +### 1. Rust Style (`#[test]` attributes) +```rust +#[test] +fn test_addition() { + assert_eq!(2 + 2, 4); +} +``` +**Pros:** Familiar, built-in, zero runtime overhead +**Cons:** Requires attribute/macro system + +### 2. Elm Style (describe/test/expect) +```elm +suite = describe "Math" + [ test "addition" <| \_ -> Expect.equal 4 (2 + 2) + , test "subtraction" <| \_ -> Expect.equal 0 (2 - 2) + ] +``` +**Pros:** Readable, hierarchical +**Cons:** Verbose, needs custom runner + +### 3. Zig Style (inline test blocks) +```zig +test "addition" { + try std.testing.expect(2 + 2 == 4); +} +``` +**Pros:** Inline with code, comptime evaluated +**Cons:** Needs language support + +### 4. Property-Based (QuickCheck/Hypothesis) +```haskell +prop_reverse :: [Int] -> Bool +prop_reverse xs = reverse (reverse xs) == xs +``` +**Pros:** Finds edge cases automatically +**Cons:** Complex to implement + +### 5. Expect Tests (OCaml/Jane Street) +```ocaml +let%expect_test "hello" = + print_endline "Hello"; + [%expect {| Hello |}] +``` +**Pros:** Self-updating, snapshot testing +**Cons:** Requires tooling + +## Recommended: Effect-Based Testing + +Since Lux has effects, testing should be an effect. This is idiomatic and enables powerful features. + +### Design + +```lux +// Built-in Test effect +effect Test { + fn assert(condition: Bool, message: String): Unit + fn assertEqual(expected: T, actual: T): Unit + fn assertNotEqual(a: T, b: T): Unit + fn fail(message: String): Unit + fn skip(reason: String): Unit + fn todo(description: String): Unit +} + +// Test declaration syntax (new keyword) +test "addition works" { + Test.assertEqual(4, 2 + 2) +} + +test "string concatenation" { + let result = "hello" + " " + "world" + Test.assertEqual("hello world", result) +} + +// Grouped tests +describe "List operations" { + test "map doubles values" { + let input = [1, 2, 3] + let output = List.map(input, fn(x) => x * 2) + Test.assertEqual([2, 4, 6], output) + } + + test "filter keeps matches" { + let input = [1, 2, 3, 4, 5] + let output = List.filter(input, fn(x) => x > 2) + Test.assertEqual([3, 4, 5], output) + } +} +``` + +### Why This Design? + +1. **Idiomatic**: Testing is just another effect +2. **Mockable**: Can provide different Test handlers for different scenarios +3. **Parallel-safe**: Effect isolation prevents test interference +4. **Extensible**: Users can define custom assertion effects +5. **Property testing**: Can add `Test.forAll` for property-based testing + +### Test Runner + +```bash +# Run all tests in a file +lux test examples/math_test.lux + +# Run all tests in directory +lux test tests/ + +# Run specific test +lux test tests/math.lux --filter "addition" + +# Watch mode +lux test --watch +``` + +### Alternative: Annotation-Based (Simpler) + +If we want simpler implementation: + +```lux +// Functions prefixed with test_ are tests +fn test_addition(): Unit with {Test} = { + Test.assertEqual(4, 2 + 2) +} + +fn test_strings(): Unit with {Test} = { + Test.assertEqual("hello", "hel" + "lo") +} +``` + +Run with: +```bash +lux test file.lux # Finds all test_* functions +``` + +## Chosen Approach: Hybrid + +Combine both for flexibility: + +### Phase 1: Function-Based (Simple, Immediate) + +```lux +// Any function starting with test_ is a test +fn test_addition(): Unit with {Test} = + Test.assertEqual(4, 2 + 2) + +fn test_factorial(): Unit with {Test} = { + Test.assertEqual(1, factorial(0)) + Test.assertEqual(1, factorial(1)) + Test.assertEqual(120, factorial(5)) +} + +// Helper for multiple assertions +fn test_list_operations(): Unit with {Test} = { + // Each assertion is independent + Test.assertEqual([2, 4], List.map([1, 2], fn(x) => x * 2)) + Test.assertEqual([2, 3], List.filter([1, 2, 3], fn(x) => x > 1)) + Test.assertEqual(6, List.fold([1, 2, 3], 0, fn(a, b) => a + b)) +} +``` + +### Phase 2: Block Syntax (Later) + +```lux +test "descriptive name" { + Test.assertEqual(4, 2 + 2) +} + +describe "module name" { + test "feature 1" { ... } + test "feature 2" { ... } +} +``` + +## Implementation Plan + +### Step 1: Test Effect + +Add built-in `Test` effect to the type system: + +```rust +// In types.rs, add Test effect +EffectDef { + name: "Test".to_string(), + operations: vec![ + EffectOpDef { name: "assert", params: vec![("cond", Bool), ("msg", String)], return_type: Unit }, + EffectOpDef { name: "assertEqual", params: vec![("expected", T), ("actual", T)], return_type: Unit }, + EffectOpDef { name: "fail", params: vec![("msg", String)], return_type: Unit }, + ], +} +``` + +### Step 2: Test Handler (Interpreter) + +```rust +// In interpreter.rs +fn handle_test_effect(&mut self, op: &str, args: &[Value]) -> Result { + match op { + "assert" => { + let cond = args[0].as_bool()?; + let msg = args[1].as_string()?; + if !cond { + self.test_failures.push(TestFailure { message: msg, .. }); + } + Ok(Value::Unit) + } + "assertEqual" => { + let expected = &args[0]; + let actual = &args[1]; + if expected != actual { + self.test_failures.push(TestFailure { + message: format!("Expected {:?}, got {:?}", expected, actual), + .. + }); + } + Ok(Value::Unit) + } + // ... + } +} +``` + +### Step 3: Test Discovery + +```rust +// Find all test_* functions in a file +fn discover_tests(program: &Program) -> Vec<&FunctionDecl> { + program.declarations.iter().filter_map(|d| { + if let Declaration::Function(f) = d { + if f.name.name.starts_with("test_") { + return Some(f); + } + } + None + }).collect() +} +``` + +### Step 4: Test Runner CLI + +```bash +lux test file.lux +``` + +Output: +``` +Running tests in file.lux... + + test_addition ... OK + test_factorial ... OK + test_strings ... FAIL + Expected: "hello world" + Actual: "helloworld" + at file.lux:15 + +Results: 2 passed, 1 failed +``` + +## Property-Based Testing (Future) + +```lux +// Properties are functions that return Bool +fn prop_reverse_twice(xs: List): Bool = + List.reverse(List.reverse(xs)) == xs + +fn prop_sort_idempotent(xs: List): Bool = + List.sort(List.sort(xs)) == List.sort(xs) + +// Test runner generates random inputs +test "reverse is involution" { + Test.forAll(prop_reverse_twice) +} +``` + +## Expect Tests (Future) + +```lux +test "json output" { + let data = { name: "Alice", age: 30 } + let json = Json.stringify(data) + + // This gets auto-updated by the test runner + Test.expect(json, ||| + {"name":"Alice","age":30} + |||) +} +``` + +## Summary + +| Phase | Feature | Effort | +|-------|---------|--------| +| 1 | Test effect + test_* discovery | 1-2 days | +| 2 | Test runner CLI (`lux test`) | 1 day | +| 3 | Nice output formatting | 0.5 day | +| 4 | `test "name" {}` syntax | 1 day | +| 5 | `describe` blocks | 0.5 day | +| 6 | Property-based testing | 2-3 days | +| 7 | Expect/snapshot tests | 2 days | + +**Recommendation:** Start with Phase 1-3 (function-based tests with CLI) for immediate value. diff --git a/examples/test_lists.lux b/examples/test_lists.lux new file mode 100644 index 0000000..e5dbfb7 --- /dev/null +++ b/examples/test_lists.lux @@ -0,0 +1,57 @@ +// List Operations Test Suite +// Run with: lux test examples/test_lists.lux + +fn test_list_length(): Unit with {Test} = { + Test.assertEqual(0, List.length([])) + Test.assertEqual(1, List.length([1])) + Test.assertEqual(3, List.length([1, 2, 3])) +} + +fn test_list_head(): Unit with {Test} = { + Test.assertEqual(Some(1), List.head([1, 2, 3])) + Test.assertEqual(None, List.head([])) +} + +fn test_list_tail(): Unit with {Test} = { + Test.assertEqual(Some([2, 3]), List.tail([1, 2, 3])) + Test.assertEqual(None, List.tail([])) +} + +fn test_list_get(): Unit with {Test} = { + let xs = [10, 20, 30] + Test.assertEqual(Some(10), List.get(xs, 0)) + Test.assertEqual(Some(20), List.get(xs, 1)) + Test.assertEqual(Some(30), List.get(xs, 2)) + Test.assertEqual(None, List.get(xs, 3)) +} + +fn test_list_map(): Unit with {Test} = { + let double = fn(x: Int): Int => x * 2 + Test.assertEqual([2, 4, 6], List.map([1, 2, 3], double)) + Test.assertEqual([], List.map([], double)) +} + +fn test_list_filter(): Unit with {Test} = { + let isEven = fn(x: Int): Bool => x % 2 == 0 + Test.assertEqual([2, 4], List.filter([1, 2, 3, 4, 5], isEven)) + Test.assertEqual([], List.filter([1, 3, 5], isEven)) +} + +fn test_list_fold(): Unit with {Test} = { + let sum = fn(acc: Int, x: Int): Int => acc + x + Test.assertEqual(6, List.fold([1, 2, 3], 0, sum)) + Test.assertEqual(10, List.fold([1, 2, 3], 4, sum)) + Test.assertEqual(0, List.fold([], 0, sum)) +} + +fn test_list_reverse(): Unit with {Test} = { + Test.assertEqual([3, 2, 1], List.reverse([1, 2, 3])) + Test.assertEqual([], List.reverse([])) + Test.assertEqual([1], List.reverse([1])) +} + +fn test_list_concat(): Unit with {Test} = { + Test.assertEqual([1, 2, 3, 4], List.concat([1, 2], [3, 4])) + Test.assertEqual([1, 2], List.concat([1, 2], [])) + Test.assertEqual([3, 4], List.concat([], [3, 4])) +} diff --git a/examples/test_math.lux b/examples/test_math.lux new file mode 100644 index 0000000..b2fc929 --- /dev/null +++ b/examples/test_math.lux @@ -0,0 +1,51 @@ +// Math Test Suite +// Run with: lux test examples/test_math.lux + +fn test_addition(): Unit with {Test} = { + Test.assertEqual(4, 2 + 2) + Test.assertEqual(0, 0 + 0) + Test.assertEqual(100, 50 + 50) +} + +fn test_subtraction(): Unit with {Test} = { + Test.assertEqual(0, 2 - 2) + Test.assertEqual(5, 10 - 5) + Test.assertEqual(-5, 0 - 5) +} + +fn test_multiplication(): Unit with {Test} = { + Test.assertEqual(6, 2 * 3) + Test.assertEqual(0, 0 * 100) + Test.assertEqual(100, 10 * 10) +} + +fn test_division(): Unit with {Test} = { + Test.assertEqual(2, 6 / 3) + Test.assertEqual(0, 0 / 5) + Test.assertEqual(5, 25 / 5) +} + +fn test_comparisons(): Unit with {Test} = { + Test.assertTrue(5 > 3) + Test.assertTrue(3 < 5) + Test.assertTrue(5 >= 5) + Test.assertTrue(5 <= 5) + Test.assertFalse(3 > 5) + Test.assertFalse(5 < 3) +} + +fn test_equality(): Unit with {Test} = { + Test.assertTrue(42 == 42) + Test.assertFalse(42 == 43) + Test.assertTrue(42 != 43) + Test.assertFalse(42 != 42) +} + +fn test_boolean_logic(): Unit with {Test} = { + Test.assertTrue(true && true) + Test.assertFalse(true && false) + Test.assertTrue(true || false) + Test.assertFalse(false || false) + Test.assertTrue(!false) + Test.assertFalse(!true) +} diff --git a/src/codegen/c_backend.rs b/src/codegen/c_backend.rs index 5424d2b..6336714 100644 --- a/src/codegen/c_backend.rs +++ b/src/codegen/c_backend.rs @@ -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, _> = 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, _> = 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 { 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()), } } diff --git a/src/codegen/mod.rs b/src/codegen/mod.rs index 5e2a278..1c45843 100644 --- a/src/codegen/mod.rs +++ b/src/codegen/mod.rs @@ -7,4 +7,5 @@ pub mod c_backend; +#[allow(unused_imports)] pub use c_backend::CBackend; diff --git a/src/compiler.rs b/src/compiler.rs index 87aed25..d934b3e 100644 --- a/src/compiler.rs +++ b/src/compiler.rs @@ -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 { diff --git a/src/interpreter.rs b/src/interpreter.rs index c0ea8e4..1ad194f 100644 --- a/src/interpreter.rs +++ b/src/interpreter.rs @@ -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>>, /// Current HTTP request being handled (stored for respond operation) current_http_request: Arc>>, + /// Test results for the Test effect + test_results: RefCell, +} + +/// Results from running tests +#[derive(Debug, Clone, Default)] +pub struct TestResults { + pub passed: usize, + pub failed: usize, + pub failures: Vec, +} + +/// A single test failure +#[derive(Debug, Clone)] +pub struct TestFailure { + pub message: String, + pub expected: Option, + pub actual: Option, } 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: {}.{}", diff --git a/src/main.rs b/src/main.rs index 8e8ea0a..5af2750 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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) { + 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; diff --git a/src/typechecker.rs b/src/typechecker.rs index 11ff365..b569589 100644 --- a/src/typechecker.rs +++ b/src/typechecker.rs @@ -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 diff --git a/src/types.rs b/src/types.rs index 0a98d74..cff2d46 100644 --- a/src/types.rs +++ b/src/types.rs @@ -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 let a = Type::var();