ADR-012: Diagnostics — Reporting Errors in the File the User Wrote

Status: Superseded by ADR-300 — its D1-D8 are D1-D8 there Closes: the error-provenance item ADR-011 §4 left open Related: ADR-001 D4 (Stage 0 is a transpiler, which is why this is needed at all), ADR-011 D2 (the lowering is name for name, which is why D3 works), ADR-005 D7 (the requirement this meets)

1. Context

Stage 0 emits Rust and hands it to rustc. Everything that then goes wrong — rustc’s type errors and the backend’s frame check alike — is reported against a file nobody wrote:

error: `until(…)` in rule `NAME` can run through the boundary "\n" of frame `MEASUREMENT`
 --> measurements.rs:10:15

measurements.rs is generated. Line 10 is generated. The user has measurements.nika open.

ADR-011 D3 already refused the obvious fix — re-implementing the frame check in Nikaia so it could be reported natively — because two implementations drift. That left the error’s provenance unsolved. This solves it for every error at once, rather than for one check: a mechanism that only fixed the frame check would leave every type error still pointing into generated code.

2. Decisions

D1 — The lowering emits a source map, and the map is the whole mechanism.

Emitting is a walk over the AST; while it walks, the emitter records which byte range of its output each node produced. A diagnostic at an offset in the generated Rust is traded for the .nika span that wrote those bytes.

This is the ordinary answer to an ordinary problem — what a JavaScript build tool does — and what it buys over the alternative is one mechanism for all error classes. The frame check, an unresolved name, a borrow error, a type mismatch in an action block, a future backend lint: each is a byte offset, and each maps back the same way.

D2 — The map is derived, never stored.

The lowering is deterministic, so the map is recomputed from the .nika file when needed rather than written beside the .rs and kept in step:

cargo build --message-format=json | nikaia --input measurements.nika --explain

No artifact to version, no format to document, and no way for the map to go stale with respect to the source it describes — they are the same file.

D3 — Positions are corrected; messages are not.

The text passes through unchanged. This is ADR-011 D2 paying out: because the lowering is name for name, NAME and MEASUREMENT in the backend’s message are the user’s rule names, and no method to_digit on char is about the d.to_digit() they wrote. The sentence was already about the Nikaia source; only the position was wrong.

Correcting a position is a lookup. Translating a text would be a second compiler.

D4 — The innermost span wins, and the granularity is stated.

Nodes are recorded as emitted, innermost first, so a lookup takes the first range containing the offset. Recorded: items, grammar rules, every grammar pattern, and statements. Expressions are not, so a type error inside a statement is reported on that statement.

That is a real limit, stated here rather than in a footnote: let x = f(g(y)) blames the whole let. Expression spans are the obvious next increment and need no new mechanism, but they are not claimed today.

D5 — An unmapped diagnostic says so.

Not every byte of the output comes from the source: the preamble, the use lines, the binding synthesised for a bare par_fold. An error there is reported against the generated file, with that stated:

error: measurements.rs:3: <message> (no Nikaia source maps to this)

Guessing the nearest line would be worse than admitting the gap, because the nearest line is frequently unrelated and a user cannot tell an invented position from a real one.

D6 — Spans come from the parser backend, not a scheme of Nikaia’s own.

A rule written -> T @= gets _span, the byte range it matched, in scope of its action block. Every node carrying a span gets it from there: no offset arithmetic, no second stream, and no way for a span to disagree with what the parser actually consumed.

D7 — Nikaia is a filter over rustc’s JSON, not a build system.

--error-format=json is a stable, documented interface giving file, line, column and byte offsets; nothing is scraped from rendered text. Nikaia reads it on stdin and writes diagnostics about the .nika file, which composes with whatever drives the build instead of replacing it.

D8 — A project build runs the binary itself, so the backend never says anything twice.

nikaia run is a build and then a run, and the build is where every diagnostic is read (D7’s channel). The run cannot use that channel — the program’s own output is on the same stdout — so for as long as the run went through cargo run, Cargo rendered its cached diagnostics to stderr while checking freshness, and a program with a warning saw it twice: once in this language’s words, and once as rustc about target/nikaia/gen/….rs. Measured on Part I 2.3’s own example.

So the build runs the program directly. Nothing is left to replay it, and no stream is captured: what a running program writes is the program’s.

The path is read and not computed. Cargo decides where a binary lands from the target directory, the profile and CARGO_TARGET_DIR; a second place working those rules out would have to keep agreeing with Cargo forever. The build’s own JSON says it outright, on the compiler-artifact line whose executable is not null — which this compiler already captures in order to read the diagnostics. Where no line says, cargo run is still what happens, so this can only remove a duplicate and never lose a run.

3. What this makes of the corpus

A fixture writes until(";") under a @frame(boundary: "\n") — a name without a semicolon would run past the newline and swallow the next record, which is exactly what makes cutting at newlines unsound. The test lowers it, compiles the emitted Rust with the same rustc that built the test, and asserts the report lands on the pattern in the .nika file:

error: broken_frame.nika:15:11: `until(…)` in rule `NAME` can run through the boundary
"\n" of frame `MEASUREMENT`; add the boundary as an alternative of the terminator so the
scan stops there: `until(… | frame_end)`
  15 |         s:until(";")
                 ^^^^^^^^^^

The test is the pipeline, not a simulation of it: the check runs where it really runs, in a proc macro during macro expansion, and the assertion is on the line number a user would read.

4. Rejected alternatives

5. Risks

This site is open source. Improve this page.