ADR-171: A Refusal From the Lowering Names Its Line

Status: Accepted Date: September 20, 2026 Related: ADR-300 (spans, the source map, and diagnostics on the .nika line), ADR-288 (the flow carries the statement’s byte, for the checker’s answers), Part III C.2 (what every diagnostic owes a reader), C.1, docs/project_status_and_roadmap.md Phase 2, which carried this as the compiler’s own anyhow errors, which are still text without a position

1. Context

Part III C.2 asks every diagnostic for four things: a headline, the line with a caret under it, the reason, and a concrete way out. Every NK… code gives all four. A refusal the lowering makes gave the first and the last and nothing in between:

this lambda calls `fs::read_to_string`, which can pause - and a lambda that
pauses is not something this compiler can build yet. Call
`fs::read_to_string` outside the lambda and hand it the value, or give it a
`sync` body

True, and a reader with a file of any size is left searching for it.

The byte is already in hand. Flow::statement carries the byte the statement being emitted starts at, threaded for the type checker’s answers (ADR-288) — and it is the same number a caret wants.

2. Decisions

D1 — A refusal carries the byte it is about, where it knows one.

Refused carries it, and refused_at! is refused! with the byte in front. None stays the answer for a refusal about no statement in particular: a manifest key, a switch, a whole file.

A refusal inside the expression and statement walks takes the statement’s byte from the flow in hand. A refusal about an item takes the item’s span, which the emitter’s item arm is already handed for the source map’s sake.

And a helper with no place of its own borrows the statement’s. emit::interpolation works on the text of a string literal: it knows an { was never closed and cannot know where the string is, because it never saw a file. The caller is emitting a statement and knows exactly where, so the handover happens there — written once rather than at each call, which is what keeps where from being something each refusal has to remember. The same handover (at_the_statement) covers the grammar entry, the dsl family and template.rs, which work on text.

A refusal about no statement keeps none. A build switch — a --target or a user-parallelism nobody can spell — is about a manifest key and a whole build; a line number for it would be an invention, and inventing one would be worse than having none.

D2 — The line is rendered where the source is, which is the unit being lowered.

The lowering knows the statement and not the path; whoever catches the error at the top knows neither, because a refusal travels as an anyhow::Error through a build that has moved on. The one place with both is the per-unit loop, and that is where a positioned refusal becomes the shape ADR-300 gave every other diagnostic:

error: an `overlap` block needs at least two branches; one statement has nothing to overlap with (Part I, 8.1.2)
  --> one.nika:4:5
   4 |     let x = overlap {
           ^

The same shape as the checker’s, deliberately: a rule enforced in the lowering and one enforced in the checker should not look different to a reader, who has no reason to know which half of this compiler refused them.

No code in front of it, because these refusals are not in the catalogue. Whether they should be is §4’s question.

3. Consequences

4. What this does not decide

This site is open source. Improve this page.