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. Acrust::TypedModulemay 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).
Thelangcrate never writes to the terminal.
2. Crate research & recommendation
Candidates for rendering structured, source-anchored diagnostics:
codespan-reporting (recommended)
- Structured data model:
Diagnostic { severity, message, code, labels, notes }+Label { file_id, range: Range<u32>, message }. Plain data,
decoupled fromstd::error::Error— exactly what resilient compilation
needs (diagnostics attached to a partially valid AST, aggregated per
module). - Multi-file: the
files::Filestrait (SimpleFilesor 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_startsuses byte offsets, clamped to char boundaries). lalrpop'sLALRPOP_PARAM_TYPEtoken offsets are also byte-based, so parser
positions flow intoLabelranges with no conversion. - Emitters:
term(ANSI colors, tty detection via termcolor),html, and ajsonfeature. CLI phase 1 uses the term emitter; LSP consumes the
structuredDiagnosticdirectly (see §5). - Already a workspace dependency (
codespan-reporting = "0.11.1") and
already used incompiler::parseto 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
meansDiagnosticListor 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<()>;
}
Diagnosticsis attached to theDatabaseand accumulated by every
frontend query (parsed,alias_source_for_mod,typed_mod, …) instead
of bubbling a singleResulterror. A query that hits a recoverable
problem pushes a diagnostic and continues; a query that genuinely cannot
proceed (e.g. unreadable file) still returnsErr.- 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 onair::Expr,air::Pattern,air::TypeExpr,
and on items/aliases/imports. The lalrpop parser emits spans from token
offsets (L..Rof the outermost tokens of each production). - crust:
NodeIds are minted fresh incrust::lowerwith no source
linkage. During lowering, build a side tableNodeSpans: HashMap<NodeId, (FileId, Span)>(thunderseethe-style side
table: metadata keyed by node id, kept out of the AST).Lowertakes the
air span and records it whenever it mints a node id. TypeInferencereceives theNodeSpans+Diagnosticsand turnsTypeError { node_id, kind }into aDiagnosticwith 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(andcrust::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 todayError panic:
unknown item references, private-item references
(check_item_visibility), unknown type aliases,check_type_alias_visibilityfailures, panics incrust::lowerthat 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
TypeInferencestoreserrors: 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 acheck,
non-function being applied, …) turns it into the specificTypeErrorKindand records it against the rightNodeId. TypeErrorKind'sDisplaycurrently hastodo!()arms
(InfiniteType,CheckIntroducedExtraVariablesOrConstraints); these need
real user-facing messages. Messages should mention the two types in
readable blr syntax (prettyis already a dependency for type printing).
4.3 New query shapes
crust::type_infer_with_items(item_source, module, node_spans, diagnostics) -> TypedModule(no moreResult<_, TypeError>). Errors go intodiagnostics; theTypedModuleis always produced, with holed items
typed as far as possible.compile/runinlib.rs: afterroot_component-eligible compilation
reachestyped_modfor the root, checkdiagnostics.has_errors()and stop before mantle if so.runstill needs aResult(for IO/link/exec), but frontend type/parse
errors surface as aDiagnosticspayload, e.g.Err(Error::Diagnostics(Diagnostics))or acompile_with_diagnosticsentry point returning(Option<Vec<u8>>, Diagnostics).- Everything from
TypedModuledownward 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
parsecurrently renders a codespan diagnostic to a string and wraps it
inError::Parse { message }. Replace:parsepushes a structuredDiagnostic(it already builds one!) into the sharedDiagnosticsand,
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
langinto the CLI.
5. CLI rendering (phase 1)
- New
compile-style entry point inblr_langthat returns diagnostics
instead of a formattedError::Parsestring. cli/src/main.rs: on frontend failure, renderDiagnosticsto stderr viacodespan_reporting::term::emitwith color auto-detected from the
terminal (or a--colorflag), 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:
testlibgets 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)
| codespan | LSP |
|---|---|
Diagnostic.severity (Error/Warning/Note) | DiagnosticSeverity |
Diagnostic.message | Diagnostic.message |
Label (primary) range (bytes) | Range (UTF-16 — convert at the boundary) |
secondary Labels, notes | Diagnostic.relatedInformation |
code | Diagnostic.code |
FileId → registered name | Uri |
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):
- #59 Diagnostics collector —
compiler/diagnostic.rs(Diagnostics+
multi-file registry), attached toDatabase;Error::Parsestops
rendering insidelang(keep the rendered string as a stopgap or carry
theDiagnostic); parse pushes structured diagnostics. - #60 Spans in air — add
Spantoair::Expr/Pattern/TypeExpr/items;
parser emits spans; snapshot updates. - #61 Node spans in crust —
NodeSpansside table built incrust::lower, plumbed intotype_infer_with_items. - #62 CLI diagnostics rendering —
compilereturnsDiagnostics; CLI
renders with the term emitter, colored stderr, exit code; first snapshot
tests of user-facing parse errors. - #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. - #64 Multiple type errors —
type_infer_with_itemsreturnsTypedModuleunconditionally;HashMap<NodeId, TypeError>collection
with parent suppression; provenance-based messages; finishTypeErrorKind::Display(no moretodo!()); compile stops before codegen
onhas_errors(). - #65 (deferred) LSP server — document registry,
DidChange→ re-query,publishDiagnosticsconversion fromDiagnostics.