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.
vibed/edu/.beans/archive/edu-3sww--11-checking-speci...

4.5 KiB

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

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

#[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