RFD: Diagnostics Rendering
Status: Proposed
Depends on:
- Type Inference — diagnostic collection during type checking
Related:
- Error Sentinel Type —
ErrorReportedwitness that a diagnostic was emitted
Problem
The current diagnostic rendering is a quick fmt_ty function inside check/body.rs that produces strings like type mismatch: expected `u32`, found `bool`. This is adequate for tests but insufficient for a real compiler:
- No span information (which expression caused the error?)
- No context (what was the expected type and why?)
- Type display is ad-hoc and not reusable outside the checker
- No structured output for IDE integration
- Diagnostics are flattened to
Vec<String>inCheckedBody, losing all structure
Goals
-
Ty::display(db, stash)method — a reusableDisplayimpl for types, usable anywhere (diagnostics, hover info, debugging). Should handle inference variables (show resolved type afterfind), generic params (show name), and ADTs (show name + type args). -
Structured diagnostics — diagnostics carry spans, severity, structured data (expected/actual types as typed values, not pre-rendered strings). Rendering to text is a separate final step.
-
Rendering — use an existing crate (
annotate_snippetsorcodespan-reporting) for pretty terminal output with labeled source snippets, rather than rolling our own.
Non-goals (for now)
- Colored terminal output (comes free from the rendering crate, but not a driver of design decisions)
- LSP diagnostic protocol integration
- Fix suggestions / quick-fix actions
Current State
Diagnostic struct (sage-ir/src/check/body.rs)
#![allow(unused)] fn main() { pub struct Diagnostic<'db> { pub kind: DiagnosticKind<'db>, } pub enum DiagnosticKind<'db> { TypeMismatch { expected: Ptr<Ty<'db>>, actual: Ptr<Ty<'db>> }, UnresolvedInferVar { var: InferVarIndex }, AmbiguousName { count: usize }, } }
No spans. Types are stash pointers — only meaningfully readable during the checker lifetime.
Collection
Diagnostics are pushed to a Vec<Diagnostic> on BodyCheck. On finish(), they're rendered to Vec<String> and stored in CheckedBody. The structural information is lost at that point.
Rendering (fmt_ty)
A standalone function that pattern-matches on Ty variants. Works, but:
- Not reusable outside body checking (requires a
&Stashreference) - Doesn't resolve inference variables through the egraph
- Can't be used for hover info, signature display, etc.
Unused scaffold (sage-ir/src/diagnostics.rs)
#![allow(unused)] fn main() { pub struct SageDiagnostic { pub severity: Severity, pub message: String, pub offset: u32, } }
Defined but never used. Too flat — single offset, no labels or sub-spans.
Design: Diagnostic Struct
Replace the current internal-only Diagnostic with a structured type that carries labeled context and sub-diagnostics:
#![allow(unused)] fn main() { /// A single diagnostic emitted by sage. /// Each span is paired with the symbol it's relative to — resolution to /// absolute file positions happens only at the rendering boundary. pub struct Diagnostic<'db> { pub severity: Severity, pub span: Span<'db>, // primary location pub message: String, // headline, e.g. "type mismatch" pub labels: Vec<Label<'db>>, // labeled sub-spans for context pub notes: Vec<Diagnostic<'db>>, // sub-diagnostics (wrapped originals, explanatory notes) } /// A source location, resolvable to an absolute file position at rendering time. pub enum Span<'db> { /// A byte range within a symbol's body (e.g., an expression inside a function). /// `LocalSymbol` is a placeholder — concretely `LocalModItemSym<'db>` in the codebase. Relative(LocalSymbol<'db>, RelativeSpan), /// The entire span of a symbol (e.g., "this function" or "this struct"). Symbol(LocalSymbol<'db>), /// An already-absolute span. Use sparingly — only outside tracked functions /// or when the span doesn't belong to any symbol (e.g., top-level parse errors). Absolute(AbsoluteSpan<'db>), } pub struct Label<'db> { pub span: Span<'db>, pub message: String, // e.g. "expected `u32`" or "found `bool`" pub style: LabelStyle, } pub enum LabelStyle { Primary, Secondary } pub enum Severity { Error, Warning } }
Why an enum instead of just AbsoluteSpan
Diagnostics are produced inside Salsa tracked functions (LocalFnSym::body()). If they stored AbsoluteSpan directly, the result would depend on the item's file position — add a blank line above the function and the entire body re-checks. The Relative and Symbol variants reference interned Salsa identities, which are position-independent.
The Absolute variant exists for edge cases outside tracked functions (e.g., top-level parse errors, or diagnostics emitted at the rendering boundary itself). Inside tracked functions, prefer Relative or Symbol.
The enum is extensible — future variants might include things like MacroExpansion(...) for pointing into generated code.
Resolution to absolute happens at the rendering boundary:
#![allow(unused)] fn main() { impl<'db> Span<'db> { pub fn resolve(&self, db: &'db dyn Db) -> AbsoluteSpan<'db> { match self { Span::Relative(sym, rel) => sym.absolute_span(db).resolve(*rel), Span::Symbol(sym) => sym.absolute_span(db), Span::Absolute(abs) => *abs, } } } }
The notes field holds sub-diagnostics — these are originals that were "wrapped" during propagation, or explanatory context added along the way. See Error Propagation and Reporting for how these get built up.
Key invariant: Diagnostic contains only rendered String messages and Span<'db> locations — never Ptr<Ty> or other stash pointers. Type names are rendered to strings at to_diagnostic() time (while the stash is live). This makes Diagnostic self-contained and safe to store in CheckedBody after the stash moves.
Diagnostic<'db> must be Clone + PartialEq + Eq + Hash (required by CheckedBody's salsa::Update impl).
ErrorReported — the return value of reporting
Reporting a diagnostic returns an ErrorReported sentinel (see error-sentinel-type RFD). This is the only way to construct Ty::Error, Res::Error, and similar error variants — making it a compile error to introduce an error type without first emitting a user-visible message.
#![allow(unused)] fn main() { /// Witness that at least one diagnostic has been emitted. /// Only constructible by the diagnostic-reporting machinery. /// Must remain zero-sized — Ty::Error(ErrorReported) and Res::Error(ErrorReported) /// are stash-allocated, so this cannot grow without changing type layout. #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] pub struct ErrorReported(()); }
The diagnostic infrastructure is the sole mint for this token. Propagation (matching an existing Ty::Error(e) and threading e forward) doesn't require re-reporting — you already hold the witness.
Example diagnostic
For fn bad(x: u32) -> bool { x }:
error: type mismatch
--> src/main.sage:1:27
|
1 | fn bad(x: u32) -> bool { x }
| ---- ^ found `u32`
| |
| expected `bool` because of return type
Represented as:
#![allow(unused)] fn main() { Diagnostic { severity: Error, span: span_of_expr_x, // primary: the expression `x` (relative) message: "type mismatch", labels: vec![ Label { span: span_of_expr_x, message: "found `u32`", style: Primary }, Label { span: span_of_return_ty, message: "expected `bool` because of return type", style: Secondary }, ], } }
Builder API
#![allow(unused)] fn main() { impl<'db> Diagnostic<'db> { pub fn error(span: Span<'db>, message: impl Into<String>) -> Self { ... } pub fn warning(span: Span<'db>, message: impl Into<String>) -> Self { ... } pub fn label(mut self, span: Span<'db>, message: impl Into<String>) -> Self { ... } pub fn secondary(mut self, span: Span<'db>, message: impl Into<String>) -> Self { ... } } }
Diagnostic is constructed at catch points by calling to_diagnostic() on the structured error value (which carries raw Ptr<Ty> etc.). It is NOT the type that flows through Result — that role belongs to the structured error types (TypeError). See Error Propagation and Reporting below for the full two-level model.
Design: Type Display
Extract a reusable type-display utility that works outside the checker context.
Interface
#![allow(unused)] fn main() { /// Wrapper that implements `Display` for a type. pub struct TyDisplay<'a, 'db> { db: &'db dyn crate::Db, stash: &'a Stash, ty: Ptr<Ty<'db>>, } impl fmt::Display for TyDisplay<'_, '_> { ... } }
Usage: format!("{}", ty.display(db, stash)) anywhere — diagnostics, hover info, debug logging.
Behavior
- Primitives:
u32,bool,str, etc. - Generic params: show the declared name from
Param::name(db) - ADTs:
Vec<u32>,Option<T>(qualified path only when ambiguous — future) - Tuples:
(u32, bool) - References:
&u32,&mut String - Fn pointers:
fn(u32, u32) -> bool - Inference variables:
_(user-facing) or?0(internal/debug mode) - Error:
{error}(internal sentinel, shouldn't normally surface)
Resolution of inference variables
The display layer should operate on resolved types — after the egraph's find() has been applied. Two options:
- Resolve before display — walk the type and substitute each
InferVarwith its resolved type before displaying. This is whatfinish()already does when building the finalTyBody. - Resolve during display — pass the egraph/version into
TyDisplayand callfind()on the fly.
Option 1 is simpler and aligns with the current flow: diagnostics are rendered after finalization, at which point inference variables have been resolved. We should go with (1) initially and support (2) only if needed for mid-inference debugging.
Design: Error Propagation and Reporting
Two-level model
There are two distinct types in play:
-
Error types — structured Rust error values that flow through
Resultduring checking. They carry raw data (Ptr<Ty>, spans, inner errors) and can be composed/wrapped like normal Rust errors. They hold references into the stash (which is still live during checking). -
Diagnostic— the rendered, self-contained output type stored inCheckedBody. Created by calling.to_diagnostic(db, stash)on an error value at a catch point. Contains pre-renderedStringmessages and anchoredSpan<'db>locations. Does NOT holdPtr<Ty>or any stash references.
This separation means:
- Error values are cheap to construct and compose (no string formatting during propagation)
- Type display happens once, at the catch point, while the stash is still live
Diagnosticstored inCheckedBodyis fully self-contained — no dangling stash pointers afterfinish()moves the stash
Error types
Error types are plain Rust enums/structs — potentially using thiserror — that describe what went wrong in structured terms:
#![allow(unused)] fn main() { #[derive(Debug)] pub enum TypeError<'db> { Mismatch { expected: Ptr<Ty<'db>>, actual: Ptr<Ty<'db>>, span: RelativeSpan, }, UnresolvedName { span: RelativeSpan, }, // ... other variants } }
Error types can carry inner errors for composition:
#![allow(unused)] fn main() { #[derive(Debug)] pub struct ContextualError<'db> { pub inner: Box<TypeError<'db>>, pub context: ErrorContext<'db>, } pub enum ErrorContext<'db> { ReturnType { ret_span: RelativeSpan }, Argument { index: usize, call_span: RelativeSpan }, FieldInit { field_name: Name<'db>, struct_span: RelativeSpan }, // ... } }
Or more simply, using a single error type with an optional source (tree-shaped, like standard Rust error chains):
#![allow(unused)] fn main() { #[derive(Debug)] pub struct TypeError<'db> { pub kind: TypeErrorKind<'db>, pub span: RelativeSpan, pub source: Option<Box<TypeError<'db>>>, // inner/wrapped error } pub enum TypeErrorKind<'db> { Mismatch { expected: Ptr<Ty<'db>>, actual: Ptr<Ty<'db>> }, UnresolvedName, InvalidFieldInit { field: Name<'db> }, ArgTypeMismatch { index: usize }, // ... } }
The exact shape is an implementation detail — the key properties are:
- Carries
Ptr<Ty>(not rendered strings) - Has a tree structure (source/inner errors)
- Lightweight to construct during checking
to_diagnostic — the rendering boundary
At catch points, error values are converted to Diagnostic while the stash is still accessible:
#![allow(unused)] fn main() { impl<'db> TypeError<'db> { pub fn to_diagnostic(&self, cx: &BodyCheck<'_, 'db>) -> Diagnostic<'db> { let db = cx.db; let stash = cx.stash(); match &self.kind { TypeErrorKind::Mismatch { expected, actual } => { let mut diag = Diagnostic::error( cx.span(self.span), "type mismatch", ) .label(cx.span(self.span), format!( "found `{}`", actual.display(db, stash) )); // If there's context (source error), add it if let Some(source) = &self.source { diag = diag.note(source.to_diagnostic(cx)); } diag } // ... other variants } } } }
Result-based propagation
Low-level operations return Result<T, TypeError<'db>>. Context accumulates on the way up via wrapping:
#![allow(unused)] fn main() { // Lowest level: returns a raw TypeError with the types involved fn require_eq(&mut self, a: Ptr<Ty<'db>>, b: Ptr<Ty<'db>>, span: RelativeSpan) -> Result<(), TypeError<'db>> { // ... decompose, compare skeletons ... Err(TypeError { kind: TypeErrorKind::Mismatch { expected: b, actual: a }, span, source: None }) } }
#![allow(unused)] fn main() { // Mid-level: wraps with context fn check_return(&mut self, expr: &Expr, ret_ty: Ptr<Ty<'db>>) -> Result<(), TypeError<'db>> { let actual = self.infer_expr(expr)?; self.require_eq(actual, ret_ty, expr.span) .map_err(|e| e.with_context(ErrorContext::ReturnType { ret_span }))?; Ok(()) } }
#![allow(unused)] fn main() { // Catch point: converts to Diagnostic, reports, soldiers on fn check_expr(&mut self, expr: &Expr, expected: Ptr<Ty<'db>>) -> Ptr<TyExpr<'db>> { match self.check_expr_inner(expr, expected) { Ok(te) => te, Err(type_err) => { let diag = type_err.to_diagnostic(self); let e = self.report(diag); self.alloc_error_expr(e, expr.span) } } } }
What happens at each ? — a taxonomy
The principle is: never discard context. Wrapping preserves the original as a source.
1. Pass through unchanged
#![allow(unused)] fn main() { let ty = self.resolve_path(path)?; }
2. Add context (wrapping with source)
#![allow(unused)] fn main() { self.require_eq(actual, expected, span) .map_err(|e| e.with_context(ErrorContext::Argument { index: 2, call_span }))?; }
The with_context method wraps the error, keeping the original accessible via .source.
3. Replace the top-level kind
#![allow(unused)] fn main() { self.require_eq(actual, field_ty, init_span) .map_err(|e| TypeError { kind: TypeErrorKind::InvalidFieldInit { field: name }, span: init_span, source: Some(Box::new(e)), })?; }
The to_diagnostic impl for InvalidFieldInit renders the high-level message ("invalid struct field initializer") and includes the source error as a note:
error: invalid struct field initializer
--> src/main.sage:5:12
|
5 | Point { x: "hello" }
| ^^^^^^^^^^^
|
= note: mismatched types: expected `u32`, found `&str`
Recursive require_eq and multiple child failures
require_eq recurses into children when skeletons match. Multiple children could fail. We propagate the first failure and suppress the rest:
#![allow(unused)] fn main() { for (ca, cb) in da.children.iter().zip(db_decomposed.children.iter()) { self.require_eq(*ca, *cb, span)?; // first failure short-circuits } }
This is fine because the top-level type mismatch (e.g., Foo<u32, bool> vs Foo<u32, u32>) is typically more useful to the user than listing each differing child. The catch point sees the innermost structural mismatch and the context chain explains why it matters.
Never discard
We never throw away a propagated error. Even when the top-level kind is replaced, the original lives on as .source. The to_diagnostic implementation recursively renders the source chain into sub-diagnostic notes. The rendering layer decides visibility: verbose mode can show the full chain, normal mode may elide internal-only notes.
Context helpers — applying a transform to a block
#![allow(unused)] fn main() { fn with_context<'db, T>( context: ErrorContext<'db>, body: impl FnOnce() -> Result<T, TypeError<'db>>, ) -> Result<T, TypeError<'db>> { body().map_err(|e| e.with_context(context)) } }
Usage:
#![allow(unused)] fn main() { with_context( ErrorContext::FieldInit { field: name, struct_span }, || { let field_ty = self.resolve_field(name)?; let init_ty = self.infer_expr(init)?; self.require_eq(init_ty, field_ty, init_span)?; Ok(...) }, ) }
The exact signature and naming are implementation details — the key insight is that this is just Result::map_err applied to a block.
Catch points — where ErrorReported is minted
A catch point is anywhere the code can manufacture a meaningful fallback value and continue checking. At a catch point, the error is converted to a Diagnostic (rendering types to strings while the stash is live), then reported.
| Level | Catches | Fallback |
|---|---|---|
| Expression | type mismatch, unresolved name | Ty::Error(e) — continue checking siblings |
| Statement | invalid assignment target | skip statement, continue block |
| Item | signature error | skip body checking entirely |
| Module | parse error | skip item |
#![allow(unused)] fn main() { impl<'a, 'db> BodyCheck<'a, 'db> { pub fn report(&mut self, diag: Diagnostic<'db>) -> ErrorReported { self.diagnostics.push(diag); ErrorReported(()) } /// Catch a TypeError: convert to Diagnostic (rendering types now) and report. pub fn catch(&mut self, err: TypeError<'db>) -> ErrorReported { let diag = err.to_diagnostic(self); self.report(diag) } } }
Multiple independent errors
When checking N independent things (e.g., all arguments to a function call), each is its own catch point — you try each independently, report each failure, and keep going:
#![allow(unused)] fn main() { for (param, arg) in params.iter().zip(args) { if let Err(type_err) = self.check_expr_against(arg, param.ty) { self.catch(type_err); // don't short-circuit — check remaining args too } } }
No need for a "set" type — each error is reported at its own catch site. The diagnostics vec accumulates them naturally.
Propagation through already-errored values
When code encounters a Ty::Error(e) from upstream, it propagates without re-reporting — the witness already exists:
#![allow(unused)] fn main() { match res { Res::Error(e) => return self.alloc_ty(Ty::Error(e)), // ... } }
No new diagnostic is emitted. The original error was already reported at whatever catch point created it.
Storage
Two viable approaches for where reported diagnostics end up:
A. Vec<Diagnostic> on BodyCheck (initial)
Diagnostics are collected during the body walk via report(). Extracted on finish() and stored in CheckedBody. Simple, no Salsa machinery needed.
B. Salsa accumulator (future)
report() calls diag.accumulate(db) instead of pushing to a vec. Better when diagnostics are emitted across multiple tracked functions (resolution, macro expansion, type checking). Either way, report() still returns ErrorReported.
#![allow(unused)] fn main() { pub struct CheckedBody<'db> { pub body: TyBody<'db>, pub diagnostics: Vec<Diagnostic<'db>>, } }
Rendering to human-readable text happens at the call site (test harness, CLI driver), not inside the IR.
Diagnostics live on Check, not just BodyCheck
The diagnostic infrastructure (diagnostics vec, current_sym, report(), catch()) lives on Check — the base struct that both signature checking and body checking use. BodyCheck derefs to Check and inherits these methods.
This means signature-level errors (e.g., unresolved type in a function parameter) can also report diagnostics and mint ErrorReported — no special-casing needed. The LocalFnSym::sig() tracked function returns diagnostics alongside the signature, same as body() does for the body.
Design: Rendering
Crate choice: annotate_snippets
annotate_snippets is the same crate rustc uses for error rendering. It's maintained, handles multi-line spans, multiple labels per snippet, and produces output familiar to Rust developers:
error: type mismatch
--> src/main.sage:1:27
|
1 | fn bad(x: u32) -> bool { x }
| ---- ^ found `u32`
| |
| expected `bool` because of return type
Rendering pipeline
Diagnostic<'db> → resolve anchored spans via db → (file, line, col) → annotate_snippets::Message → String
The conversion from Diagnostic to annotate_snippets types lives in a thin rendering module (e.g. sage-diagnostics crate or a render module in sage-ir). The test harness can bypass this and just inspect the structured diagnostics directly.
Test expectations
Tests should continue to assert on rendered strings for readability, but the rendering step is now explicit:
#![allow(unused)] fn main() { TestCrate::in_memory("fn bad(x: u32) -> bool { x }") .check_errors(expect![[r#" error: type mismatch --> input.sage:1:27 | 1 | fn bad(x: u32) -> bool { x } | ^ found `u32` | "#]]); }
Or for simpler assertions during early development, a one-line mode that skips the snippet:
#![allow(unused)] fn main() { .check_errors_short(expect![[r#"type mismatch: expected `bool`, found `u32`"#]]); }
Design: Span Handling and Rendering Boundary
Span variants in diagnostics
Span<'db> is an enum — each variant represents a different way to identify a source location:
Relative(LocalSymbol, RelativeSpan)— a byte range within a symbol's body. Most common during body checking.Symbol(LocalSymbol)— the full span of a symbol. Useful for "this function" or "this struct field" labels.Absolute(AbsoluteSpan)— a pre-resolved position. Only for use outside tracked functions.
Since LocalSymbol is a Salsa-tracked struct (interned identity), the Relative and Symbol variants don't depend on file position. The diagnostic result is stable under edits elsewhere in the file — incrementality is preserved.
During body checking, BodyCheck knows which symbol it's checking. A convenience method builds spans anchored to the current item:
#![allow(unused)] fn main() { impl<'a, 'db> BodyCheck<'a, 'db> { pub fn span(&self, relative: RelativeSpan) -> Span<'db> { Span::Relative(self.current_sym, relative) } } }
For cross-item labels (e.g., pointing at a return type annotation, a field definition, or a trait bound), use Span::Relative(other_sym, rel) or Span::Symbol(other_sym) to point at the relevant item.
Resolution to absolute — only at the rendering boundary
The rendering boundary is outside the Salsa tracked function. Resolution calls absolute_span(db) on the anchor:
#![allow(unused)] fn main() { // Outside the tracked function — e.g., test harness or CLI driver let checked = fn_sym.body(db); for diag in &checked.diagnostics { let rendered = render(db, diag); // each span resolves itself via anchor println!("{rendered}"); } }
This means BodyCheck does NOT need an AbsoluteSpan field. It stores the LocalSymbol for anchoring, which is already known.
Implementation Plan
Each phase is driven by snapshot tests (expect_test). The workflow:
- Write the test with
expect![[""]]— it fails (red). - Implement the feature.
- Run with
UPDATE_EXPECT=1— the snapshot fills in with the actual output (green). - Review the git diff of the snapshot — that IS the test.
- Commit — the snapshot becomes the regression guard.
Subsequent phases refine the rendering. Existing snapshots from earlier phases get updated via UPDATE_EXPECT=1 as the format improves — the diff shows exactly what changed.
Phase 1: Structured diagnostics with spans
Status: DONE
Snapshot test:
#![allow(unused)] fn main() { #[test] fn error_has_span() { TestCrate::in_memory("fn bad(x: u32) -> bool { x }") .check_errors(expect![[""]]); // starts empty → red } }
After implementing, UPDATE_EXPECT=1 fills in:
#![allow(unused)] fn main() { .check_errors(expect![[r#" error at 23..28: type mismatch: expected `bool`, found `u32` "#]]); }
Today this same test would produce "type mismatch: expected bool, found u32" with no location. The existing return_type_mismatch test's snapshot also updates — it gains a span prefix. The diff shows the improvement.
Work done:
- Defined new
Diagnostic<'db>,Span<'db>enum,Label<'db>,Severity,ErrorReportedinsage-ir/src/diagnostic.rs - Defined
TypeError<'db>error type withto_diagnostic(&self, cx: &BodyCheck) -> Diagnostic<'db>(lives incheck/body.rsfor now) LocalModItemSym::absolute_span(db)already existed — used as the anchor type inSpan- Stored
current_sym: LocalModItemSym<'db>onBodyCheck; addedreport(),catch(), andspan()methods - Converted
require_eq/require_sub/require_coerceto returnResult<(), TypeError<'db>>with aspan: RelativeSpanparameter - Migrated all constraint call sites in
cst/expr.rs(if, while, binary, assign, call, match arm, let, array, struct fields) to catch and report - Changed
CheckedBody.diagnosticsfromVec<String>toVec<Diagnostic<'db>> - Updated test harness
collect_errors()to render diagnostics with spans (simple format:"error at {start}..{end}: {message}") - All 10 existing error tests updated with span prefixes
Deviations from design:
Ty::ErrorandRes::Errremain bare variants (notTy::Error(ErrorReported)/Res::Error(ErrorReported)). TheErrorReportedwitness is used at catch points but not threaded through the type system yet. This avoids a large multi-file refactor that would touchskeleton.rs,ty_fold.rs, and all pattern-match sites. Deferred to a future iteration.resolve_pathcatches errors internally and returnsRes::Err(fire-and-forget style) rather than returningResult<Res, TypeError>. This is simpler for the many call sites in pattern checking that just need the resolution value.- The span on the
return_type_mismatchtest is23..28(the body block expression) rather than just the innerxexpression (25..26). This is becauserequire_coercegets the body expression's span. Phase 2 will add secondary labels pointing at the return type annotation.
Phase 2: Secondary labels / context
Snapshot test:
#![allow(unused)] fn main() { #[test] fn error_labels_expected_and_found() { TestCrate::in_memory("fn bad(x: u32) -> bool { x }") .check_errors(expect![[""]]); } }
Status: DONE
After implementing, UPDATE_EXPECT=1 fills in:
#![allow(unused)] fn main() { .check_errors(expect![[r#" error at 23..28: type mismatch: expected `bool`, found `u32` at 23..28: found `u32` at 18..22: expected `bool` because of return type"#]]); }
Work done:
- Added
ErrorContextenum (ReturnType,Argument,FieldInit) andcontext: Option<ErrorContext>field onTypeError - Added
.with_context()method onTypeError - At return-type checking in
fns.rs, errors are wrapped withErrorContext::ReturnType { ret_span } - Enriched
to_diagnostic()to render context as secondary labels (primary label shows "foundX", secondary shows "expectedYbecause of return type") render_short()now emits labels as indented sub-lines
Deviations from design:
resolve_path()returnsRes<'db>(notResult) because expression checking always continues (producing aTyExpreven on failure).Res::Error(ErrorReported)carries the witness. The lower-levelResolutionenum has noErrorvariant —Path::resolve()returnsOption<Resolution>, and empty means "not found".- Did not add a
with_contextblock helper — simple.with_context(...)on the error value at catch sites is sufficient for now. - The diagnostic message still includes both types in the headline (
"type mismatch: expected \bool`, found `u32`"`) rather than splitting them purely into labels. This avoids losing information when rendered without labels.
Phase 3: TyDisplay — reusable type formatting
Status: DONE
Snapshot tests:
#![allow(unused)] fn main() { #[test] fn ty_display_unit_return() { TestCrate::in_memory("fn f() -> u32 { }").check_errors(expect![[r#" error at 14..17: type mismatch: expected `u32`, found `()` at 14..17: found `()` at 10..13: expected `u32` because of return type"#]]); } #[test] fn ty_display_fn_pointer() { TestCrate::in_memory("fn f(g: fn(u32) -> bool) -> u32 { g }").check_errors(expect![[r#" error at 32..37: type mismatch: expected `u32`, found `fn(u32) -> bool` at 32..37: found `fn(u32) -> bool` at 28..31: expected `u32` because of return type"#]]); } }
Work done:
- Extracted
fmt_tyintoTyDisplay<'a, 'db>struct withimpl Displayinsage-ir/src/display.rs TyDisplay::new(db, stash, ty)is usable anywhere — diagnostics, hover info, debugging- Used
TyDisplayinTypeError::to_diagnostic()via.to_string() - Deleted old
fmt_tyfunction fromcheck/body.rs
Deviations from design:
TyDisplaylives insage-ir/src/display.rs(existing placeholder file) rather thansage-ir/src/ty/display.rs— simpler module layout,ty.rsremains a single file.
Phase 4: Rich rendering with annotate_snippets
Status: DONE
After UPDATE_EXPECT=1, all snapshots now show full rustc-style source snippets:
#![allow(unused)] fn main() { .check_errors(expect![[r#" error: type mismatch: expected `bool`, found `u32` --> lib.rs:1:24 | 1 | fn bad(x: u32) -> bool { x } | ---- ^^^^^ found `u32` | | | expected `bool` because of return type"#]]); }
Work done:
- Added
annotate_snippetsv0.12 dependency tosage-ir - Added
Diagnostic::render()method that resolves spans → source text →annotate_snippets::Snippetwith annotations - Test harness now uses
render()(rich) instead ofrender_short()(simple) - All 30 test snapshots updated to show source snippets with underlines and labels
- Removed old
SageDiagnosticscaffold (diagnostics.rsdeleted)
Deviations from design:
- Rendering lives as a method on
Diagnosticindiagnostic.rsrather than a separatediagnostic/render.rsmodule — simpler for the current scope. notes(sub-diagnostics) are not yet rendered via annotate_snippets (no need yet since no test exercises them).
Phase 5: Sub-diagnostics / wrap
Status: DONE
The struct field init error now points at the individual field initializer (not the whole struct literal) and includes an ErrorContext::FieldInit label:
error: type mismatch: expected `u32`, found `bool`
--> lib.rs:2:40
|
2 | fn f() -> Wrapper { Wrapper { value: true } }
| ^^^^^^^^^^^
| |
| found `bool`
| expected `u32` for this field
Work done:
- At struct-lit field checking, errors are caught with
ErrorContext::FieldInit { field_span }using the field initializer's own span (not the whole struct literal) - The diagnostic now correctly points at the offending field initializer expression
Deviations from design:
- Did not implement a
.wrap()/ sub-diagnostic note pattern — instead used the sameErrorContextmechanism from Phase 2, which produces the same user-visible effect (context labels on the diagnostic) without a separate concept. This is simpler and consistent with the rest of the system. - The headline message remains "type mismatch" rather than changing to "invalid struct field initializer" — the field context label makes the meaning clear.
Phase 6: Oracle harness integration
Status: DONE
The oracle harness now collects actual structured diagnostics from sage (with resolved line numbers) and matches them against //# ERROR annotations:
#![allow(unused)] fn main() { // test-fixtures/oracle/errors/type_mismatch.rs fn returns_wrong_type() -> u32 { //# ERROR type mismatch "hello" } }
Work done:
- Added
collect_diagnostics()/collect_diagnostics_files()tosage-test-harness— collectsResolvedDiagnostic { line, message }from sage - Rewrote
check_annotations()in the oracle harness to use actual diagnostic collection instead of checking for"?"markers in the emit JSON - Each
//# ERROR patternannotation is matched against diagnostics on the target line with message pattern matching - Updated
type_mismatch.rsfixture to use inline annotations with patterns
Deviations from design:
- Did not add byte-range span checking to annotations (e.g.,
//# ERROR 2:5..2:12) — line + message pattern matching is sufficient for now. Span precision can be validated via theexpect_testsnapshot tests. - Annotations target the function declaration line (where the body block starts) rather than the expression line, because sage's current span attribution points at the body block expression.
Future iterations
- Salsa accumulator for cross-phase diagnostic collection
- LSP diagnostic bridge
- Quick-fix suggestions
- Verbose mode that surfaces internal sub-diagnostics
Inspiration: Dada
Key patterns from Dada worth noting:
- Salsa accumulator for diagnostics —
Diagnosticis#[salsa::accumulator], collected via.accumulate(db)during any tracked function, retrieved withfn::accumulated::<Diagnostic>(db). Reportedsentinel —type Errors<T> = Result<T, Reported>prevents cascading errors. Once reported, the span is captured inReported(span)and further operations can early-return without re-reporting.OrElsetrait +Becauseenum — low-level operations (subtyping, unification) receive anOrElsecallback. On failure, it's invoked with aBecausevariant that explains why the operation was attempted. This produces diagnostics like "expected T because of return type annotation" vs "expected T because of assignment target".- Builder API with labels —
.label(db, Level::Error, span, msg)for multi-span errors,.child(diagnostic)for nested explanatory notes. annotate_snippetsfor rendering to terminal.
We adopt immediately:
- Structural shape (span + labels + builder)
ErrorReportedsentinel (our version of Dada'sReported/ rustc'sErrorGuarantee)annotate_snippetsfor rendering
Different approach from Dada:
- Instead of
OrElse/Becausecallbacks threaded down into low-level operations, we useResult<T, Diagnostic<'db>>and accumulate context on the way up via.map_err(). This is more idiomatic Rust and doesn't require passing closures through every layer.
Deferred to future iterations:
- Salsa accumulators