Files
bux-lang/docs/superpowers/specs/2026-07-28-selfhost-sema-error-recovery-design.md

5.8 KiB

Self-hosted compiler: semantic error recovery

Date: 2026-07-28 Scope: Self-hosted Bux compiler only (src/sema.bux, src/cli.bux) Status: Approved design

Goal

When the self-hosted compiler performs semantic analysis, it must report all independent semantic errors in a single run instead of stopping after the first one. Users should see every type mismatch, undeclared identifier, return-type error, etc., before the compiler exits with failure.

Current state

  • src/sema.bux already collects diagnostics in Sema.diags via Sema_EmitError and sets Sema.hasError.
  • Sema_Analyze walks every top-level declaration, so errors in different functions are all collected.
  • However, inside a single function, several Sema_EmitError call sites are followed by an early return (or by returning tyUnknown in a way that aborts the parent expression). This truncates checking of the remaining statements/sub-expressions and hides errors.
  • src/cli.bux correctly prints all collected semantic diagnostics and skips HIR lowering / C codegen when Sema_HasError is true.

Decision

Implement expression-level recovery with an error sentinel (tyUnknown):

  • Every error path in Sema_CheckExpr must return tyUnknown and must not abort the parent check.
  • Every error path in Sema_CheckStmt must record the diagnostic and then continue with the next statement (no early return that skips the rest of the block/function).
  • tyUnknown is already treated as numeric-compatible in Sema_IsNumeric; audit the other predicates and error sites so tyUnknown suppresses cascading errors rather than causing them.

Non-goals

  • No changes to the bootstrap Nim compiler (bootstrap/*.nim).
  • No parser recovery in this work item; parser errors still stop the pipeline before semantic analysis.
  • No "best-effort codegen": if semantic errors exist, HIR lowering and C generation are still skipped.

Architecture

Sema context

Sema keeps its existing diagnostic storage:

struct Sema {
    // ... existing fields ...
    diagCount: int;
    diags: *SemaDiag;
    hasError: bool;
}

Sema_EmitError remains the low-level reporter.

Error sentinel helper

Add a small helper for expression-level errors:

func Sema_EmitExprError(sema: *Sema, expr: *Expr, msg: String) -> int {
    Sema_EmitError(sema, expr.line, expr.column, msg);
    let te: *TypeExpr = bux_alloc(sizeof(TypeExpr)) as *TypeExpr;
    te.kind = tekNamed;
    te.typeName = "?";
    expr.refType = te;
    return tyUnknown;
}

Callers use it like:

return Sema_EmitExprError(sema, expr, "undeclared identifier 'foo'");

This makes the "record error + return sentinel" pattern explicit and harder to get wrong.

Statement-level recovery

Audit Sema_CheckStmt:

  • After any Sema_EmitError inside a statement handler, the handler must fall through to the normal return at the end of that branch so that the caller (Sema_CheckBlock / Sema_Analyze) continues with the next statement.
  • Example: if condition not bool must still check then/else blocks before returning.

Expression-level recovery

Audit Sema_CheckExpr:

  • Binary/unary/call/index expressions: always check both/all children before deciding whether the parent expression has a valid type.
  • When a child returns tyUnknown, the parent must return tyUnknown (or the already-known result type for comparisons) without emitting a second, derived error.
  • Sema_IsNumeric already returns true for tyUnknown; keep that behavior. Check Sema_IsBool and any custom predicates that might emit follow-up errors on tyUnknown.

CLI behavior

src/cli.bux keeps the current flow:

  1. Sema_Analyze(mod)
  2. If Sema_HasError(sema) → print every sema.diags[i] with Diagnostic_Print and return failure ("" / exit code 1).
  3. Only if no errors → HirLower_LowerModuleCBackend_Generate.

This guarantees that code generation never runs on an AST with semantic errors.

Data flow

Source
  → Lexer
  → Parser
  → Macro expand
  → Sema_Analyze
      ├─ Sema_CollectGlobals
      └─ for each func: Sema_CheckStmt / Sema_CheckExpr
            ├─ error → Sema_EmitError / Sema_EmitExprError → tyUnknown → continue
            └─ ok    → normal type
  → if hasError: print all diags, exit 1
  → else: HirLower → CBackend → cc

Error handling rules

  1. Never panic/abort for user-source errors.
  2. Never skip checking the rest of a block because one statement failed.
  3. Never emit a cascading error on an expression whose type is already tyUnknown.
  4. Do not generate code when hasError is true.

Testing

Create _test_error_recovery/:

  • src/Main.bux contains multiple independent semantic errors, e.g.:
import Std::Io::{PrintLine, PrintInt};

func Main() -> int {
    let x: int = "hello";
    let y: bool = 42;
    PrintInt(x + y);
    PrintLine(undefined_variable);
    return 0;
}

Expected buxc2 check (self-hosted) output: all semantic errors listed in one run.

Known blocker

make selfhost currently fails because the bootstrap parser rejects if used as an expression in src/hir_lower.bux:2726. The changes in this design are in src/sema.bux and src/cli.bux, so they can be syntax-checked with the bootstrap parser, but the new recovery behavior cannot be exercised end-to-end until that parser gap is fixed.

Risks

  • Changing Sema_CheckStmt/Sema_CheckExpr control flow may accidentally alter valid-code behavior; keep the diff minimal and only move/eliminate early returns that follow error emission.
  • tyUnknown suppression logic relies on existing predicates; missing one predicate may produce noisy cascading diagnostics.
  • Self-hosted compiler cannot be built right now, so runtime verification of buxc2 diagnostics is blocked.