ADR-300: A Diagnostic Names the File the User Wrote — at Build Time Through a Source Map, at Run Time Through a Location Table

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)

1. Question

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.

2. How others do it

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).

3. Options, weighed

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)

4. Decision

At build time

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

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).

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:

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.

D3 — Positions are corrected; messages are not.

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.

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

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.

D5 — An unmapped diagnostic says so.

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.

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. 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.

D7 — Nikaia is a filter over 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.

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

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.

At run time

D9 — One table beside the program: generated line to Nikaia file and line.

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).

D10 — The panic hook looks the site up before it prints anything.

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.

D11 — The run-time half stands on its own.

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.

5. Consequences

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.

6. What this does not decide

This site is open source. Improve this page.