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)
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.
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.
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.
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.
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.
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.
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.
--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.
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.
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.
--error-format=json exists precisely
so nobody has to.