ADR-011: Stage 0 Lowering — the Grammar Protocol onto the Parser Backend

Status: Superseded by ADR-296 — its D1-D8 are D16-D23 there Implements: ADR-009 D1–D3 and D5 on the Nikaia side Related: ADR-001 D4 (Stage 0 is a transpiler), ADR-004 D1 (the one lowering this is the construct-by-construct half of), ADR-007 (the DSL protocol), ADR-008 (views and the input lifetime), ADR-012 (spans, which close half of §4)

1. Context

Everything the parallel grammar protocol needs exists in the parser backend. Nikaia could not reach any of it, because a grammar item in the AST was:

Grammar { name: Ident, content: String }

A string is a shape no compiler can do anything with. It cannot verify a frame, cannot find the entry rule of a dsl … from …, cannot decide the driver’s Parallelism from user_parallelism, and cannot even rewrite @frame to #[frame] without parsing it first.

This ADR records what the lowering is, and — more importantly — what it deliberately refuses to do.

2. Decisions

D1 — A grammar is parsed, not carried as text.

grammar Name { … } becomes a structure: rules, alternatives, patterns, the commit point, repetition bounds, fold/par_fold with their expressions, and the keyed @frame attribute. dsl Name from input becomes an expression naming a grammar in the same file.

Keeping the body as text fails at the first question anyone asks of it. Which rule does dsl Measurements from data enter through? Is it a par_fold, so that a piece driver exists? Does this grammar declare a frame at all? All three are answered by looking at the rules and none by a string.

D2 — The lowering is syntactic, and never guesses a meaning.

Every action block, every init/step/merge, every function body is emitted as written, name for name. Where Nikaia and Rust disagree about what a name means, the emitted program says so by failing to compile; the printer does not invent semantics to paper over it.

The case this rules on: a par_fold whose step is written fn(acc, m) { acc.record(m) }, where record is a &mut self method. A monoid’s step is (Acc, Item) -> Acc, so making it fit means emitting |mut acc, m| { acc.record(m); acc } — inferring from the shape of the tail expression that the method mutates and the accumulator should be threaded. Rejected: acc.merged(m), which returns a new accumulator, has the same shape and would be silently mis-lowered. The adapter is a typing question, answerable once the compiler lowers impl blocks and knows the receivers — never in an emitter.

D3 — @frame lowers key for key, and Nikaia does not re-implement its check.

@frame → #[frame], @frame(boundary: "\n") → #[frame(boundary = "\n")], and unchecked passes through. The boundary keeps its escape text, so what the source wrote is what the backend is handed.

The check that a boundary cannot occur inside a frame stays upstream. Two implementations of ADR-009 D1’s “checked, never rewritten” would eventually disagree about what is safe, and the version of that disagreement that matters is where Nikaia accepts a grammar the backend rejects — a compile error against generated code, blaming a line the user never wrote. One verdict, one place.

unchecked passes through for the same reason: it is the user’s assertion to the checker, and a second opinion is only a second thing to get wrong.

D4 — user_parallelism chooses the Parallelism, and nothing else.

dsl Name from input lowers to the piece driver with Parallelism::Auto under auto and the identical call with Parallelism::Off at 0. That is the whole of ADR-009 D2’s degradation: same accumulator, same merge, same result, no threads.

The two emitted programs differ in one token. Nothing else in the lowering reads either switch, because nothing else may: a switch selects a build, not a dialect (ADR-037 D1), and the moment it changes what a program means the language has two of them.

A grammar whose entry rule is not a par_fold has no pieces to cut and no merge to apply, so it gets the rule’s own parser, run once over the whole input.

D5 — A rule that is a par_fold gets its binding from the compiler.

ADR-009 D2 requires a par_fold to be the whole body of its rule, so there is nothing an action block could add: the value of the rule is the value of the fold. Nikaia accepts the rule without one and supplies the binding the backend wants.

Everywhere else an action is required, which is also what makes a rule’s pattern unambiguous: the pattern ends at its -> rather than wherever the next rule happens to begin.

D6 — A view takes the input’s lifetime, and the struct holding it comes along.

&str in a rule’s return type is emitted as &'a str, 'a being the lifetime the backend gives its input. A struct with a view field is emitted with <'a> and named with it wherever it appears, transitively.

How it is spelled follows the position and nothing else: named inside the grammar module and on the structs it builds, where the two must be tied together; elided in a function signature, which declares no lifetime and would not compile with one named.

This is ADR-008 in the only place a bootstrap compiler can express it — the source never writes a lifetime, because & is a view marker there. The tether-state lattice is not implemented: every view is emitted as Borrowed, which is correct for the grammars written so far and wrong the moment one escapes its buffer. A named limitation, not a design.

D7 — Comments are whitespace, and string literals are Nikaia’s own.

WS = (WSE | COMMENT)* is the only place comment skipping can be said, since the backend inserts WS between the tokens of every syntactic rule.

The backend’s built-in string recognises \\ and \" and no other escape — one short of "\n", which is the boundary every frame in the corpus is written with. So the compiler brings its own lexical STRING, which keeps escapes as written. Keeping them is what makes D3’s “the backend is handed the same "\n"” true.

D8 — A grammar reaches the parser backend through the transpiler, as a macro invocation.

The lowering emits grammar! { … } and lets the parser backend expand it, name for name with what the .nika file wrote. A grammar item is never dropped silently, which would compile a program with its parser missing.

3. What this makes of the corpus

The grammar half of the flagship lowers end to end. Its emitted Rust is checked in, and the test guarding it does not only compare text: it include!s the file, so the backend has to accept every line, and it runs the generated driver at five different cuts to check that the answer does not depend on how the input was divided — on an input it accepts and on one it rejects.

4. What is deliberately not here

Named so that “the compiler lowers the grammar protocol” is not read as more than it is:

5. Rejected alternatives

6. Risks

This site is open source. Improve this page.