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.buxalready collects diagnostics inSema.diagsviaSema_EmitErrorand setsSema.hasError.Sema_Analyzewalks every top-level declaration, so errors in different functions are all collected.- However, inside a single function, several
Sema_EmitErrorcall sites are followed by an earlyreturn(or by returningtyUnknownin a way that aborts the parent expression). This truncates checking of the remaining statements/sub-expressions and hides errors. src/cli.buxcorrectly prints all collected semantic diagnostics and skips HIR lowering / C codegen whenSema_HasErroris true.
Decision
Implement expression-level recovery with an error sentinel (tyUnknown):
- Every error path in
Sema_CheckExprmust returntyUnknownand must not abort the parent check. - Every error path in
Sema_CheckStmtmust record the diagnostic and then continue with the next statement (no earlyreturnthat skips the rest of the block/function). tyUnknownis already treated as numeric-compatible inSema_IsNumeric; audit the other predicates and error sites sotyUnknownsuppresses 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_EmitErrorinside a statement handler, the handler must fall through to the normalreturnat the end of that branch so that the caller (Sema_CheckBlock/Sema_Analyze) continues with the next statement. - Example:
ifcondition notboolmust still checkthen/elseblocks 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 returntyUnknown(or the already-known result type for comparisons) without emitting a second, derived error. Sema_IsNumericalready returnstruefortyUnknown; keep that behavior. CheckSema_IsBooland any custom predicates that might emit follow-up errors ontyUnknown.
CLI behavior
src/cli.bux keeps the current flow:
Sema_Analyze(mod)- If
Sema_HasError(sema)→ print everysema.diags[i]withDiagnostic_Printand return failure (""/ exit code 1). - Only if no errors →
HirLower_LowerModule→CBackend_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
- Never panic/abort for user-source errors.
- Never skip checking the rest of a block because one statement failed.
- Never emit a cascading error on an expression whose type is already
tyUnknown. - Do not generate code when
hasErroris true.
Testing
Create _test_error_recovery/:
src/Main.buxcontains 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_CheckExprcontrol flow may accidentally alter valid-code behavior; keep the diff minimal and only move/eliminate earlyreturns that follow error emission. tyUnknownsuppression 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
buxc2diagnostics is blocked.