Status: Superseded by ADR-298 — its D1-D5 keep their numbers there; its list of twenty-nine words is carried as the rule, the list itself is Part I 2.1’s
Related: ADR-001 D2 (the scannerless grammar, which is why
there was no list to begin with), ADR-007 D5 (Self::dsl, the one
type spelled with a keyword), ADR-022 (the fn: … refusal, whose
fail shape does not work here and §4 says why),
ADR-050 D2 (overlap, reserved before the construct exists),
Part I 2.1 (where the list is written down)
rule NAME = not(digit) n:ident — a name was any identifier that did not start
with a digit, and nothing was excluded. So let fn = 3 parsed, and
open-decisions.md put the question that had never been put - and this record
is the answer, so the entry is gone from that file: are this
language’s keywords reserved?
The state was not a decision. The grammar is scannerless (ADR-001 D2): there is no lexer classifying words, so there was no place a reserved list would naturally have lived, and none was written. The keywords were not chosen to be contextual — the choice had not been made.
What it cost is measurable, and it is the worst class this project names.
Three rows of error-corpus.md were marked “parses”
with the note that they were “things the grammar admits that probably should not
be”. They were one thing, and it was not that:
if { } // read as: the variable `if`, then a block
let 5 = x // read as: the variable `let`, then `5 = x`
if a = b { } // read as: the variable `if`, then `a = b`, then a block
Each is a program that means something other than what is written, with no
diagnostic of any kind. Beside them, let fn = 3 lowered to let fn = 3; and
was refused by rustc with “expected identifier, found keyword fn” — about a
file nobody wrote, which Part III C.1 forbids.
as catch dsl else enum false fn for
grammar if impl in let match mut overlap
pub return self seq spawn struct sync throw
throws true use while
Twenty-nine words. The list is in Part I 2.1 so that a reader can see it, and in
the compiler as parser::RESERVED_WORDS so that a diagnostic can read it.
Why reserved rather than contextual. The reason a language makes a keyword
contextual is backwards compatibility: a new reserved word breaks programs that
used it as a name, so it is introduced in a position where it cannot be mistaken.
var, async, await and nameof are contextual in C# for exactly that
reason, and Go, which never had to add one, reserves all of them. Nikaia has no
programs outside this repository, so the reason does not apply, and the choice
is free today in a way it will never be again.
rule, boundary, fold, par_fold and unchecked are keywords inside a
grammar block (Part II 10.1–10.7) and words a program may want everywhere
else. A grammar block is a sublanguage with its own vocabulary, and a word
reserved there is reserved there.
That is not a concession to make the list shorter: it is what the list is. A
reserved word is one the reader of an ordinary Nikaia statement could mistake for
a construct, and fold( in a statement could not be — no Nikaia statement begins
with it.
Two positions: after :: and after ..
Self::dsl // the shadow type of a deferred-parameter DSL
scope.spawn fn { … } // a method whose name is a keyword
Nothing can be misread in either place. A segment follows a :: and a member
follows a .; no construct begins in that position, so the word is a name
whatever it is elsewhere. This is not an exception list of two spellings — it is
one rule about where ambiguity can exist, and Self::dsl
(ADR-007 D5) is the case that made it necessary rather than the
reason for it.
In the grammar this is a rule of its own, SEGMENT: NAME without the check.
Every position where a name is declared, or where one stands on its own, uses
NAME, and that is where the list bites.
self is on the list and the grammar cannot enforce it, so the checker does.self is the one reserved word that is a name: self.min refers to it. And
NAME is the rule both for declaring a name and for referring to one, so
excluding self from it would refuse every method body in this repository.
So the list carries self and the refusal moves to where the declaration is:
NK1119, at a let, a for binding, a lambda’s argument and a struct field.
Which is the better place anyway — a parse error can say “a reserved word is not a name” and
no more, and NK1119 can say what self already names and that it, this and
me are free.
Every declaring position is covered, and the last two took work of two different kinds — which is worth recording, because the first guess about them was wrong.
FieldDef. Without one the
nearest span that walk had was a statement’s, on a different line, and a caret
on the wrong line is worse than no message. field_def takes @= now and the
caret lands on the field.A parameter turned out never to have parsed, and NK1119 is not where it
belongs. fn f(self: i64) is refused by the grammar: the receiver rule takes
the word and the : i64 has nowhere to go. What it said was “expected );
found :“, so the arm that says it in a sentence is in receiver, before the
bare self alternative.
And that arm consumes the colon rather than peeking it, which is the
opposite of what ADR-046 D2’s import refusals do — measured both
ways. With peek(":"), or peek((KW_SELF ":")), the bare arm below reaches
just as far and its “expected )” wins, because a fail here is high
priority and not fatal: progress before priority, the same rule
#235 recorded about the dsl block. So the arm has to get further than the
alternative, and the cost is the caret, which lands on the type rather than on
the word. The sentence is what carries the answer.
expected `mut`; found unexpected token `fn` at line 2, column 9
2 | let fn = 3
^^
note: `fn` is a reserved word, so it is not a name (Part I, 2.1). Either pick
another name, or - if the construct was meant - it does not belong in this
position.
The headline is the grammar’s and is about the position; the note is about the word, and it is the half a reader acts on. It is read off the rendered message rather than off the error, because what a reader needs is attached to what a reader sees and the backend’s error carries the position but not the word. Where the shape it looks for is absent, the note is absent — a note that disappears is the safe way for this to be wrong, since it adds a sentence and corrects nothing.
Three silent miscompilations become refusals, which is what this record is
for. error-corpus.md rows B3, C4 and D2 were ○
parses; two are now refusals whose message is token soup and one, D2, says
expected `{` at the = — and that row also loses the “open question” it
carried, because an assignment is not an expression and if a = b is now
refused rather than read as three statements.
#235’s dsl block gets its diagnostic, and by the categorical
route rather than the local one. That entry offered a cut — once dsl NAME {
matches, forbid backtracking out of the rule — or reserving the word. A cut
repairs one diagnostic for one construct; the list repairs the class, and every
keyword anybody adds later comes with it.
(The grammar machinery does have a cut, written =>, for user-written grammars.
Whether the compiler’s own grammar can reach it was not established, and does not
change the order: a local repair against a categorical one.)
What it cost in this repository: one identifier, renamed. seq was a
DNA sequence in examples/k-nucleotide/src/main.nika (now dna). That is the
whole measured price, and it is worth stating as a number: a decision whose cost
is two renames in the only corpus that exists is a decision taken at the right
time.
Two specification forms become parse errors rather than different programs, and both were already recorded as unbuilt:
panic::on_panic fn(info) sync { … } (Part I 7.2, ADR-006 D6) —
nothing in the grammar gives a lambda an effect annotation, so the line was
three statements. It is refused at the sync now.spawn fn { … } (Part I 8.2) — the rule is spawn "(" expr ")", so the
keyword form never reached it. It was read first as a variable called spawn
and later, once a trailing lambda could reach a path, as a call to a function
of that name. Two wrong readings, both reported by rustc; now one refusal,
reported here.Neither is progress on what those forms mean — that is #219’s spawn
entry and Part I 7.2’s own note. It is progress on who says they do not work yet.
A program may not name a variable fn, spawn, seq, overlap, dsl.
That is what every reserved word costs in every language, and it is the price of
a word meaning one thing wherever it appears.
It does not make the grammar a two-pass one. There is still no lexer; the
check is not(RESERVED) inside NAME, one alternation of the KW_* rules that
already existed. The KW_* rules carry their own not(ident) boundary, so the
order of the alternation does not matter — throw does not match the start of
throws.
A fail("…") arm does not work here, and this is worth writing down because it
is the third time it has been tried. ADR-022’s fn: … refusal
uses fail, and the shape looks right: match the reserved word, hand back a
sentence. It was measured, and fail in this library records an error and lets
the arm succeed — so NAME returned an empty symbol and let fn = 3 lowered
to let = 3;. A worse defect than the one being fixed, and reverted. fail
belongs where a reading has to be replaced by a message, not where it has to be
removed.
It does not decide whether a reserved word may be a struct field or a
parameter. D3’s rule covers reaching one (p.from is a member after a .);
declaring one is refused by NAME for every word but self, whose two remaining
positions D4 names.
All of it. RESERVED and SEGMENT are in the grammar, RESERVED_WORDS and
the parse-error note are in parser, NK1119 is in the checker, and Part I 2.1
carries the list.
crates/nikaia/tests/parser.rs holds the Rust list and the grammar rule together
by behaviour rather than by comparing texts — every word in the const is
refused as a name, self is the documented exception, the sublanguage’s words are
still names, Self::dsl still parses, and the three silent misreadings are
refusals. crates/nikaia/tests/typecheck.rs covers NK1119 and that referring
to self is untouched.