ADR-136: A Number Literal May Be Written 1_000_000, 0xFF, 0b1010 or 0o17, and the Digits It Was Written In Are a Spelling

Status: Superseded by ADR-285 — its D1-D3 are D16-D18 there Date: September 19, 2026 Target Version: Nikaia 0.0.18 Related: ADR-060 (a literal no use constrains takes the first type that holds it), ADR-043 D5 and ADR-063 (a constant that fits, NK1116), ADR-048 (the numeric surface), Part I 2.2 and 2.4

1. Context

let n = 1_000_000 is two statements: the number 1 and a name _000_000 that nothing declares, which is NK1117. 0xFF is the number 0 beside a name xFF. The grammar is scannerless, so a separator and a radix prefix are not syntax errors — they are a misparse, which is the class Part III C.1 is about and the class NK1117 was built to report rather than hand to rustc.

The forms are Rust’s and the lowering is verbatim, so nothing here is expensive. One question is the owner’s.

Does a hexadecimal literal carry a width? 0xFF is eight bits written out, and a reader may take that as a u8. Or the radix is a spelling and 0xFF is 255, which takes the first type that holds it exactly as 255 does (ADR-060).

2. Decisions

D1 — The four forms are 1_000_000, 0xFF, 0b1010 and 0o17.

An underscore may stand between digits and nowhere else: not first, not last, not beside the radix prefix. 0x, 0b and 0o are lower-case, and the digits after 0x may be either case. A float takes the separator too — 1_000.5 — and no radix prefix, because a hexadecimal float is a shape nobody writes.

D2 — The radix is a spelling, and a literal is a value.

0xFF is 255. It takes the first type that holds it, which is ADR-060’s rule unchanged, so let mask = 0xFF is an i32 and let mask: u8 = 0xFF is a u8. A literal that fits nothing is NK1116, which is the refusal it already gets in decimal.

Because the alternative makes the width invisible. If 0xFF were a u8, then 0x0FF would be a u16 and 0xFF + 1 would overflow where 255 + 1 does not — a type that depends on how many zeroes somebody typed. What a reader wants from 0xFF is the bits; what they want from the type is the declaration, and this language has one place for that.

D3 — The separator is not in the value, anywhere.

1_000 is 1000 to the checker, to the ledger and to the constant fold. It does not survive into a diagnostic’s text either: a message about let n = 1_000 names 1000, because that is the number, and naming the spelling back at a reader who wrote it says nothing.

3. Consequences

4. What this does not decide

5. What is built

All three steps.

  1. The four forms, and the underscore refused where it does not stand between digits — doubled, last, or beside the prefix. A leading underscore is not among them and never could be: _000 is a name, in every language that has both, and a rule that read it as part of a number would take let _1 = 5 away. A float takes the separator (1_000.5) and no prefix, which it has to: without it 1_000 stands beside .5, and .5 on a number is a tuple part.
  2. The fold reads the value, which took nothing: what the parser hands up is the number, so NK1116 and NK1118 were already answering about it. D3 falls out of the same thing — the separator is not in the value, so it is not in a message either.
  3. NK1117’s help lost the clause. It explained that 1_000 is 1 beside the name _000, which stopped being true here; a help text that explains a form the language has is worse than no help at all.

Written in Rust rather than in the grammar, and for two reasons the DSL gives rather than a preference. Its character classes are digit and any, so a binary digit and a hexadecimal digit have no spelling in it; and an action cannot fail, so a number too wide for an i64 could only be parse().unwrap() — which is what it was, and 99999999999999999999 took this compiler down rather than the program. extern rule number_lit -> i64; is the declaration and the parser stands beside the grammar.

Two things beyond what the record asked for, both of them the same misparse one step along. A digit the radix does not have — 0b1210, 0o19 — is refused, because otherwise the number ends at the bad digit and what follows is a second number nobody wrote. And the refusals put the caret on the character that is wrong rather than at the start of the literal: a diagnostic is ranked by how far the parse got, so one built at the start loses to the float rule’s failure further along, which is a true sentence about this parser and no help to a reader.

And one number the record left without a spelling, since given one. -9223372036854775808 is i64::MIN and belongs to the type; as a negation of a positive literal its digits are 9223372036854775808, which no i64 holds, so the parser refused a number that is in the language. Every other number in the range parsed either way, which is what made it one number missing rather than a hole. A - written directly against the digits is now part of the literal, and the sign goes into the text the radix parser reads rather than being applied to what came out — so -0xFF is -255 for the same reason 0xFF is 255 (D2).

Only where it is against them, which is the whole of the rule: - 5 and -x are the unary operator they always were, a binary - is matched by the rule that wrote it rather than by its operand’s, and a . or an exponent after the digits hands the - back — -1.16e+00 is a float and -2..<2 is a range, and examples/n-body/src/main.nika is what said so the first time the rule ran.

#135 carried both.

This site is open source. Improve this page.