|
|
---
|
|
|
# edu-3sww
|
|
|
title: §11 Checking Special Forms
|
|
|
status: completed
|
|
|
type: task
|
|
|
priority: normal
|
|
|
created_at: 2026-03-10T23:30:01Z
|
|
|
updated_at: 2026-03-10T23:30:01Z
|
|
|
---
|
|
|
|
|
|
## §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
|