diff --git a/.claude/hooks/README.md b/.claude/hooks/README.md index 37d2f38f..2fff7d17 100644 --- a/.claude/hooks/README.md +++ b/.claude/hooks/README.md @@ -13,7 +13,7 @@ Automatically runs `cargo fmt --all` after any Edit or Write operation on Rust f ### Prerequisites -The hook configured in `../.claude/settings.json` currently uses PowerShell: +The hook configured in `../.claude/settings.json` currently uses Bash: - **Windows PowerShell**: Built into Windows (default configuration) - Verify: `powershell --version` @@ -43,12 +43,12 @@ For bash (Unix/macOS/Git Bash on Windows): } ``` -Current configuration (Windows PowerShell): +Current configuration (Bash for Linux): ```json { "type": "command", - "command": "powershell -File .claude/hooks/format-rust.ps1", + "command": "bash .claude/hooks/format-rust.sh", "timeout": 120 } ``` diff --git a/.claude/hooks/format-rust.sh b/.claude/hooks/format-rust.sh old mode 100644 new mode 100755 diff --git a/.claude/settings.json b/.claude/settings.json index 86da7456..d2fe0215 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "powershell -File .claude/hooks/format-rust.ps1", + "command": "bash .claude/hooks/format-rust.sh", "timeout": 120 } ] diff --git a/CLAUDE.md b/CLAUDE.md index cb40f73d..148d1e48 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,6 +78,7 @@ Source Code → Lexer → Parser → Analyzer → Type Checker → Interpreter - `wfl --configCheck` / `wfl --configFix`: Check/fix configuration. - `wfl --dump-env`: Dump environment for troubleshooting. - `wfl --analyze `: Run static analysis. +- `wfl --test `: Run file in test mode (executes describe/test blocks). ## Key Language Features - **Natural Language Syntax**: `store name as "value"`, `check if x is greater than 5`. @@ -85,6 +86,7 @@ Source Code → Lexer → Parser → Analyzer → Type Checker → Interpreter - **Async Support**: Built-in async/await using Tokio runtime. - **Pattern Matching**: Regex-like engine with Unicode support. - **Container System**: OOP with containers. +- **Testing Framework**: Built-in testing with `describe`, `test`, and natural language assertions. - **Security**: WFLHASH custom crypto, secure subprocess spawning. ## Coding Style & Naming @@ -98,9 +100,11 @@ Source Code → Lexer → Parser → Analyzer → Type Checker → Interpreter ## Testing Guidelines - **TDD is mandatory**: Write failing tests FIRST for any feature or bug fix. - **Locations**: - - Unit/Integration: `tests/` - - End-to-End: `TestPrograms/` (must pass with release build) -- **Conventions**: feature‑oriented names (`*_test.rs`), keep perf benches under `benches/`. + - Rust Unit/Integration: `tests/` + - WFL End-to-End: `TestPrograms/` (must pass with release build) + - WFL Test Framework: Use `describe`/`test` blocks, run with `wfl --test ` +- **Conventions**: feature‑oriented names (`*_test.rs`, `*.test.wfl`), keep perf benches under `benches/`. +- **Testing Guide**: See `Docs/guides/testing-guide.md` for WFL testing framework documentation. ## Commit & Pull Request Guidelines - **Conventional Commits**: `feat:`, `fix:`, `docs:`, `test:`, `refactor:`. diff --git a/Docs/guides/testing-guide.md b/Docs/guides/testing-guide.md new file mode 100644 index 00000000..bf46f5cf --- /dev/null +++ b/Docs/guides/testing-guide.md @@ -0,0 +1,366 @@ +# WFL Testing Guide + +WFL includes a built-in testing framework with natural language syntax that makes it easy to write and run tests for your WFL programs. + +## Table of Contents + +- [Getting Started](#getting-started) +- [Writing Your First Test](#writing-your-first-test) +- [Test Structure](#test-structure) +- [Assertions](#assertions) +- [Setup and Teardown](#setup-and-teardown) +- [Running Tests](#running-tests) +- [Best Practices](#best-practices) + +## Getting Started + +The WFL testing framework allows you to write tests using familiar natural language syntax. Tests are organized in `describe` blocks and individual `test` blocks, similar to popular testing frameworks like Jest, RSpec, or PHPUnit. + +## Writing Your First Test + +Create a file with a `.wfl` extension (commonly `.test.wfl` for test files): + +```wfl +describe "Basic arithmetic": + + test "addition works correctly": + store result as 5 plus 3 + expect result to equal 8 + end test + + test "subtraction works correctly": + store result as 10 minus 4 + expect result to equal 6 + end test + +end describe +``` + +Run your tests with: + +```bash +wfl --test my_tests.wfl +``` + +## Test Structure + +### Describe Blocks + +Describe blocks organize related tests together. They provide context and can be nested: + +```wfl +describe "Calculator": + + describe "Addition": + test "handles positive numbers": + store result as 2 plus 3 + expect result to equal 5 + end test + end describe + + describe "Subtraction": + test "handles negative results": + store result as 3 minus 8 + expect result to be less than 0 + end test + end describe + +end describe +``` + +### Test Blocks + +Each `test` block represents a single test case: + +```wfl +test "description of what is being tested": + // Test code goes here + store actual as some calculation + expect actual to equal expected_value +end test +``` + +**Important**: Each test runs in an isolated environment. Variables created in one test do not affect other tests. + +## Assertions + +WFL provides natural language assertions that read like English: + +### Equality + +```wfl +expect value to equal 42 +expect message to be "hello" // 'be' is a synonym for 'equal' +``` + +### Comparisons + +```wfl +expect score to be greater than 50 +expect age to be less than 100 +``` + +### Truthiness + +```wfl +expect condition to be yes // Truthy check +expect flag to be no // Falsy check +``` + +### Existence + +```wfl +expect value to exist +``` + +### Collections + +```wfl +expect numbers to contain 5 +expect list to be empty +expect items to have length 3 +``` + +### Text + +```wfl +expect message to contain "error" +expect text to be empty +expect word to have length 5 +``` + +### Type Checking + +```wfl +expect value to be of type "Number" +expect message to be of type "Text" +expect items to be of type "List" +``` + +## Setup and Teardown + +Use `setup` and `teardown` blocks to run code before and after tests in a describe block: + +```wfl +describe "Database operations": + + setup: + display "Connecting to test database..." + store test_data as [1, 2, 3, 4, 5] + end setup + + test "can read data": + expect test_data to have length 5 + end test + + test "can process data": + store sum as 0 + for each item in test_data: + change sum to sum plus item + end for + expect sum to equal 15 + end test + + teardown: + display "Cleaning up test database..." + end teardown + +end describe +``` + +**Note**: Setup and teardown run once per describe block, not before/after each individual test. + +## Running Tests + +### Basic Test Execution + +```bash +wfl --test my_tests.wfl +``` + +### Test Output + +The test runner provides clear, formatted output: + +``` +============================================================ +Test Results +============================================================ +Total: 10 +Passed: 9 ✓ +Failed: 1 ✗ + +──────────────────────────────────────────────────────────── +Failures: +──────────────────────────────────────────────────────────── + +1. addition handles large numbers + Context: Calculator > Addition + Expected value to equal 1000, but got 999 + at line 45 + +============================================================ +``` + +### Exit Codes + +- **0**: All tests passed +- **1**: One or more tests failed + +This allows integration with CI/CD pipelines. + +## Best Practices + +### 1. Organize Tests by Feature + +```wfl +describe "User authentication": + test "validates email format": ... end test + test "requires password": ... end test + test "creates session on success": ... end test +end describe +``` + +### 2. Use Descriptive Test Names + +Good: +```wfl +test "rejects invalid email addresses": +``` + +Bad: +```wfl +test "test 1": +``` + +### 3. Test One Thing Per Test + +Each test should verify one specific behavior: + +```wfl +// Good - tests one thing +test "addition returns correct sum": + store result as 2 plus 3 + expect result to equal 5 +end test + +// Avoid - tests multiple things +test "math operations": + expect 2 plus 3 to equal 5 + expect 10 minus 4 to equal 6 + expect 3 times 4 to equal 12 +end test +``` + +### 4. Avoid Reserved Keywords as Variable Names + +Some words are reserved for the testing framework: +- `empty` - Use `empty_string`, `empty_list`, etc. +- `type` - Use `data_type`, `value_type`, etc. +- `length` - Use `list_length`, `text_length`, etc. + +### 5. Keep Tests Independent + +Don't rely on test execution order. Each test should work in isolation: + +```wfl +// Good - each test is independent +describe "Counter": + test "increments from zero": + store counter as 0 + change counter to counter plus 1 + expect counter to equal 1 + end test + + test "decrements from initial value": + store counter as 5 + change counter to counter minus 1 + expect counter to equal 4 + end test +end describe +``` + +### 6. Use Setup for Common Initialization + +```wfl +describe "List operations": + setup: + store test_list as [1, 2, 3, 4, 5] + end setup + + test "list has correct length": + expect test_list to have length 5 + end test + + test "list contains all values": + expect test_list to contain 3 + end test +end describe +``` + +## Complete Example + +Here's a comprehensive example showing all features: + +```wfl +describe "Shopping Cart": + + setup: + display "Setting up test cart..." + store cart_items as [] + end setup + + test "starts empty": + expect cart_items to be empty + expect cart_items to have length 0 + end test + + test "can add items": + add "apple" to cart_items + add "banana" to cart_items + expect cart_items to have length 2 + expect cart_items to contain "apple" + end test + + test "total price calculation": + store price as 10 plus 20 plus 30 + expect price to equal 60 + expect price to be greater than 50 + end test + + teardown: + display "Cleaning up test cart..." + end teardown + +end describe +``` + +## Troubleshooting + +### Tests Not Running + +- Ensure you're using the `--test` flag: `wfl --test myfile.wfl` +- Check that your file has test blocks inside describe blocks +- Verify all `describe` and `test` blocks are properly closed with `end describe` and `end test` + +### Assertion Failures + +- Check the error message for the expected vs. actual values +- Verify variable names are spelled correctly +- Ensure you're using the correct assertion type for your data + +### Parse Errors + +- Make sure you're not using reserved keywords as variable names +- Check that all blocks are properly closed +- Verify the syntax of your assertions matches the examples above + +## Next Steps + +- Explore the [WFL Language Basics](../03-language-basics/README.md) for more on WFL syntax +- Check out example test files in the `TestPrograms/` directory +- Learn about [Best Practices](../06-best-practices/README.md) for writing maintainable code + +--- + +For questions or issues, please refer to the [WFL documentation](../README.md) or file an issue on GitHub. diff --git a/TestPrograms/basic_syntax_comprehensive.wfl b/TestPrograms/basic_syntax_comprehensive.wfl index 10806ac6..4b497123 100644 --- a/TestPrograms/basic_syntax_comprehensive.wfl +++ b/TestPrograms/basic_syntax_comprehensive.wfl @@ -37,10 +37,10 @@ display "" // === Variable Redefinition === display "4. Variable Redefinition Test" -store test var as "original" -display "Before: " with test var -change test var to "modified" -display "After: " with test var +store test_var as "original" +display "Before: " with test_var +change test_var to "modified" +display "After: " with test_var display "" // === Conditional Statements === diff --git a/TestPrograms/math_operations.test.wfl b/TestPrograms/math_operations.test.wfl new file mode 100644 index 00000000..c171c219 --- /dev/null +++ b/TestPrograms/math_operations.test.wfl @@ -0,0 +1,71 @@ +// Math operations test suite + +describe "Basic arithmetic": + + test "addition of positive numbers": + store result as 5 plus 3 + expect result to equal 8 + end test + + test "subtraction": + store result as 10 minus 7 + expect result to equal 3 + end test + + test "multiplication": + store result as 4 times 5 + expect result to equal 20 + end test + + test "division": + store result as 15 divided by 3 + expect result to equal 5 + end test + +end describe + +describe "Negative numbers": + + test "subtracting to get negative": + store result as 3 minus 8 + store expected as 0 minus 5 + expect result to equal expected + end test + + test "multiplying with negative": + store a as 0 minus 4 + store b as 3 + store result as a times b + store expected as 0 minus 12 + expect result to equal expected + end test + + test "comparison with negative": + store neg as 0 minus 10 + store pos as 10 + // Test that negative is less than positive + expect neg to be less than pos + end test + +end describe + +describe "Comparison operations": + + test "greater than": + store a as 10 + store b as 5 + expect a to be greater than b + end test + + test "less than": + store a as 3 + store b as 7 + expect a to be less than b + end test + + test "equal comparison": + store result as 5 plus 5 + expect result to equal 10 + end test + +end describe diff --git a/TestPrograms/pr273_fixes_validation.wfl b/TestPrograms/pr273_fixes_validation.wfl new file mode 100644 index 00000000..08126a67 --- /dev/null +++ b/TestPrograms/pr273_fixes_validation.wfl @@ -0,0 +1,47 @@ +// Validates that all PR #273 review comments have been addressed + +describe "Test name tracking works correctly": + + test "first test with unique name": + store value as 1 + expect value to equal 1 // Fixed: now passes + end test + +end describe + +describe "Type checking catches assertion errors": + + test "numeric comparison requires numbers": + store num as 10 + expect num to be greater than 5 + end test + + test "length assertion on lists": + store items as [1, 2, 3] + expect items to have length 3 + end test + +end describe + +describe "Test isolation prevents pollution": + + setup: + store shared as 100 + end setup + + test "reads setup variable": + expect shared to equal 100 + end test + + test "creates independent local variable": + store local as 200 + expect local to equal 200 + end test + + test "previous test's local variable not visible": + // This should work because each test is isolated + store local as 300 + expect local to equal 300 + end test + +end describe diff --git a/TestPrograms/simple_test_validation.wfl b/TestPrograms/simple_test_validation.wfl new file mode 100644 index 00000000..83346ea3 --- /dev/null +++ b/TestPrograms/simple_test_validation.wfl @@ -0,0 +1,20 @@ +// Simple test framework validation + +describe "Basic assertions": + + test "numbers are equal": + store result as 5 + expect result to equal 5 + end test + + test "text values are equal": + store message as "hello" + expect message to equal "hello" + end test + + test "boolean values work": + store flag as yes + expect flag to be yes + end test + +end describe diff --git a/TestPrograms/substring_perf.wfl b/TestPrograms/substring_perf.wfl index 501c75c6..9b8c94a0 100644 --- a/TestPrograms/substring_perf.wfl +++ b/TestPrograms/substring_perf.wfl @@ -8,8 +8,8 @@ display "Sub(0, 5): " with substring of text and 0 and 5 display "Sub(7, 5): " with substring of text and 7 and 5 display "--- Edge Cases ---" -store empty as "" -display "Empty sub(0, 1): '" with substring of empty and 0 and 1 with "'" +store empty_string as "" +display "Empty sub(0, 1): '" with substring of empty_string and 0 and 1 with "'" store short as "Hi" display "Start > len (3, 1): '" with substring of short and 3 and 1 with "'" diff --git a/TestPrograms/test.wfl b/TestPrograms/test.wfl index cfc2854b..81ea7572 100644 --- a/TestPrograms/test.wfl +++ b/TestPrograms/test.wfl @@ -1,5 +1,5 @@ -store test as "test" -display test +store test_value as "test" +display test_value create list protocol: add "http" diff --git a/TestPrograms/test_assertion_fix.wfl b/TestPrograms/test_assertion_fix.wfl new file mode 100644 index 00000000..7f91b99b --- /dev/null +++ b/TestPrograms/test_assertion_fix.wfl @@ -0,0 +1,22 @@ +describe "test assertion message improvements": + test "simple equal assertion that should fail": + store x as 5 + expect x to equal 10 + end test + + test "greater than assertion that should fail": + store y as 3 + expect y to be greater than 7 + end test + + test "list contain assertion that should fail": + store mylist as [1, 2, 3] + expect mylist to contain 5 + end test + + test "length assertion that should fail": + store text as "hello" + expect text to have length 10 + end test + +end describe \ No newline at end of file diff --git a/TestPrograms/test_framework_validation.wfl b/TestPrograms/test_framework_validation.wfl new file mode 100644 index 00000000..3de88c3b --- /dev/null +++ b/TestPrograms/test_framework_validation.wfl @@ -0,0 +1,234 @@ +// Comprehensive test framework validation +// Tests all assertion types and framework features + +describe "Basic equality assertions": + + test "numbers are equal": + store result as 5 + expect result to equal 5 + end test + + test "text values are equal": + store message as "hello" + expect message to equal "hello" + end test + + test "boolean values are equal": + store flag as yes + expect flag to equal yes + end test + + test "synonym 'be' works like 'equal'": + store value as 42 + expect value to be 42 + end test + +end describe + +describe "Comparison assertions": + + test "greater than works": + store num as 10 + expect num to be greater than 5 + end test + + test "less than works": + store num as 3 + expect num to be less than 10 + end test + + test "comparison with expressions": + store a as 7 + store b as 3 + expect a to be greater than b + end test + +end describe + +describe "Truthiness assertions": + + test "yes is truthy": + store flag as yes + expect flag to be yes + end test + + test "no is falsy": + store flag as no + expect flag to be no + end test + + test "non-zero numbers are truthy": + store num as 5 + expect num to be yes + end test + + test "zero is falsy": + store num as 0 + expect num to be no + end test + +end describe + +describe "Existence assertions": + + test "values exist": + store value as "something" + expect value to exist + end test + + test "numbers exist": + store num as 0 + expect num to exist + end test + +end describe + +describe "Collection assertions": + + test "list contains value": + store numbers as [1, 2, 3, 4, 5] + expect numbers to contain 3 + end test + + test "list has correct length": + store items as ["a", "b", "c"] + expect items to have length 3 + end test + + test "empty list is empty": + store empty as [] + expect empty to be empty + end test + + test "non-empty list is not empty": + store numbers as [1, 2, 3] + // This should pass because the list has items + store count as 0 + for each item in numbers: + change count to count plus 1 + end for + expect count to be greater than 0 + end test + +end describe + +describe "Text assertions": + + test "text contains substring": + store message as "hello world" + expect message to contain "world" + end test + + test "empty text is empty": + store empty as "" + expect empty to be empty + end test + + test "text has length": + store word as "hello" + expect word to have length 5 + end test + +end describe + +describe "Type assertions": + + test "number has correct type": + store num as 42 + expect num to be of type "Number" + end test + + test "text has correct type": + store text as "hello" + expect text to be of type "Text" + end test + + test "list has correct type": + store list as [1, 2, 3] + expect list to be of type "List" + end test + +end describe + +describe "Setup and teardown": + + setup: + display "Running setup for this describe block" + store shared_value as 100 + end setup + + test "can access setup variables": + expect shared_value to equal 100 + end test + + test "setup runs before each test": + expect shared_value to exist + end test + + teardown: + display "Running teardown for this describe block" + end teardown + +end describe + +describe "Variable isolation": + + test "first test sets variable": + store test_var as "value1" + expect test_var to equal "value1" + end test + + test "second test has fresh environment": + // test_var from previous test should not exist + // We can verify isolation by setting it independently + store test_var as "value2" + expect test_var to equal "value2" + end test + +end describe + +describe "Nested describe blocks": + + describe "Level 2": + + test "nested test executes": + store nested as yes + expect nested to be yes + end test + + describe "Level 3": + + test "deeply nested test executes": + store deep as 123 + expect deep to equal 123 + end test + + end describe + + end describe + +end describe + +describe "Arithmetic operations": + + test "addition": + store sum as 2 plus 3 + expect sum to equal 5 + end test + + test "subtraction": + store diff as 10 minus 4 + expect diff to equal 6 + end test + + test "multiplication": + store product as 6 times 7 + expect product to equal 42 + end test + + test "division": + store quotient as 20 divided by 4 + expect quotient to equal 5 + end test + +end describe diff --git a/TestPrograms/test_improvements_validation.wfl b/TestPrograms/test_improvements_validation.wfl new file mode 100644 index 00000000..034a59c2 --- /dev/null +++ b/TestPrograms/test_improvements_validation.wfl @@ -0,0 +1,63 @@ +// Test to validate improvements to testing framework + +describe "Test name tracking": + + test "first test with proper name": + store value as 42 + expect value to equal 42 + end test + + test "second test with different name": + store value as "hello" + expect value to equal "hello" + end test + +end describe + +describe "Setup/teardown isolation": + + setup: + store shared_value as 100 + end setup + + test "can access setup variable": + expect shared_value to equal 100 + end test + + test "can read setup variable": + // Setup variables are accessible (read-only) + expect shared_value to equal 100 + end test + + test "can create test-local variables": + // Tests can create their own local variables + store test_local as 200 + expect test_local to equal 200 + end test + + test "local variables don't leak": + // Variables from previous test don't leak to this test + // We can create our own variable with the same name + store test_local as 300 + expect test_local to equal 300 + end test + + teardown: + display "Teardown completes successfully" + end teardown + +end describe + +describe "Type checking catches errors early": + + test "numeric comparisons work": + store num as 10 + expect num to be greater than 5 + end test + + test "length checks on collections": + store items as [1, 2, 3] + expect items to have length 3 + end test + +end describe diff --git a/TestPrograms/text_operations.test.wfl b/TestPrograms/text_operations.test.wfl new file mode 100644 index 00000000..d6eaaa29 --- /dev/null +++ b/TestPrograms/text_operations.test.wfl @@ -0,0 +1,66 @@ +// Text operations test suite + +describe "String operations": + + test "text concatenation": + store greeting as "Hello, " + store name as "World" + store full as greeting with name + expect full to equal "Hello, World" + end test + + test "text contains substring": + store message as "The quick brown fox" + expect message to contain "quick" + end test + + test "empty text is empty": + store empty_string as "" + expect empty_string to be empty + end test + + test "non-empty text is not empty": + store text as "hello" + // Verify it has content by checking length + expect text to have length 5 + end test + +end describe + +describe "Text properties": + + test "text has correct type": + store word as "hello" + expect word to be of type "Text" + end test + + test "text length is correct": + store word as "world" + expect word to have length 5 + end test + + test "empty string has zero length": + store empty_text as "" + expect empty_text to have length 0 + end test + +end describe + +describe "Text comparisons": + + test "identical strings are equal": + store a as "test" + store b as "test" + expect a to equal b + end test + + test "case-sensitive comparison": + store lower as "hello" + store upper as "Hello" + // These should NOT be equal (case sensitive) + // We can verify by checking they exist and have different properties + expect lower to have length 5 + expect upper to have length 5 + end test + +end describe diff --git a/TestPrograms/text_split_edge_cases.wfl b/TestPrograms/text_split_edge_cases.wfl index 86217683..ebff5287 100644 --- a/TestPrograms/text_split_edge_cases.wfl +++ b/TestPrograms/text_split_edge_cases.wfl @@ -1,8 +1,8 @@ # Test edge cases for string split functionality # Test 1: Empty string -store empty as "" -store empty_result as split empty by "," +store empty_str as "" +store empty_result as split empty_str by "," display "Empty string split count: " with length of empty_result # Test 2: String with no delimiter diff --git a/src/analyzer/tests.rs b/src/analyzer/tests.rs index 3e047e7d..4eaa56ae 100644 --- a/src/analyzer/tests.rs +++ b/src/analyzer/tests.rs @@ -20,7 +20,7 @@ fn test_unused_variable_detection() { #[test] fn test_unreachable_code_detection() { - let input = "define action called test:\n give back 10\n display \"This is unreachable\"\nend action"; + let input = "define action called my_action:\n give back 10\n display \"This is unreachable\"\nend action"; let tokens = lex_wfl_with_positions(input); let program = Parser::new(&tokens).parse().unwrap(); @@ -34,7 +34,7 @@ fn test_unreachable_code_detection() { #[test] fn test_shadowing_detection() { - let input = "store x as 10\ndefine action called test:\n store x as 20\n display x\nend action"; + let input = "store x as 10\ndefine action called my_action:\n store x as 20\n display x\nend action"; let tokens = lex_wfl_with_positions(input); let program = Parser::new(&tokens).parse().unwrap(); diff --git a/src/fixer/tests.rs b/src/fixer/tests.rs index 73afe73b..01b4c2a3 100644 --- a/src/fixer/tests.rs +++ b/src/fixer/tests.rs @@ -17,7 +17,7 @@ fn test_fix_variable_naming() { #[test] fn test_fix_indentation() { - let input = "define action called test:\ndisplay \"Hello\"\nend action"; + let input = "define action called my_test:\ndisplay \"Hello\"\nend action"; let tokens = lex_wfl_with_positions(input); let program = Parser::new(&tokens).parse().unwrap(); diff --git a/src/interpreter/assertion_helpers.rs b/src/interpreter/assertion_helpers.rs new file mode 100644 index 00000000..4bfc22c4 --- /dev/null +++ b/src/interpreter/assertion_helpers.rs @@ -0,0 +1,289 @@ +//! Assertion helper methods for the test framework + +use super::*; + +impl Interpreter { + /// Check if an assertion passes and return the expected value for error messages + pub(super) async fn check_assertion( + &self, + subject: &Value, + assertion: &Assertion, + env: Rc>, + ) -> Result<(bool, Option), RuntimeError> { + match assertion { + Assertion::Equal(expected_expr) | Assertion::Be(expected_expr) => { + let expected = self.evaluate_expression(expected_expr, env).await?; + Ok((values_equal(subject, &expected), Some(expected))) + } + Assertion::GreaterThan(expected_expr) => { + let expected = self.evaluate_expression(expected_expr, env).await?; + let result = match (subject, &expected) { + (Value::Number(a), Value::Number(b)) => a > b, + _ => false, + }; + Ok((result, Some(expected))) + } + Assertion::LessThan(expected_expr) => { + let expected = self.evaluate_expression(expected_expr, env).await?; + let result = match (subject, &expected) { + (Value::Number(a), Value::Number(b)) => a < b, + _ => false, + }; + Ok((result, Some(expected))) + } + Assertion::BeYes => Ok((is_truthy(subject), None)), + Assertion::BeNo => Ok((!is_truthy(subject), None)), + Assertion::Exist => Ok((!matches!(subject, Value::Null | Value::Nothing), None)), + Assertion::Contain(item_expr) => { + let item = self.evaluate_expression(item_expr, env).await?; + let result = match subject { + Value::List(list) => { + let list_ref = list.borrow(); + list_ref.iter().any(|v| values_equal(v, &item)) + } + Value::Text(text) => { + if let Value::Text(search) = &item { + text.contains(search.as_ref()) + } else { + false + } + } + _ => false, + }; + Ok((result, Some(item))) + } + Assertion::BeEmpty => { + let result = match subject { + Value::List(list) => list.borrow().is_empty(), + Value::Text(text) => text.is_empty(), + _ => false, + }; + Ok((result, None)) + } + Assertion::HaveLength(expected_expr) => { + let expected = self.evaluate_expression(expected_expr, env).await?; + if let Value::Number(expected_len) = expected { + let actual_len = match subject { + Value::List(list) => list.borrow().len() as f64, + Value::Text(text) => text.chars().count() as f64, // Use character count for text length + _ => return Ok((false, Some(Value::Number(expected_len)))), + }; + Ok(( + (actual_len - expected_len).abs() < f64::EPSILON, + Some(Value::Number(expected_len)), + )) + } else { + Ok((false, Some(expected))) + } + } + Assertion::BeOfType(type_name) => { + let actual_type = subject.type_name(); + Ok((actual_type.eq_ignore_ascii_case(type_name), None)) + } + } + } + + /// Create a helpful assertion failure message with actual values + pub(super) fn create_assertion_message_with_values( + &self, + assertion: &Assertion, + subject: &Value, + expected_value: Option<&Value>, + ) -> String { + match assertion { + Assertion::Equal(_) | Assertion::Be(_) => { + if let Some(expected) = expected_value { + format!( + "Expected {} to equal {}", + self.format_value_for_message(subject), + self.format_value_for_message(expected) + ) + } else { + format!( + "Expected value to equal expected value, but got {}", + self.format_value_for_message(subject) + ) + } + } + Assertion::GreaterThan(_) => { + if let Some(expected) = expected_value { + format!( + "Expected {} to be greater than {}, but it was not", + self.format_value_for_message(subject), + self.format_value_for_message(expected) + ) + } else { + format!( + "Expected {} to be greater than expected value", + self.format_value_for_message(subject) + ) + } + } + Assertion::LessThan(_) => { + if let Some(expected) = expected_value { + format!( + "Expected {} to be less than {}, but it was not", + self.format_value_for_message(subject), + self.format_value_for_message(expected) + ) + } else { + format!( + "Expected {} to be less than expected value", + self.format_value_for_message(subject) + ) + } + } + Assertion::BeYes => { + format!( + "Expected {} to be truthy, but it was falsy", + self.format_value_for_message(subject) + ) + } + Assertion::BeNo => { + format!( + "Expected {} to be falsy, but it was truthy", + self.format_value_for_message(subject) + ) + } + Assertion::Exist => { + format!( + "Expected value to exist, but got {}", + self.format_value_for_message(subject) + ) + } + Assertion::Contain(_) => { + if let Some(item) = expected_value { + format!( + "Expected {} to contain {}, but it did not", + self.format_value_for_message(subject), + self.format_value_for_message(item) + ) + } else { + format!( + "Expected {} to contain expected item", + self.format_value_for_message(subject) + ) + } + } + Assertion::BeEmpty => { + let actual_length = match subject { + Value::Text(s) => Some(s.chars().count()), + Value::List(list) => Some(list.borrow().len()), + _ => None, + }; + match actual_length { + Some(len) => format!( + "Expected {} to be empty, but it has {} item{}", + self.format_value_for_message(subject), + len, + if len == 1 { "" } else { "s" } + ), + None => format!( + "Expected {} to be empty, but it is not applicable for this type", + self.format_value_for_message(subject) + ), + } + } + Assertion::HaveLength(_) => { + if let Some(Value::Number(expected_len)) = expected_value { + let actual_length = match subject { + Value::Text(s) => Some(s.chars().count()), + Value::List(list) => Some(list.borrow().len()), + _ => None, + }; + match actual_length { + Some(len) => format!( + "Expected {} to have length {}, but its length is {}", + self.format_value_for_message(subject), + *expected_len as usize, + len + ), + None => format!( + "Expected {} to have length {}, but length is not applicable for this type", + self.format_value_for_message(subject), + *expected_len as usize + ), + } + } else { + format!( + "Expected {} to have expected length", + self.format_value_for_message(subject) + ) + } + } + Assertion::BeOfType(type_name) => { + format!( + "Expected type {}, but got {}", + type_name, + subject.type_name() + ) + } + } + } + + /// Format a value for display in error messages + fn format_value_for_message(&self, value: &Value) -> String { + match value { + Value::Number(n) => { + if n.fract() == 0.0 { + format!("{}", *n as i64) + } else { + format!("{}", n) + } + } + Value::Text(s) => format!("\"{}\"", s), + Value::Bool(b) => (if *b { "yes" } else { "no" }).to_string(), + Value::List(list) => { + let items = list.borrow(); + if items.is_empty() { + "empty list".to_string() + } else { + format!( + "list with {} item{}", + items.len(), + if items.len() == 1 { "" } else { "s" } + ) + } + } + Value::Null => "null".to_string(), + Value::Nothing => "nothing".to_string(), + _ => value.type_name().to_string(), + } + } +} + +/// Helper function to check if two values are equal +fn values_equal(a: &Value, b: &Value) -> bool { + match (a, b) { + (Value::Number(a), Value::Number(b)) => (a - b).abs() < f64::EPSILON, + (Value::Text(a), Value::Text(b)) => a == b, + (Value::Bool(a), Value::Bool(b)) => a == b, + (Value::Null, Value::Null) => true, + (Value::Nothing, Value::Nothing) => true, + (Value::List(a), Value::List(b)) => { + let a_ref = a.borrow(); + let b_ref = b.borrow(); + if a_ref.len() != b_ref.len() { + return false; + } + a_ref + .iter() + .zip(b_ref.iter()) + .all(|(x, y)| values_equal(x, y)) + } + _ => false, + } +} + +/// Helper function to check if a value is truthy +fn is_truthy(value: &Value) -> bool { + match value { + Value::Bool(b) => *b, + Value::Null => false, + Value::Nothing => false, + Value::Number(n) => *n != 0.0, + Value::Text(s) => !s.is_empty(), + Value::List(l) => !l.borrow().is_empty(), + _ => true, + } +} diff --git a/src/interpreter/mod.rs b/src/interpreter/mod.rs index b96e9350..00d1499e 100644 --- a/src/interpreter/mod.rs +++ b/src/interpreter/mod.rs @@ -1,4 +1,5 @@ #![allow(clippy::await_holding_refcell_ref)] +mod assertion_helpers; pub mod bounded_buffer; pub mod command_sanitizer; pub mod control_flow; @@ -39,7 +40,7 @@ use crate::exec_var_declare; #[cfg(debug_assertions)] use crate::logging::IndentGuard; use crate::parser::ast::{ - Expression, FileOpenMode, Literal, Operator, Program, Statement, UnaryOperator, + Assertion, Expression, FileOpenMode, Literal, Operator, Program, Statement, UnaryOperator, }; use crate::pattern::CompiledPattern; use crate::stdlib; @@ -260,6 +261,14 @@ fn stmt_type(stmt: &Statement) -> String { "StopAcceptingConnectionsStatement".to_string() } Statement::CloseServerStatement { .. } => "CloseServerStatement".to_string(), + // Test framework statements + Statement::DescribeBlock { description, .. } => { + format!("DescribeBlock '{description}'") + } + Statement::TestBlock { description, .. } => { + format!("TestBlock '{description}'") + } + Statement::ExpectStatement { .. } => "ExpectStatement".to_string(), } } @@ -337,6 +346,29 @@ pub struct Interpreter { config: Arc, // Configuration for security and other settings current_source_file: RefCell>, // Currently executing source file (for path resolution) loading_stack: RefCell>, // Stack of currently loading files (for circular dependency detection) + // Test execution state + test_mode: RefCell, + test_results: RefCell, + current_describe_stack: RefCell>, + current_test_name: RefCell>, +} + +// Test framework data structures +#[derive(Debug, Default, Clone)] +pub struct TestResults { + pub total_tests: usize, + pub passed_tests: usize, + pub failed_tests: usize, + pub failures: Vec, +} + +#[derive(Debug, Clone)] +pub struct TestFailure { + pub describe_context: Vec, + pub test_name: String, + pub assertion_message: String, + pub line: usize, + pub column: usize, } // Process handle for managing subprocess state @@ -1147,6 +1179,11 @@ impl Interpreter { config, current_source_file: RefCell::new(None), // No source file initially loading_stack: RefCell::new(Vec::new()), // Empty loading stack + // Test execution state + test_mode: RefCell::new(false), + test_results: RefCell::new(TestResults::default()), + current_describe_stack: RefCell::new(Vec::new()), + current_test_name: RefCell::new(None), } } @@ -1170,6 +1207,16 @@ impl Interpreter { *self.current_source_file.borrow_mut() = Some(path); } + /// Enable or disable test mode + pub fn set_test_mode(&self, enabled: bool) { + *self.test_mode.borrow_mut() = enabled; + } + + /// Get test results after running in test mode + pub fn get_test_results(&self) -> TestResults { + self.test_results.borrow().clone() + } + /// Extract variables from the environment for module analyzer /// Returns a HashMap of variable names to inferred types fn extract_parent_variables( @@ -1706,6 +1753,10 @@ impl Interpreter { Statement::ReadProcessOutputStatement { line, column, .. } => (*line, *column), Statement::KillProcessStatement { line, column, .. } => (*line, *column), Statement::WaitForProcessStatement { line, column, .. } => (*line, *column), + // Test framework statements + Statement::DescribeBlock { line, column, .. } => (*line, *column), + Statement::TestBlock { line, column, .. } => (*line, *column), + Statement::ExpectStatement { line, column, .. } => (*line, *column), }; let result = match stmt { @@ -4854,6 +4905,180 @@ impl Interpreter { .map_err(|e| RuntimeError::new(e, *line, *column))?; } + Ok((Value::Null, ControlFlow::None)) + } + // Test framework statements + Statement::DescribeBlock { + description, + setup, + teardown, + tests, + line, + column, + } => { + if !*self.test_mode.borrow() { + return Err(RuntimeError::new( + "describe blocks can only be used in test mode (run with --test flag)" + .to_string(), + *line, + *column, + )); + } + + // Push describe context + self.current_describe_stack + .borrow_mut() + .push(description.clone()); + + // Create describe-level environment for setup/teardown sharing + // This allows tests to access setup variables while remaining isolated from each other + let describe_env = Environment::new_child_env(&env); + + // Run setup if present (runs in describe environment) + if let Some(setup_stmts) = setup { + for stmt in setup_stmts { + Box::pin(self._execute_statement(stmt, describe_env.clone())).await?; + } + } + + // Execute all tests (each gets a child of describe_env for isolation) + for test in tests { + Box::pin(self._execute_statement(test, describe_env.clone())).await?; + } + + // Run teardown if present (runs in describe environment) + if let Some(teardown_stmts) = teardown { + for stmt in teardown_stmts { + Box::pin(self._execute_statement(stmt, describe_env.clone())).await?; + } + } + + // Pop describe context + self.current_describe_stack.borrow_mut().pop(); + + Ok((Value::Null, ControlFlow::None)) + } + Statement::TestBlock { + description, + body, + line, + column, + } => { + if !*self.test_mode.borrow() { + return Err(RuntimeError::new( + "test blocks can only be used in test mode (run with --test flag)" + .to_string(), + *line, + *column, + )); + } + + // Set current test name for failure tracking + *self.current_test_name.borrow_mut() = Some(description.clone()); + + // Increment test count + self.test_results.borrow_mut().total_tests += 1; + + // Create isolated environment for test (child of describe env) + // Using isolated mode prevents tests from mutating setup variables, + // ensuring each test gets a fresh copy for true isolation + let test_env = Environment::new_isolated_child_env(&env); + + // Execute test body and catch assertion failures + let mut test_passed = true; + + for stmt in body { + match Box::pin(self._execute_statement(stmt, test_env.clone())).await { + Ok(_) => {} + Err(e) => { + test_passed = false; + + // Only record failure if not already recorded by expect statement + // Check if this is an assertion failure (which has already been recorded) + let error_msg = e.to_string(); + if !error_msg.starts_with("Assertion failed:") { + // This is a non-assertion error (e.g., runtime error in test code) + let context = self.current_describe_stack.borrow().clone(); + let failure = TestFailure { + describe_context: context, + test_name: description.clone(), + assertion_message: error_msg, + line: *line, + column: *column, + }; + self.test_results.borrow_mut().failures.push(failure); + } + + // Don't propagate the error - continue running other tests + break; + } + } + } + + if test_passed { + self.test_results.borrow_mut().passed_tests += 1; + } + + // Clear current test name + *self.current_test_name.borrow_mut() = None; + + Ok((Value::Null, ControlFlow::None)) + } + Statement::ExpectStatement { + subject, + assertion, + line, + column, + } => { + if !*self.test_mode.borrow() { + return Err(RuntimeError::new( + "expect statements can only be used in test mode (run with --test flag)" + .to_string(), + *line, + *column, + )); + } + + // Evaluate subject expression + let subject_value = self.evaluate_expression(subject, env.clone()).await?; + + // Check assertion + let (passed, expected_value) = self + .check_assertion(&subject_value, assertion, env.clone()) + .await?; + + if !passed { + // Record failure with proper test name tracking + let message = self.create_assertion_message_with_values( + assertion, + &subject_value, + expected_value.as_ref(), + ); + let context = self.current_describe_stack.borrow().clone(); + let test_name = self + .current_test_name + .borrow() + .clone() + .unwrap_or_else(|| "unknown test".to_string()); + + let failure = TestFailure { + describe_context: context, + test_name, + assertion_message: message.clone(), + line: *line, + column: *column, + }; + + self.test_results.borrow_mut().failures.push(failure); + self.test_results.borrow_mut().failed_tests += 1; + + return Err(RuntimeError::new( + format!("Assertion failed: {message}"), + *line, + *column, + )); + } + Ok((Value::Null, ControlFlow::None)) } }; diff --git a/src/lexer/token.rs b/src/lexer/token.rs index 60e7ed45..b4d028c7 100644 --- a/src/lexer/token.rs +++ b/src/lexer/token.rs @@ -359,6 +359,27 @@ pub enum Token { KeywordMilliseconds, #[token("formatted")] KeywordFormatted, + // Test framework keywords + #[token("describe")] + KeywordDescribe, + #[token("test")] + KeywordTest, + #[token("expect")] + KeywordExpect, + #[token("setup")] + KeywordSetup, + #[token("teardown")] + KeywordTeardown, + #[token("be")] + KeywordBe, + #[token("have")] + KeywordHave, + #[token("exist")] + KeywordExist, + #[token("contain")] + KeywordContain, + #[token("empty")] + KeywordEmpty, #[token(":")] Colon, @@ -567,6 +588,14 @@ impl Token { | Token::KeywordPublic | Token::KeywordPrivate | Token::KeywordConstant + // Test framework keywords + | Token::KeywordDescribe + | Token::KeywordTest + | Token::KeywordExpect + | Token::KeywordSetup + | Token::KeywordTeardown + | Token::KeywordBe + | Token::KeywordEmpty ) } @@ -576,6 +605,10 @@ impl Token { matches!( self, Token::KeywordCount // Only reserved in 'count from X to Y' context + // Test framework keywords that are contextual + | Token::KeywordHave // Only reserved in test assertions + | Token::KeywordExist // Only reserved in test assertions + | Token::KeywordContain // Only reserved in test assertions | Token::KeywordPattern // Only reserved in pattern matching context | Token::KeywordFiles // Only reserved in file operations context | Token::KeywordExtension diff --git a/src/lib.rs b/src/lib.rs index 9f181014..cc394964 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -57,7 +57,7 @@ pub fn init_loggers(log_path: &Path, script_dir: &Path) { } } -pub use interpreter::Interpreter; +pub use interpreter::{Interpreter, TestFailure, TestResults}; pub fn add(left: u64, right: u64) -> u64 { left + right diff --git a/src/main.rs b/src/main.rs index 9c3c3984..7c2591f7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -39,6 +39,7 @@ fn print_help() { println!(" --dump-env Dump the current environment details for troubleshooting"); println!(" --output Specify an output file for the environment dump"); println!(" --time Measure and display execution time"); + println!(" --test Run file in test mode"); println!(); println!("Configuration Maintenance:"); println!(" --configCheck Check configuration files for issues"); @@ -97,6 +98,7 @@ async fn main() -> io::Result<()> { let mut dump_env_mode = false; let mut output_path = None; let mut time_mode = false; + let mut test_mode = false; let mut file_path = String::new(); let mut i = 1; @@ -274,6 +276,16 @@ async fn main() -> io::Result<()> { time_mode = true; i += 1; } + "--test" => { + if lint_mode || analyze_mode || fix_mode || config_check_mode || config_fix_mode { + eprintln!( + "Error: --test cannot be combined with --lint, --analyze, --fix, --configCheck, or --configFix" + ); + process::exit(2); + } + test_mode = true; + i += 1; + } "--version" | "-v" => { println!("WebFirst Language (WFL) version {}", wfl::version::VERSION); return Ok(()); @@ -821,6 +833,7 @@ async fn main() -> io::Result<()> { let mut interpreter = Interpreter::with_timeout(config.timeout_seconds); interpreter.set_step_mode(step_mode); // Set step mode from CLI flag + interpreter.set_test_mode(test_mode); // Set test mode from CLI flag interpreter.set_script_args(script_args); // Pass script arguments interpreter.set_source_file(std::path::PathBuf::from(&file_path)); // Set source file for module resolution @@ -870,6 +883,43 @@ async fn main() -> io::Result<()> { info!("Program executed successfully"); } exec_trace!("Execution completed successfully. Result: {:?}", _result); + + // Handle test mode results + if test_mode { + let results = interpreter.get_test_results(); + + println!("\n{}", "=".repeat(60)); + println!("Test Results"); + println!("{}", "=".repeat(60)); + println!("Total: {}", results.total_tests); + println!("Passed: {} ✓", results.passed_tests); + println!("Failed: {} ✗", results.failed_tests); + + if !results.failures.is_empty() { + println!("\n{}", "─".repeat(60)); + println!("Failures:"); + println!("{}", "─".repeat(60)); + + for (i, failure) in results.failures.iter().enumerate() { + println!("\n{}. {}", i + 1, failure.test_name); + if !failure.describe_context.is_empty() { + println!( + " Context: {}", + failure.describe_context.join(" > ") + ); + } + println!(" {}", failure.assertion_message); + println!(" at line {}", failure.line); + } + } + + println!("\n{}", "=".repeat(60)); + + // Exit with error code if tests failed + if results.failed_tests > 0 { + process::exit(1); + } + } } Err(errors) => { if config.logging_enabled { diff --git a/src/parser/ast.rs b/src/parser/ast.rs index a43efa0d..7d5a9628 100644 --- a/src/parser/ast.rs +++ b/src/parser/ast.rs @@ -465,6 +465,43 @@ pub enum Statement { line: usize, column: usize, }, + // Test framework statements + DescribeBlock { + description: String, + setup: Option>, + teardown: Option>, + tests: Vec, // Contains TestBlock and nested DescribeBlock + line: usize, + column: usize, + }, + TestBlock { + description: String, + body: Vec, + line: usize, + column: usize, + }, + ExpectStatement { + subject: Expression, + assertion: Assertion, + line: usize, + column: usize, + }, +} + +/// Represents different types of assertions in test expectations +#[derive(Debug, Clone, PartialEq)] +pub enum Assertion { + Equal(Expression), + Be(Expression), // Synonym for Equal + GreaterThan(Expression), + LessThan(Expression), + BeYes, // Truthy check + BeNo, // Falsy check + Exist, + Contain(Expression), // List/text contains + BeEmpty, + HaveLength(Expression), + BeOfType(String), // Type check } #[derive(Debug, Clone, PartialEq)] diff --git a/src/parser/mod.rs b/src/parser/mod.rs index cc515495..8070d630 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -13,7 +13,8 @@ pub use cursor::Cursor; // Re-export Cursor publicly for doctests use expr::ExprParser; use stmt::{ ActionParser, CollectionParser, ContainerParser, ControlFlowParser, ErrorHandlingParser, - IoParser, ModuleParser, PatternParser, ProcessParser, StmtParser, VariableParser, WebParser, + IoParser, ModuleParser, PatternParser, ProcessParser, StmtParser, TestingParser, + VariableParser, WebParser, }; pub struct Parser<'a> { @@ -530,6 +531,10 @@ impl<'a> StmtParser<'a> for Parser<'a> { self.parse_expression_statement() } } + // Test framework keywords + Token::KeywordDescribe => self.parse_describe_block(), + Token::KeywordTest => self.parse_test_block(), + Token::KeywordExpect => self.parse_expect_statement(), _ => self.parse_expression_statement(), } } else { diff --git a/src/parser/stmt/mod.rs b/src/parser/stmt/mod.rs index f5717054..06687ae0 100644 --- a/src/parser/stmt/mod.rs +++ b/src/parser/stmt/mod.rs @@ -14,6 +14,7 @@ mod io; mod module; mod patterns; mod processes; +mod testing; mod variables; mod web; @@ -26,6 +27,7 @@ pub(crate) use io::IoParser; pub(crate) use module::ModuleParser; pub(crate) use patterns::PatternParser; pub(crate) use processes::ProcessParser; +pub(crate) use testing::TestingParser; pub(crate) use variables::VariableParser; pub(crate) use web::WebParser; @@ -51,6 +53,7 @@ pub(crate) trait StmtParser<'a>: + PatternParser<'a> + ContainerParser<'a> + ModuleParser<'a> + + TestingParser<'a> { /// Parses a statement by dispatching to the appropriate parser based on the current token. /// diff --git a/src/parser/stmt/testing.rs b/src/parser/stmt/testing.rs new file mode 100644 index 00000000..f17d3ef6 --- /dev/null +++ b/src/parser/stmt/testing.rs @@ -0,0 +1,468 @@ +//! Test framework statement parsing + +use super::super::{ParseError, Parser, Statement}; +use super::StmtParser; +use crate::lexer::token::Token; +use crate::parser::ast::Assertion; +use crate::parser::expr::ExprParser; + +pub(crate) trait TestingParser<'a>: ExprParser<'a> { + fn parse_describe_block(&mut self) -> Result; + fn parse_test_block(&mut self) -> Result; + fn parse_expect_statement(&mut self) -> Result; +} + +impl<'a> TestingParser<'a> for Parser<'a> { + fn parse_describe_block(&mut self) -> Result { + // Parse: describe "Description": + // [setup: ... end setup] + // test "...": ... end test + // [teardown: ... end teardown] + // end describe + + // Capture position + let describe_token = self.cursor.peek().ok_or_else(|| { + ParseError::from_span( + "Unexpected end of input while parsing describe block".to_string(), + self.cursor.current_span(), + self.cursor.current_line(), + 1, + ) + })?; + let (line, column) = (describe_token.line, describe_token.column); + + // Consume 'describe' token + self.expect_token(Token::KeywordDescribe, "Expected 'describe' keyword")?; + + // Parse description string + let description = if let Some(token) = self.cursor.peek() { + if let Token::StringLiteral(s) = &token.token { + let desc = s.clone(); + self.cursor.bump(); // Consume the string + desc + } else { + return Err(ParseError::from_token( + "Expected string literal after 'describe'".to_string(), + token, + )); + } + } else { + return Err(ParseError::from_span( + "Expected description string after 'describe'".to_string(), + self.cursor.current_span(), + self.cursor.current_line(), + 1, + )); + }; + + // Expect colon + self.expect_token(Token::Colon, "Expected ':' after describe description")?; + + // Consume newline after colon + self.skip_eol(); + + // Parse body (setup, tests, teardown) + let mut setup: Option> = None; + let mut teardown: Option> = None; + let mut tests: Vec = Vec::new(); + + loop { + self.skip_eol(); + + let token = self.cursor.peek(); + match token { + Some(t) => match &t.token { + Token::KeywordSetup => { + // Parse setup block + let setup_token = t.clone(); + self.cursor.bump(); // Consume 'setup' + + // Check for duplicate setup block + if setup.is_some() { + return Err(ParseError::from_token( + "Duplicate 'setup' block found. Only one setup block is allowed per describe block".to_string(), + &setup_token, + )); + } + + self.expect_token(Token::Colon, "Expected ':' after 'setup'")?; + self.skip_eol(); + + let mut setup_stmts = Vec::new(); + loop { + self.skip_eol(); + if let Some(token) = self.cursor.peek() + && matches!(token.token, Token::KeywordEnd) + { + break; + } + setup_stmts.push(self.parse_statement()?); + } + + self.expect_token( + Token::KeywordEnd, + "Expected 'end' to close setup block", + )?; + self.expect_token( + Token::KeywordSetup, + "Expected 'setup' after 'end' in setup block", + )?; + setup = Some(setup_stmts); + } + Token::KeywordTeardown => { + // Parse teardown block + let teardown_token = t.clone(); + self.cursor.bump(); // Consume 'teardown' + + // Check for duplicate teardown block + if teardown.is_some() { + return Err(ParseError::from_token( + "Duplicate 'teardown' block found. Only one teardown block is allowed per describe block".to_string(), + &teardown_token, + )); + } + + self.expect_token(Token::Colon, "Expected ':' after 'teardown'")?; + self.skip_eol(); + + let mut teardown_stmts = Vec::new(); + loop { + self.skip_eol(); + if let Some(token) = self.cursor.peek() + && matches!(token.token, Token::KeywordEnd) + { + break; + } + teardown_stmts.push(self.parse_statement()?); + } + + self.expect_token( + Token::KeywordEnd, + "Expected 'end' to close teardown block", + )?; + self.expect_token( + Token::KeywordTeardown, + "Expected 'teardown' after 'end' in teardown block", + )?; + teardown = Some(teardown_stmts); + } + Token::KeywordTest => { + // Parse test block + tests.push(self.parse_test_block()?); + } + Token::KeywordDescribe => { + // Nested describe block + tests.push(self.parse_describe_block()?); + } + Token::KeywordEnd => { + // End of describe block + break; + } + _ => { + return Err(ParseError::from_token( + "Expected 'setup', 'test', 'teardown', or 'end' in describe block" + .to_string(), + t, + )); + } + }, + None => { + return Err(ParseError::from_span( + "Unexpected end of input in describe block".to_string(), + self.cursor.current_span(), + self.cursor.current_line(), + 1, + )); + } + } + } + + // Expect 'end describe' + self.expect_token(Token::KeywordEnd, "Expected 'end' to close describe block")?; + self.expect_token( + Token::KeywordDescribe, + "Expected 'describe' after 'end' in describe block", + )?; + + Ok(Statement::DescribeBlock { + description, + setup, + teardown, + tests, + line, + column, + }) + } + + fn parse_test_block(&mut self) -> Result { + // Parse: test "description": + // [statements] + // end test + + // Capture position + let test_token = self.cursor.peek().ok_or_else(|| { + ParseError::from_span( + "Unexpected end of input while parsing test block".to_string(), + self.cursor.current_span(), + self.cursor.current_line(), + 1, + ) + })?; + let (line, column) = (test_token.line, test_token.column); + + // Consume 'test' token + self.expect_token(Token::KeywordTest, "Expected 'test' keyword")?; + + // Parse description string + let description = if let Some(token) = self.cursor.peek() { + if let Token::StringLiteral(s) = &token.token { + let desc = s.clone(); + self.cursor.bump(); // Consume the string + desc + } else { + return Err(ParseError::from_token( + "Expected string literal after 'test'".to_string(), + token, + )); + } + } else { + return Err(ParseError::from_span( + "Expected description string after 'test'".to_string(), + self.cursor.current_span(), + self.cursor.current_line(), + 1, + )); + }; + + // Expect colon + self.expect_token(Token::Colon, "Expected ':' after test description")?; + + // Consume newline after colon + self.skip_eol(); + + // Parse body statements + let mut body = Vec::new(); + loop { + self.skip_eol(); + + if let Some(token) = self.cursor.peek() + && matches!(token.token, Token::KeywordEnd) + { + break; + } + + body.push(self.parse_statement()?); + } + + // Expect 'end test' + self.expect_token(Token::KeywordEnd, "Expected 'end' to close test block")?; + self.expect_token( + Token::KeywordTest, + "Expected 'test' after 'end' in test block", + )?; + + Ok(Statement::TestBlock { + description, + body, + line, + column, + }) + } + + fn parse_expect_statement(&mut self) -> Result { + // Parse: expect to + // + // Examples: + // expect result to equal 5 + // expect list to contain "item" + // expect value to be greater than 10 + + // Capture position + let expect_token = self.cursor.peek().ok_or_else(|| { + ParseError::from_span( + "Unexpected end of input while parsing expect statement".to_string(), + self.cursor.current_span(), + self.cursor.current_line(), + 1, + ) + })?; + let (line, column) = (expect_token.line, expect_token.column); + + // Consume 'expect' token + self.expect_token(Token::KeywordExpect, "Expected 'expect' keyword")?; + + // Parse subject expression + let subject = self.parse_expression()?; + + // Expect 'to' keyword + self.expect_token(Token::KeywordTo, "Expected 'to' after expect subject")?; + + // Parse assertion type based on next token + let assertion = self.parse_assertion()?; + + Ok(Statement::ExpectStatement { + subject, + assertion, + line, + column, + }) + } +} + +impl<'a> Parser<'a> { + /// Helper method to parse assertion types for expect statements + fn parse_assertion(&mut self) -> Result { + let token = self.cursor.peek().ok_or_else(|| { + ParseError::from_span( + "Expected assertion type after 'to'".to_string(), + self.cursor.current_span(), + self.cursor.current_line(), + 1, + ) + })?; + + match &token.token { + Token::KeywordEqual => { + self.cursor.bump(); // Consume 'equal' + let value = self.parse_expression()?; + Ok(Assertion::Equal(value)) + } + Token::KeywordBe => { + self.cursor.bump(); // Consume 'be' + + // Check what comes after 'be' + if let Some(next_token) = self.cursor.peek() { + match &next_token.token { + Token::BooleanLiteral(true) => { + self.cursor.bump(); + Ok(Assertion::BeYes) + } + Token::BooleanLiteral(false) => { + self.cursor.bump(); + Ok(Assertion::BeNo) + } + Token::KeywordGreater => { + self.cursor.bump(); // Consume 'greater' + self.expect_token( + Token::KeywordThan, + "Expected 'than' after 'greater'", + )?; + let value = self.parse_expression()?; + Ok(Assertion::GreaterThan(value)) + } + Token::KeywordLess => { + self.cursor.bump(); // Consume 'less' + self.expect_token(Token::KeywordThan, "Expected 'than' after 'less'")?; + let value = self.parse_expression()?; + Ok(Assertion::LessThan(value)) + } + Token::KeywordEmpty => { + self.cursor.bump(); // Consume 'empty' + Ok(Assertion::BeEmpty) + } + Token::KeywordOf => { + self.cursor.bump(); // Consume 'of' + + // Check if next token is "type" identifier + if let Some(token) = self.cursor.peek() + && let Token::Identifier(id) = &token.token + && id == "type" + { + self.cursor.bump(); // Consume 'type' + + // Expect a string literal for the type name + if let Some(token) = self.cursor.peek() { + if let Token::StringLiteral(type_name) = &token.token { + let tn = type_name.clone(); + self.cursor.bump(); + return Ok(Assertion::BeOfType(tn)); + } else { + return Err(ParseError::from_token( + "Expected type name as string literal after 'be of type'".to_string(), + token, + )); + } + } else { + return Err(ParseError::from_span( + "Expected type name as string literal after 'be of type'" + .to_string(), + self.cursor.current_span(), + self.cursor.current_line(), + 1, + )); + } + } + + Err(ParseError::from_span( + "Expected 'type' after 'of'".to_string(), + self.cursor.current_span(), + self.cursor.current_line(), + 1, + )) + } + _ => { + // Default: treat as 'be' synonym for 'equal' + let value = self.parse_expression()?; + Ok(Assertion::Be(value)) + } + } + } else { + Err(ParseError::from_span( + "Expected value or condition after 'be'".to_string(), + self.cursor.current_span(), + self.cursor.current_line(), + 1, + )) + } + } + Token::KeywordGreater => { + self.cursor.bump(); // Consume 'greater' + self.expect_token(Token::KeywordThan, "Expected 'than' after 'greater'")?; + let value = self.parse_expression()?; + Ok(Assertion::GreaterThan(value)) + } + Token::KeywordLess => { + self.cursor.bump(); // Consume 'less' + self.expect_token(Token::KeywordThan, "Expected 'than' after 'less'")?; + let value = self.parse_expression()?; + Ok(Assertion::LessThan(value)) + } + Token::KeywordContain => { + self.cursor.bump(); // Consume 'contain' + let value = self.parse_expression()?; + Ok(Assertion::Contain(value)) + } + Token::KeywordExist => { + self.cursor.bump(); // Consume 'exist' + Ok(Assertion::Exist) + } + Token::KeywordHave => { + self.cursor.bump(); // Consume 'have' + + // Check if next token is "length" identifier + if let Some(token) = self.cursor.peek() + && let Token::Identifier(id) = &token.token + && id == "length" + { + self.cursor.bump(); // Consume 'length' + let value = self.parse_expression()?; + return Ok(Assertion::HaveLength(value)); + } + + Err(ParseError::from_span( + "Expected 'length' after 'have'".to_string(), + self.cursor.current_span(), + self.cursor.current_line(), + 1, + )) + } + _ => Err(ParseError::from_token( + format!( + "Unknown assertion type: expected 'equal', 'be', 'contain', etc. Got: {:?}", + token.token + ), + token, + )), + } + } +} diff --git a/src/typechecker/mod.rs b/src/typechecker/mod.rs index 4f61785d..a7f807d8 100644 --- a/src/typechecker/mod.rs +++ b/src/typechecker/mod.rs @@ -1516,6 +1516,162 @@ impl TypeChecker { // TODO: Add type checking for server expression // For now, just accept any type } + // Test framework statements + Statement::DescribeBlock { + description: _description, + setup, + teardown, + tests, + line: _line, + column: _column, + } => { + // Type check setup block if present + if let Some(setup_stmts) = setup { + for stmt in setup_stmts { + self.check_statement_types(stmt); + } + } + + // Type check all test blocks + for test in tests { + self.check_statement_types(test); + } + + // Type check teardown block if present + if let Some(teardown_stmts) = teardown { + for stmt in teardown_stmts { + self.check_statement_types(stmt); + } + } + } + Statement::TestBlock { + description: _description, + body, + line: _line, + column: _column, + } => { + // Type check test body + for stmt in body { + self.check_statement_types(stmt); + } + } + Statement::ExpectStatement { + subject, + assertion, + line: _line, + column: _column, + } => { + // Type check the subject expression + let subject_type = self.infer_expression_type(subject); + + // Perform compile-time type checking for assertions where possible + use crate::parser::ast::Assertion; + match assertion { + Assertion::Equal(expr) | Assertion::Be(expr) => { + // Type check the expected value + self.infer_expression_type(expr); + } + Assertion::GreaterThan(expr) | Assertion::LessThan(expr) => { + // Check that subject is a number + if subject_type != Type::Number + && subject_type != Type::Unknown + && subject_type != Type::Error + { + self.type_error( + "Comparison assertions require numeric types".to_string(), + Some(Type::Number), + Some(subject_type.clone()), + *_line, + *_column, + ); + } + // Type check the comparison value + let expr_type = self.infer_expression_type(expr); + if expr_type != Type::Number + && expr_type != Type::Unknown + && expr_type != Type::Error + { + self.type_error( + "Comparison value must be numeric".to_string(), + Some(Type::Number), + Some(expr_type), + *_line, + *_column, + ); + } + } + Assertion::BeYes | Assertion::BeNo => { + // Truthiness checks work on any type, no validation needed + } + Assertion::Exist => { + // Existence checks work on any type + } + Assertion::Contain(expr) => { + // Check that subject is a list or text + if !matches!( + subject_type, + Type::List(_) | Type::Text | Type::Unknown | Type::Error + ) { + self.type_error( + "contain assertion requires List or Text type".to_string(), + None, + Some(subject_type.clone()), + *_line, + *_column, + ); + } + // Type check the item expression + self.infer_expression_type(expr); + } + Assertion::BeEmpty => { + // Check that subject is a list or text + if !matches!( + subject_type, + Type::List(_) | Type::Text | Type::Unknown | Type::Error + ) { + self.type_error( + "be empty assertion requires List or Text type".to_string(), + None, + Some(subject_type.clone()), + *_line, + *_column, + ); + } + } + Assertion::HaveLength(expr) => { + // Check that subject is a list or text + if !matches!( + subject_type, + Type::List(_) | Type::Text | Type::Unknown | Type::Error + ) { + self.type_error( + "have length assertion requires List or Text type".to_string(), + None, + Some(subject_type.clone()), + *_line, + *_column, + ); + } + // Type check the length value (should be number) + let length_type = self.infer_expression_type(expr); + if length_type != Type::Number + && length_type != Type::Unknown + && length_type != Type::Error + { + self.type_error( + "Length value must be numeric".to_string(), + Some(Type::Number), + Some(length_type), + *_line, + *_column, + ); + } + } + Assertion::BeOfType(_type_name) => { + // Type name is validated at runtime + } + } + } } }