Status: Accepted
Date: October 3, 2026
History: the records this one replaces are archived in docs/history/adr; read them only for how the decision was reached
Answers: the error-provenance item ADR-296 left open
(every backend error is reported against a generated file nobody wrote), and
the same question for an abort at run time, which otherwise names the
generated file and line
Related:
ADR-001 D4 (Stage 0 is a transpiler, which is why this is needed at all);
ADR-296 D17 and D18 (the lowering is name for name; the checks are not re-implemented in Nikaia);
ADR-005 D7 (a raw rustc error reaching the user is a compiler bug — the requirement this meets for positions);
ADR-297 D10 (the panic hook, global and sync, where the run-time lookup goes);
ADR-280 (a stack trace — not this record’s);
ADR-024 D7 and ADR-081 D1 (statement granularity, and the first expression-level span);
ADR-260 D3 (the single-file commands are verbs);
ADR-286 D9 (the source map records which file a unit came from)
When something goes wrong in a Nikaia program — a backend refusal while it is built, or an abort while it runs — which file and line does the user read?
Stage 0 emits Rust and hands it to rustc. Without an answer, every type error,
every backend frame check and every panic names target/nikaia/gen/….rs, a
file nobody wrote, while the user has the .nika file open. A mechanism that
fixed one error class at a time would leave every other one pointing into
generated code, so the question is asked once, for all of them, and once more
for the run-time path where no compiler is left to translate anything.
| Tool | Its answer | What it costs there |
|---|---|---|
| JavaScript build tools | a source map from generated byte ranges back to the source | a map file beside the output that has to stay in step |
rustc |
--error-format=json: file, line, column and byte offsets, a stable, documented interface |
nothing; it exists so that nobody scrapes rendered text |
| Rust’s panic hook | runs before the process goes down, under panic=abort (exit 134) and panic=unwind (exit 101), and info.location() gives the generated file and line — measured |
the location is the generated one; translating it is ours |
What does not transfer: a stored map file. Nikaia’s lowering is deterministic, so the map can be recomputed instead of kept (D2).
| Option | Rejected because |
|---|---|
| Re-implement the checks in Nikaia | ADR-296 D18, not reopened: one error class, a second implementation of a specification, and it fails by silent divergence |
| Write the map beside the generated file | an artifact that goes stale, a format to version; determinism makes it unnecessary (D2) |
| Rewrite messages into Nikaia’s vocabulary | a second compiler’s worth of knowledge kept in step by hand, to improve sentences that are already about the source (D3) |
| Marker comments in the emitted Rust | changes the compiled text, does not survive rustc’s spans into macro expansions, and gives line granularity where byte ranges are free |
| A line map rather than byte ranges, at build time | the frame error points at a pattern; a line map would blame the whole rule for the same effort |
Parsing rustc’s rendered output |
--error-format=json exists so nobody has to |
| Fix each abort path on its own | four fixes, and a fifth the day a fifth abort path arrives (D10) |
| A table of only the lines that can abort | a list that would have to be kept correct as the emitter grows, and an omission is an abort with no line — the defect itself (D9) |
While the emitter walks the AST it 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. One mechanism for every error class: 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. A project’s map records which file each unit came from
(ADR-286 D9).
The lowering is deterministic, so the map is recomputed from the .nika file
when needed rather than written beside the .rs:
cargo build --message-format=json | nikaia explain measurements.nika
No artifact to version, no format to document, and no way for the map to go stale with respect to its source. ADR-021 D8 rests on the same determinism.
The text passes through unchanged. Because the lowering is name for name
(ADR-296 D17), the names in the backend’s message are the user’s
names, and 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. Where a message’s nouns are Rust’s and not the author’s —
E0277, E0621 — rewriting the text is ADR-005 D7’s open
question, not this record’s.
Nodes are recorded 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: let x = f(g(y)) blames the whole let.
Expression spans need no new mechanism and are not claimed.
The preamble, the use lines and synthesised bindings come from no source. An
error there is reported against the generated file with that stated —
error: measurements.rs:3: <message> (no Nikaia source maps to this) — because
a guessed nearest line is frequently unrelated and indistinguishable from a
real one.
A rule written -> T @= gets _span, the byte range it matched. Every node
carrying a span gets it from there: no offset arithmetic, no second stream, no
way for a span to disagree with what the parser consumed.
rustc’s JSON, not a build system.--error-format=json gives file, line, column and byte offsets; nothing is
scraped from rendered text. Nikaia reads it and writes diagnostics about the
.nika file, which composes with whatever drives the build.
nikaia run is a build and then a run. The build reads every diagnostic over
D7’s channel; the run cannot, because the program’s own output is on the same
stdout. Run through cargo run, Cargo replays its cached diagnostics to
stderr while checking freshness, so a program with a warning would see it
twice — once in this language’s words, once as rustc about the generated
file. So the build runs the program directly.
The path is read, not computed: the build’s JSON names it on the
compiler-artifact line whose executable is not null. Where no line says,
cargo run is still what happens, so this can only remove a duplicate and
never lose a run.
The emitter knows both ends of every line it writes; the table is that knowledge kept. It belongs to the program, not the compiler, because it is needed when the compiler is gone. It holds every mapped line, not a judgement about which can abort, and it is appended after the program, never prepended, so it moves no line it names. Where two spans cover one generated line, the outermost wins: a whole line is better named by its statement than by the sub-expression that starts it (the build-time lookup of D4 answers a byte, and keeps the innermost).
The hook is global and runs on every abort path (ADR-297 D10,
Part I 7.2), so the translation goes in one place and reaches every case at
once — an overflow, a conversion that does not fit, an index out of bounds, a
written panic():
src/main.nika:2: the program stopped: attempt to multiply with overflow.
The lookup is a binary search, so the hook stays sync. A location the table
does not know is handed to the hook installed before this one, which is Rust’s
own, so a program is never worse off. A std helper that can abort carries
#[track_caller], so the location is the caller’s generated line and not a
line of std’s Rust.
Nothing in D9 or D10 is about arithmetic; an overflow (ADR-285) is one abort path among the others. The table translates a site, not a stack: what a trace looks like is ADR-280’s.
The rule holds without an exception: for everything the compiler says (D1-D8) and for everything the program says when it aborts (D9-D10).
What the program pays: a table of mapped lines in the binary — paid by every program, needed by the ones that fail, the trade a panic message already makes.
Risks carried over. The map is only as good as the emitter’s discipline: a
node emitted without its span loses precision silently, and a general “every
node is covered” check is not attempted. Only rustc’s diagnostics are covered
at build time — linker errors and what Cargo says on its own account are not.
A synthesised construct maps to its enclosure, having no truer position.
E0277, E0621) — ADR-005 D7’s open half.emit_program (the path tests and
lower-std use): it is handed one Parsed and no file name, so its table is
empty and it falls through to Rust’s hook. Every
path a user takes goes through modules::Program, which knows every file.