8 min read 1696 words Updated Sep 29, 2026 Created Sep 29, 2026
##59##60##61##62##63##64##65

Design: compiler diagnostics (error & warning reporting)

Status: proposed. Phase 1 = structured diagnostics printed by the CLI.
Phase 2 (future) = expose the same diagnostics over LSP.

1. Goals

  • Every reported issue points at exactly where in the source it arises
    (file + byte range, plus optional secondary ranges and notes).
  • The frontend is resilient: one bad subexpression does not stop the rest
    of the program from being checked (see
    Pushing Past the First Error During Type Inference
    and the Hazel hole-recovery paper).
    We report all distinct problems in one pass.
  • Boundary invariant: below the crust layer, nothing fails on valid
    input. A crust::TypedModule may contain holes (typed black boxes
    standing in for invalid code); a hole never reaches codegen — the CLI
    exits before codegen when errors are present — and mantle panics on a
    hole as an early error, since one reaching that far is a frontend bug
    rather than a program state to tolerate.
  • Diagnostics are data, not errors: they are collected alongside partially
    valid ASTs and only rendered at the outer boundary (CLI now, LSP later).
    The lang crate never writes to the terminal.

2. Crate research & recommendation

Candidates for rendering structured, source-anchored diagnostics:

  • Structured data model: Diagnostic { severity, message, code, labels, notes } + Label { file_id, range: Range<u32>, message }. Plain data,
    decoupled from std::error::Error — exactly what resilient compilation
    needs (diagnostics attached to a partially valid AST, aggregated per
    module).
  • Multi-file: the files::Files trait (SimpleFiles or a custom impl)
    maps file ids to source. Blr is multi-module (main + imports + std), so
    diagnostics must carry a file id anyway.
  • Offsets are byte-based (Files::line_index(id, byte_index);
    line_starts uses byte offsets, clamped to char boundaries). lalrpop's
    LALRPOP_PARAM_TYPE token offsets are also byte-based, so parser
    positions flow into Label ranges with no conversion.
  • Emitters: term (ANSI colors, tty detection via termcolor), html, and a
    json feature. CLI phase 1 uses the term emitter; LSP consumes the
    structured Diagnostic directly (see §5).
  • Already a workspace dependency (codespan-reporting = "0.11.1") and
    already used in compiler::parse to render lalrpop errors.

miette

  • Prettier terminal rendering out of the box (graphical handler).
  • But it is built around impl Diagnostic for E where E: Error — diagnostics
    are welded to error types. Reporting N unrelated errors in one program
    means DiagnosticList or rendering each error separately; aggregation and
    per-file grouping are awkward.
  • Rendering is embedded in the error type rather than being pure data, so
    LSP conversion means re-implementing the mapping from miette's handler
    state anyway.
  • No first-class multi-file story (one file per diagnostic).

annotate-snippets (rustc's snippet engine)

  • The engine under rustc's new diagnostics; very flexible and battle-tested.
  • Lower-level: you build DisplayLists by hand for each diagnostic. More
    machinery to maintain for our needs; no multi-file registry; no LSP
    mapping. Overkill.

Others

  • owo-colors/yansi/styled: styling only, not diagnostics.
  • spanned/located: position bookkeeping only (we use lalrpop's offsets
    and codespan ranges instead).

Recommendation: keep codespan-reporting (already in the tree) as the
single diagnostics representation and renderer. If we later want miette's
visual style in the CLI, that is a rendering-layer swap; the data model we
standardize on is codespan's.

3. Data model

New module lang/src/compiler/diagnostic.rs:

pub type Code = &'static str; // stable codes, e.g. "E0001", assigned lazily

pub struct Diagnostics {
    files: SimpleFiles<String>,          // multi-file registry
    diagnostics: Vec<Diagnostic<Code>>,
}

impl Diagnostics {
    pub fn register_source(&mut self, name: String, src: String) -> usize; // FileId
    pub fn push(&mut self, d: Diagnostic<Code>);
    pub fn has_errors(&self) -> bool;    // any severity == Error
    pub fn len / is_empty / iter / split ...
    pub fn render(&self, w: &mut dyn Write, color: ColorChoice) -> io::Result<()>;
}
  • Diagnostics is attached to the Database and accumulated by every
    frontend query (parsed, alias_source_for_mod, typed_mod, …) instead
    of bubbling a single Result error. A query that hits a recoverable
    problem pushes a diagnostic and continues; a query that genuinely cannot
    proceed (e.g. unreadable file) still returns Err.
  • File naming: the CLI registers the entry source as main.blr (or the
    actual file name) and each import as its module path (std/df, …).
    parsed() is the single place sources enter, so file registration lives
    there; source(db, path) output is registered once per path.

Source spans

  • air: add a source span to every node. Add Span = std::ops::Range<u32>
    (byte offsets) as a field on air::Expr, air::Pattern, air::TypeExpr,
    and on items/aliases/imports. The lalrpop parser emits spans from token
    offsets (L..R of the outermost tokens of each production).
  • crust: NodeIds are minted fresh in crust::lower with no source
    linkage. During lowering, build a side table
    NodeSpans: HashMap<NodeId, (FileId, Span)> (thunderseethe-style side
    table: metadata keyed by node id, kept out of the AST). Lower takes the
    air span and records it whenever it mints a node id.
  • TypeInference receives the NodeSpans + Diagnostics and turns
    TypeError { node_id, kind } into a Diagnostic with a primary label at
    the node's span and secondary labels/notes for involved types.

4. Resilient frontend (the air & crust layers)

Mirrors the three improvements from thunderseethe's post: node ids (crust
already has these), a Hole node, and multiple-error reporting.

