pub Rule Is an EntryStatus: 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)
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.
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.
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.
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.
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.
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.
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.
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:
uses_driver asked whether the picked rule is a par_fold; with
the rule named at the call it has to ask about the rule named. The lowering
was right and the preamble was not, and rustc answered “use of undeclared
type ParseContext” about the generated file.pub needs its own sentence. A grammar name can only
stand where a callee stands, so Two.N(data) is an entry naming a private
rule rather than a method call that missed — and there is no such rule,
about one written three lines up, is a message a reader cannot act on.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.
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.