ADR-082: A Grammar Is Entered by a Call, and Every pub Rule Is an Entry

Status: Superseded by ADR-296 — its D1-D4 are D24-D27 there Date: September 15, 2026 Supersedes: the dsl <grammar> from <expr> form of Part II 10.2. The block form, dsl <driver> { … } eod, is untouched and is not what this is about Related: ADR-007 D4 (the two binding modes of a block, which stay), ADR-073 and ADR-077 (comptime, which is where a build-time parse will stand), ADR-079 (what a build-time result may be when it crosses), ADR-072 and ADR-116 (asset("…"), the read of a file at build time, which is a different thing from a grammar)


1. Context

dsl spells two constructs that have almost nothing in common.

dsl mysql { SELECT … :id … } eod    // foreign source, with holes
dsl Json from input                  // parse a value

The first embeds text in another language, with :name holes that reach Nikaia (ADR-007 D4). It earns a keyword: its body must not be read as Nikaia at all, which no call can arrange.

The second parses a value with a grammar declared in the file. It earns nothing, and it costs three things that only became visible when they were listed together.

The name comes from a different place in each. dsl Json from x looks Json up among the file’s grammars and refuses if there is none. dsl mysql { … } eod does not resolve its target to a grammar at all — Part II 10.2’s own status note says so — because mysql names a driver. One keyword, two namespaces.

The same line means two different things in two positions. At run time dsl Json from "config.json" parses the eleven characters config.json. In a build-time binding the same line was meant to parse the contents of that file. Part II 10.2 presented that as a feature — “the same syntax … follows from the context, not from a different spelling” — and it is the one thing this project refuses everywhere else.

A form that is not a call has to re-earn what a call gets free. The emitter already carries special handling for a DslFrom inside a catch and for whether the ? is written. That is the cost, in code, of standing outside the shape the language already has.

2. Decisions

D1 — A grammar is entered by an ordinary call, and the rule’s name is the method’s.

let data = Json.value(input)

Json is the grammar, value is a pub rule in it. Nothing new is parsed: this is the call form the language has, pointed at a name it did not previously accept as a callee.

What it buys is everything a call already has and the old form had to be given by hand — ?, catch, being passed on, being chained.

D2 — Every pub rule is an entry, and there is no distinguished one.

This is not an invention; it is what the layer below already does. winnow-grammar generates one function per public rule, named after it — Cron::parse_schedule() — and has no notion of a main rule.

The distinguished entry was ours, and it is what produced the defect. The emitter picks one like this:

def.rules.iter().find(|r| r.is_public && par_fold_of(r).is_some())
    .or_else(|| def.rules.iter().find(|r| r.is_public))

The first public rule wins, and one that happens to be a par_fold beats an earlier one that is not. A grammar with two public rules gets one of them, by source order, silently. D1 removes the question rather than fixing the pick.

D3 — There is no parse alias, conventional or otherwise.

Considered and refused. An alias that exists only while a grammar has exactly one public rule is a name that appears and disappears with its neighbours, which is worse than no name. An alias a grammar must declare says a second time what the rule name already says. A program that wants to write Json.parse(x) names its rule parse.

D4 — Where the bytes come from is said separately, by asset("…").

The read of a file at build time is its own step — where bytes come from, named three times, checked against an allowlist (ADR-072, spelled by ADR-116). That has nothing to do with grammars, which is why the two are not spelled alike:

comptime ROUTES = Routes.table(asset("routes.txt"))

Three visible steps, each already decided on its own: comptime says when, asset("…") says where the bytes come from, Routes.table says what is done with them. The reading that used to depend on position is now written down.

3. What this does not touch

The block form, which is the one that carries foreign syntax with holes reaching Nikaia — SQL parameters, template values, a register name in assembly. Every decision ADR-007 D4 makes about it stands, and the question of what a driver says about itself is a separate one, not answered here.

Once D1 lands, dsl takes a driver name and only a driver name, which is one namespace instead of two.

4. Consequences

A refusal disappears — “grammar … has no pub rule to enter through” — because with every public rule an entry there is nothing to fail to find.

Part II 10.2’s dual-mode claim gets weaker and truer. A grammar is still used at both stages with one spelling; what changes is that the spelling now means the same thing at both, and when it runs is said by comptime rather than inferred from where the line sits.

Two emitter special cases can go with the form they served.

5. What is built

All of it, in one change. Grammar.rule(expr) is the entry — it was already a method call in this language’s grammar, so nothing in the parser had to learn a shape; what it needed was a checker that counts a grammar name as declared and an emitter that recognises the receiver. Every pub rule is an entry in the ledger with throws = ["?"], so the catch written beside one is answered from a contract and ADR-091 D4’s one-line bridge is deleted with the form it was for. dsl X from e is refused at the parse, with a message naming X.rule(e) — the shape ADR-022 gave fn:.

The defect went with it. entry_rule — the first pub rule, and a par_fold one ahead of an earlier one — is deleted rather than narrowed, and a_grammar_with_two_public_rules_is_entered_by_either is a grammar where the old choice would have taken the wrong one whichever was asked for.

Two things the record had not said, both met on the way:

Migrated in the same change: eight programs on nine lines, examples/README.md, Part II’s own blocks and the three test files, so there is one form in the language at every moment and no deprecation window with two spellings for one thing.

What the entry says, and what it took to say it

An entry was written with a signature, throws and nothing else, so the columns that are a question about a body were absent — and a caller inherits an absence: examples/inventory’s read, whose body is one Stock::file(data), came out with no touches and locks = "?". That was #134.

sync was answered by ADR-142 D1, which says an action may not pause. touches and locks are derived over the rule’s action blocks, which are ordinary Nikaia — the same walks a function’s columns come from, reading the same bodies.

What that needed first was a key. The checker files a call’s answers under the enclosing function, and an action has none, so a rule’s answers landed nowhere at all — six calls in the corpus were not merely unanswered, they were uncounted. A pub rule is an entry, so it has the entry’s key now; what the key must not do is make an action a function, and NK2605 is where that is said instead.

The grammar is the unit rather than the rule, which is the over-approximation ADR-033 D4 asks for in this column: a rule’s pattern names other rules of the same grammar and their actions run with it, and which ones is the parser backend’s question rather than this walk’s.

keeps is left, and it is the tether’s question. A parse hands back views into its input, so an entry keeps its input — which is ADR-008’s tether rather than anything an action says. Absent is the safe reading meanwhile: nobody said makes a caller hand the input over whole, where a derived keeps = [] would lend it to a parser that tethers views into it.

This site is open source. Improve this page.