You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

124 lines
4.5 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

+++
title = "§11 Checking Special Forms"
priority = 5
status = "done"
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 500700 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