+++ title = "§11 Checking Special Forms" priority = 5 status = "todo" ticket_type = "task" dependencies = [] +++ ## §11 Checking Special Forms — Stub to fill File: `edu/src/lisp-compiler.md`, section `### 11. Checking Special Forms` Replace the stub line with full content. Target 500–700 words. Extend the analyser to validate the shape and arity of each special form. Moderate code, conceptually straightforward. ## Learning objectives - Understand what "shape checking" means for special forms - Add arity and constraint checks for each special form in the analyser - Produce actionable error messages that identify the problematic form ## Content to write ### Why the parser isn't enough The parser already enforces some structure — `parse_if` requires exactly `(if cond then else)`. But because we use `cut` at certain points and `many0`/`many1` at others, some invalid inputs may slip through to produce odd AST nodes. Shape checking in the analyser catches these and provides better error messages than a nom parse failure would. More importantly, shape constraints that are hard to encode in a parser (like "a lambda must have at least one body expression") are cleanly expressed as analyser rules. ### Checks to add Extend `check_expr` from §10 with these constraints: **`Define`**: the `value` expression must not itself be another bare `Define` (nested defines are disallowed in MiniLisp — only top-level defines are valid). Emit: `"define is only allowed at the top level"`. **`Lambda`**: body must be non-empty (guaranteed by `many1` in the parser, but double-check). Parameters must be unique — duplicate parameter names are an error. Emit: `"duplicate parameter name: \`{name}\`"`. **`If`**: no checks beyond what the parser enforced (exactly three sub-expressions). This is already correct by construction from the AST variant. **`Let`**: binding names must be unique within the `let` form. Each binding value is evaluated in the *outer* scope (not the let scope) — confirm that binding values reference the outer env, not the inner one. **`Call` with built-in operators**: check arity. - Binary operators (`+`, `-`, `*`, `/`, `=`, `<`, `>`, `<=`, `>=`): exactly 2 arguments. - Unary operator (`not`): exactly 1 argument. - `display`: exactly 1 argument. - `newline`: exactly 0 arguments. - `error`: exactly 1 argument (the message string). ```rust fn check_call_arity(func: &Expr, args: &[Expr]) -> Result<(), CompileError> { let name = match func { Expr::Symbol(s) => s.as_str(), _ => return Ok(()), // user-defined function call; arity checked at runtime }; let expected = match name { "+" | "-" | "*" | "/" | "=" | "<" | ">" | "<=" | ">=" => Some(2), "not" | "display" | "error" => Some(1), "newline" => Some(0), _ => None, // user-defined; no static arity check }; if let Some(n) = expected { if args.len() != n { return Err(CompileError::SemanticError(format!( "`{}` expects {} argument(s), got {}", name, n, args.len() ))); } } Ok(()) } ``` Call `check_call_arity` inside the `Expr::Call` arm of `check_expr`. ### Disallowing nested defines Top-level `Define` inside a function body should be rejected: ```rust fn check_expr_in_body(expr: &Expr, env: &Env) -> Result<(), CompileError> { if let Expr::Define { .. } = expr { return Err(CompileError::SemanticError( "define is only allowed at the top level".to_string() )); } check_expr(expr, env) } ``` Use `check_expr_in_body` when checking lambda bodies, let bodies, and begin expressions. ### Unit tests ```rust #[test] fn test_duplicate_params_rejected() { let exprs = parse("(define (f x x) x)").unwrap(); assert!(analyse(exprs).is_err()); } #[test] fn test_wrong_arity_rejected() { let exprs = parse("(+ 1 2 3)").unwrap(); assert!(analyse(exprs).is_err()); } #[test] fn test_nested_define_rejected() { let exprs = parse("(define (f x) (define y 1) y)").unwrap(); assert!(analyse(exprs).is_err()); } #[test] fn test_valid_program_passes() { let exprs = parse("(define (factorial n) (if (= n 0) 1 (* n (factorial (- n 1)))))").unwrap(); assert!(analyse(exprs).is_ok()); } ``` ## Style notes - This section is shorter than §10 — it is an extension, not a redesign - The built-in arity table is the most useful part; present it as a reference table in the text - End with a checkpoint: run the full analyser on the factorial example and verify it passes