ADR-051: This Language’s Keywords Are Reserved Words

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)

1. Context

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.

2. Decisions

D1 — There is a list of reserved words, and a name may not be one of them.

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.

D2 — The list is the Nikaia level, and the grammar sublanguage’s words are not on it.

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.

D3 — After a separator that can only be followed by a name, a reserved word is a name.

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.

D4 — 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.

D5 — A parse error over a reserved word says that that is what happened.

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.

3. Consequences

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:

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.

4. What this does not do, and one thing that does not work

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.

5. What is built

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.

This site is open source. Improve this page.