|
|
+++
|
|
|
title = "§17 Testing the Compiler"
|
|
|
priority = 5
|
|
|
status = "todo"
|
|
|
ticket_type = "task"
|
|
|
dependencies = []
|
|
|
+++
|
|
|
|
|
|
## §17 Testing the Compiler — Stub to fill
|
|
|
|
|
|
File: `edu/src/lisp-compiler.md`, section `### 17. Testing the Compiler`
|
|
|
|
|
|
Replace the stub line with full content. Target 700–900 words. Add unit tests per stage and integration tests that compile MiniLisp → C → binary → run and assert on output.
|
|
|
|
|
|
## Learning objectives
|
|
|
|
|
|
- Know which unit tests to write for each compiler stage
|
|
|
- Write integration tests that invoke `cc` and run the result
|
|
|
- Build a test corpus covering all language features
|
|
|
- Understand how to test error paths (invalid programs should produce errors, not panics)
|
|
|
|
|
|
## Content to write
|
|
|
|
|
|
### Testing strategy
|
|
|
|
|
|
Three levels:
|
|
|
|
|
|
1. **Unit tests** per module: already written in §8–15 for individual parsers and code-gen functions. This section consolidates and supplements them.
|
|
|
2. **`compile()` round-trip tests**: call `compile(source)` and assert on the C output string (does it contain expected fragments?).
|
|
|
3. **Integration tests**: compile → write to temp file → `cc` → run → assert stdout.
|
|
|
|
|
|
### Additional unit tests
|
|
|
|
|
|
Supplement the tests from earlier sections with edge cases:
|
|
|
|
|
|
**Parser edge cases:**
|
|
|
```rust
|
|
|
#[test] fn empty_program() { assert_eq!(parse("").unwrap(), vec![]); }
|
|
|
#[test] fn only_comments() { assert!(parse("; hello\n; world").unwrap().is_empty()); }
|
|
|
#[test] fn nested_parens() { parse("(f (g (h 1)))").unwrap(); }
|
|
|
#[test] fn string_escapes() { assert_eq!(parse(r#""\n""#).unwrap()[0], Expr::Str("\n".into())); }
|
|
|
```
|
|
|
|
|
|
**Analyser error cases:**
|
|
|
```rust
|
|
|
#[test] fn undefined_rejects() { assert!(analyse(parse("x").unwrap()).is_err()); }
|
|
|
#[test] fn bad_arity_rejects() { assert!(analyse(parse("(+ 1 2 3)").unwrap()).is_err()); }
|
|
|
#[test] fn valid_mutual_rec() { assert!(analyse(parse(MUTUAL_REC_SRC).unwrap()).is_ok()); }
|
|
|
```
|
|
|
|
|
|
### Integration tests
|
|
|
|
|
|
Write an integration test helper that:
|
|
|
1. Calls `compile(source)` to get C source
|
|
|
2. Writes C source to a temp file (`/tmp/ml_test_{N}.c`)
|
|
|
3. Invokes `cc -o /tmp/ml_test_{N} /tmp/ml_test_{N}.c`
|
|
|
4. Runs the binary and captures stdout
|
|
|
5. Asserts stdout matches expected output
|
|
|
6. Cleans up temp files
|
|
|
|
|
|
```rust
|
|
|
#[cfg(test)]
|
|
|
fn run_minilisp(source: &str) -> String {
|
|
|
use std::process::Command;
|
|
|
let c_source = crate::compile(source).expect("compile failed");
|
|
|
let c_path = "/tmp/ml_test.c";
|
|
|
let bin_path = "/tmp/ml_test";
|
|
|
std::fs::write(c_path, &c_source).unwrap();
|
|
|
let cc_status = Command::new("cc")
|
|
|
.args([c_path, "-o", bin_path])
|
|
|
.status()
|
|
|
.expect("cc not found");
|
|
|
assert!(cc_status.success(), "C compilation failed:\n{}", c_source);
|
|
|
let output = Command::new(bin_path).output().expect("run failed");
|
|
|
String::from_utf8(output.stdout).unwrap()
|
|
|
}
|
|
|
```
|
|
|
|
|
|
### Test corpus
|
|
|
|
|
|
Write integration tests for each major language feature:
|
|
|
|
|
|
```rust
|
|
|
#[test]
|
|
|
fn test_display_integer() {
|
|
|
assert_eq!(run_minilisp("(display 42)(newline)"), "42\n");
|
|
|
}
|
|
|
|
|
|
#[test]
|
|
|
fn test_arithmetic() {
|
|
|
assert_eq!(run_minilisp("(display (+ (* 3 4) (- 10 5)))(newline)"), "17\n");
|
|
|
}
|
|
|
|
|
|
#[test]
|
|
|
fn test_boolean_if() {
|
|
|
let src = "(display (if #t 1 2))(newline)";
|
|
|
assert_eq!(run_minilisp(src), "1\n");
|
|
|
}
|
|
|
|
|
|
#[test]
|
|
|
fn test_recursive_factorial() {
|
|
|
let src = r#"
|
|
|
(define (factorial n)
|
|
|
(if (= n 0) 1 (* n (factorial (- n 1)))))
|
|
|
(display (factorial 10))(newline)
|
|
|
"#;
|
|
|
assert_eq!(run_minilisp(src), "3628800\n");
|
|
|
}
|
|
|
|
|
|
#[test]
|
|
|
fn test_let() {
|
|
|
let src = "(display (let ((x 3) (y 4)) (+ x y)))(newline)";
|
|
|
assert_eq!(run_minilisp(src), "7\n");
|
|
|
}
|
|
|
|
|
|
#[test]
|
|
|
fn test_mutual_recursion() {
|
|
|
let src = r#"
|
|
|
(define (even? n) (if (= n 0) #t (odd? (- n 1))))
|
|
|
(define (odd? n) (if (= n 0) #f (even? (- n 1))))
|
|
|
(display (even? 10))(newline)
|
|
|
(display (odd? 7))(newline)
|
|
|
"#;
|
|
|
assert_eq!(run_minilisp(src), "true\ntrue\n");
|
|
|
}
|
|
|
|
|
|
#[test]
|
|
|
fn test_higher_order() {
|
|
|
let src = r#"
|
|
|
(define (apply f x) (f x))
|
|
|
(define (double x) (* x 2))
|
|
|
(display (apply double 5))(newline)
|
|
|
"#;
|
|
|
assert_eq!(run_minilisp(src), "10\n");
|
|
|
}
|
|
|
|
|
|
#[test]
|
|
|
fn test_begin() {
|
|
|
let src = "(begin (display 1)(display 2)(display 3)(newline))";
|
|
|
assert_eq!(run_minilisp(src), "123\n");
|
|
|
}
|
|
|
```
|
|
|
|
|
|
### Testing error paths
|
|
|
|
|
|
```rust
|
|
|
#[test]
|
|
|
fn test_undefined_symbol_error() {
|
|
|
assert!(crate::compile("(display undefined-var)").is_err());
|
|
|
}
|
|
|
|
|
|
#[test]
|
|
|
fn test_unmatched_paren_error() {
|
|
|
assert!(crate::compile("(define x 1").is_err());
|
|
|
}
|
|
|
|
|
|
#[test]
|
|
|
fn test_wrong_arity_error() {
|
|
|
assert!(crate::compile("(+ 1 2 3)").is_err());
|
|
|
}
|
|
|
```
|
|
|
|
|
|
### Running the tests
|
|
|
|
|
|
```sh
|
|
|
cargo test
|
|
|
```
|
|
|
|
|
|
Note: integration tests require `cc` (or `gcc`/`clang`) to be installed. On a system without a C compiler, they will panic with "cc not found". Consider gating them with `#[ignore]` or a feature flag.
|
|
|
|
|
|
## Style notes
|
|
|
|
|
|
- The `run_minilisp` helper is the key piece of infrastructure — spend time on it
|
|
|
- The test corpus should cover every special form (define, lambda, if, let, begin) and every built-in
|
|
|
- Temporary file management in tests is inelegant but explicit; mention that `tempfile` crate would be cleaner in production
|
|
|
- Flag the `cc` dependency clearly — integration tests are not hermetic
|