4.1 Hole

  • New air::Expr::Hole (and crust::Expr<V>::Hole(NodeId)). A hole is the
    black box standing in for code we decided is invalid.
  • Lowering replaces an invalid subexpression with a hole and pushes a
    diagnostic for the root cause. Examples that today Err or panic:
    unknown item references, private-item references
    (check_item_visibility), unknown type aliases,
    check_type_alias_visibility failures, panics in crust::lower that act
    as "backstops".
  • Type inference treats a hole as checked against any type: it is
    ascribed a fresh type variable and never checked (a hole unifies with
    anything, so it cannot block or skew inference of valid code).
  • A function whose body contains a hole still gets a (possibly
    unconstrained) scheme; inference proceeds for every other item.

4.2 Multiple errors, no cascades

  • TypeInference stores errors: HashMap<NodeId, TypeError> (one error per
    node). Parent-suppression rule: when collecting diagnostics at the end,
    a node that has an error hides the errors of its descendants — a cascade
    rooted at one bad node is reported as that single root error.
  • Unification keeps returning Result<(), TypeErrorKind>; the caller that
    knows the AST context (provenance: expected-vs-actual at a check,
    non-function being applied, …) turns it into the specific
    TypeErrorKind and records it against the right NodeId.
  • TypeErrorKind's Display currently has todo!() arms
    (InfiniteType, CheckIntroducedExtraVariablesOrConstraints); these need
    real user-facing messages. Messages should mention the two types in
    readable blr syntax (pretty is already a dependency for type printing).

4.3 New query shapes

  • crust::type_infer_with_items(item_source, module, node_spans, diagnostics) -> TypedModule (no more Result<_, TypeError>). Errors go into
    diagnostics; the TypedModule is always produced, with holed items
    typed as far as possible.
  • compile/run in lib.rs: after root_component-eligible compilation
    reaches typed_mod for the root, check
    diagnostics.has_errors() and stop before mantle if so.
    run still needs a Result (for IO/link/exec), but frontend type/parse
    errors surface as a Diagnostics payload, e.g.
    Err(Error::Diagnostics(Diagnostics)) or a
    compile_with_diagnostics entry point returning
    (Option<Vec<u8>>, Diagnostics).
  • Everything from TypedModule downward is unchanged; mantle panics on a
    hole (unreachable: the pipeline stops before mantle when errors are
    present, so a hole that far is a frontend bug, and we want that to be an
    early error).

4.4 Parse errors

  • parse currently renders a codespan diagnostic to a string and wraps it
    in Error::Parse { message }. Replace: parse pushes a structured
    Diagnostic (it already builds one!) into the shared Diagnostics and,
    since a failed parse yields no module at all, compilation of that module
    stops (later, LSP-driven, we can return a partial AST; not in phase 1).
  • Rendering moves out of lang into the CLI.

5. CLI rendering (phase 1)

  • New compile-style entry point in blr_lang that returns diagnostics
    instead of a formatted Error::Parse string.
  • cli/src/main.rs: on frontend failure, render Diagnostics to stderr via
    codespan_reporting::term::emit with color auto-detected from the
    terminal (or a --color flag), and exit non-zero (exit code 1; keep
    distinct from panic exit codes).
  • Warnings (once any exist, e.g. unused values): rendered the same way but
    do not fail the run.
  • Snapshot tests: testlib gets a "diagnostics" test flavor — programs with
    deliberate errors whose expected stderr is snapshotted (stderr rendering
    must be deterministic: fixed width, no tty, stable file names).

6. LSP mapping (phase 2, why the design is LSP-shaped)

codespanLSP
Diagnostic.severity (Error/Warning/Note)DiagnosticSeverity
Diagnostic.messageDiagnostic.message
Label (primary) range (bytes)Range (UTF-16 — convert at the boundary)
secondary Labels, notesDiagnostic.relatedInformation
codeDiagnostic.code
FileId → registered nameUri

Because diagnostics are structured data attached to memoized modules, the
LSP server reuses the same Database + Diagnostics and converts on emit.
The resilient frontend (holes + multiple errors) is what makes incremental
editing viable: a half-typed function never blocks the rest of the file.

7. Proposed ticket breakdown

Created in the tracker (hut -t "~nathanielc/blr" todo ticket show):

  1. #59 Diagnostics collector — compiler/diagnostic.rs (Diagnostics +
    multi-file registry), attached to Database; Error::Parse stops
    rendering inside lang (keep the rendered string as a stopgap or carry
    the Diagnostic); parse pushes structured diagnostics.
  2. #60 Spans in air — add Span to air::Expr/Pattern/TypeExpr/items;
    parser emits spans; snapshot updates.
  3. #61 Node spans in crust — NodeSpans side table built in
    crust::lower, plumbed into type_infer_with_items.
  4. #62 CLI diagnostics rendering — compile returns Diagnostics; CLI
    renders with the term emitter, colored stderr, exit code; first snapshot
    tests of user-facing parse errors.
  5. #63 Holes — air::Expr::Hole + crust::Expr::Hole; lowering turns
    unknown/private item refs, unknown/private aliases, and lowering
    backstop-panics into hole + diagnostic; type inference gives holes fresh
    type variables; mantle lowers holes to unreachable/unit.
  6. #64 Multiple type errors — type_infer_with_items returns
    TypedModule unconditionally; HashMap<NodeId, TypeError> collection
    with parent suppression; provenance-based messages; finish
    TypeErrorKind::Display (no more todo!()); compile stops before codegen
    on has_errors().
  7. #65 (deferred) LSP server — document registry, DidChange → re-query,
    publishDiagnostics conversion from Diagnostics.