Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

09 - Diagnostics

Pyfun’s pitch is that the compiler is the gatekeeper, so the compiler’s voice matters. DESIGN.md §3 puts it plainly: syntax is cheap, error quality is not. A language that rejects a program owes the reader a clear reason, a precise location, and where possible a way forward. That is why diagnostics get their own attention even though the rendering code is small.

The renderer is deliberately small

src/diagnostics/mod.rs is under a hundred lines. It renders one diagnostic in a rustc-like style: a level, a message, and a single underlined span.

#![allow(unused)]
fn main() {
// src/diagnostics/mod.rs
pub fn render(source: &str, level: Level, message: &str, span: Span) -> String {
    let (line_no, col_no, line_start) = locate(source, span.start);
    // ... builds the gutter, the source line, and a `^^^` underline
}
}

Level is Error, Warning, or Note. locate maps a byte offset to a one-based line and column, and the underline length is clamped to the rest of the line. That is the whole rendering surface. The intelligence lives upstream: the type checker (src/types/mod.rs) composes the message string, including the “did you mean” member hints and the typed-hole reports, and hands render a level, that message, and a span. Keeping presentation this thin means every diagnostic across the compiler shares one format for free.

Three real diagnostics

A type mismatch. HM inference reports the expected and found types at the offending call:

error: type mismatch: expected int<'a>, found string
 --> 2:13
  |
2 | let wrong = add "hello" 5
  |             ^^^^^^^^^^^

A non-exhaustive match. The Maranget usefulness check produces a concrete witness for the case the program forgot, so the message names it rather than saying only “not exhaustive”:

error: non-exhaustive match: `Rect _ _` is not matched
 --> 4:3
  |
4 |   match s:
  |   ^^^^^^^^

A typed hole report. This one is a note, not an error in the ordinary sense: it tells you the type the blank must have and offers ways to fill it.

note: hole `?body` has type `float` — try: h, w — or: area ?, cbrt ?, sqrt ?, abs ?
 --> 6:24
  |
6 |     case Rect w h: w * ?body
  |                        ^^^^^
1 unfilled hole

Typed holes and hole fits

A ? or ?name is a typed blank. It infers as a fresh type variable that unifies freely, so it never causes a spurious error and takes whatever type the context demands; here the context of w * ?body forces float. The report is assembled by Hole::message:

#![allow(unused)]
fn main() {
// src/types/mod.rs
pub fn message(&self) -> String {
    let mut parts = vec![/* hole `?name` has type `T` */];
    if !self.fits.is_empty() { parts.push(format!("try: {}", self.fits.join(", "))); }
    if !self.refinements.is_empty() { parts.push(format!("or: {}", self.refinements.join(", "))); }
    parts.join(" — ")
}
}

The try: list is the valid hole fits: every in-scope binding whose type unifies with the hole’s. The test is a real trial unification that snapshots the substitution maps, instantiates each candidate scheme, unifies it against the resolved hole type, and rolls back (Infer::hole_fits, described in INTERNALS.md’s typed-holes section). Fits are ranked most-specific first and capped. The or: list is refinement fits: a function whose result, after applying one or two further holes, unifies with the target, so area ? and sqrt ? appear because each returns a float. A hole blocks compile and run and makes check exit non-zero, so it is informative during development without ever slipping into emitted Python.

Why this framing

The mismatch names both types, the non-exhaustive report hands you the exact missing shape, and the hole tells you what would type-check right there. Each one answers the reader’s next question instead of leaving them to guess. That is the concrete meaning of “error quality is not cheap”: the checker does extra work (witnesses, trial unification) specifically so the message can be specific.

Where you would add a new diagnostic note

A new hint or note is composed where the checker detects the condition, in src/types/mod.rs (the closest_member “did you mean” path is a good model), and passed through render with the right Level. If a diagnostic needs a shape the renderer cannot express yet, such as a second span or a diagnostic code, that extension goes into src/diagnostics/mod.rs; the module’s own header flags multi-span notes and codes as the natural next step.