match Takes Six More Pattern Shapes, a Guard Is if, and .. in a Pattern Is Inclusive — So ..< Is the Exclusive Range EverywhereStatus: Superseded by ADR-291 — its D1-D6 are D10-D15 there
Date: September 19, 2026
Target Version: Nikaia 0.0.18
Related: ADR-126 (_ is the ignore pattern), ADR-098 (a flat tuple of names), ADR-084 (what a keyword costs), ADR-051 D1, Part I 3.3 and 3.4
match matches a literal, a path with bindings and _, and nothing else.
Six shapes a reader expects are missing — a tuple, an or-pattern, a range, a
guard, a nested pattern, and .. for the fields of a struct — and their
absence shows in the corpus: examples/calc/src/main.nika matches step.0 because it
cannot match step.
Each is Rust’s and lowers verbatim, so five of the six are work rather than a question. Two are the owner’s.
What a guard is written with. Rust writes if; a language could write
where.
What a range pattern is written with, and it is the sharper one. Rust
writes 1..=5 for an inclusive range and 1..5 for an exclusive one. This
language’s .. is exclusive — for i in 0..n stops at n - 1 — so a
pattern 1..5 would mean one thing here and a reader from Rust would read
1..=5 looking for it. There are two ways out: take Rust’s ..= for the
inclusive pattern, or make .. inclusive in a pattern and find another
spelling for the exclusive range.
This language already has ..= in an expression — Part I 3.3 writes a..b
excludes its end and a..=b includes it, and the parser takes both — so the
second way out does not add an inclusive form, it makes .. a second spelling
of one that exists. D6 is what that costs.
match point {
(0, 0) => "origin", // a tuple
(0, y) | (y, 0) => "on an axis", // an or-pattern
(x, y) if x == y => "diagonal", // a guard
else => "elsewhere",
}
match code {
200..299 => "ok", // a range, inclusive at both ends
else => "other",
}
match event {
Event::Click(Point { x, .. }) => x, // nested, and `..` for the rest
else => 0,
}
A tuple pattern, an or-pattern with |, an inclusive range, a guard, a
nested pattern, and .. standing for the fields a struct pattern does not
name. The catch-all arm is else (ADR-145), which is what these
examples wrote as _ before that record. Every binding an or-pattern’s alternatives make must be the same set of
names, which is the rule that keeps the arm’s body answerable.
if.(x, y) if x == y =>. The word is the one every neighbouring language uses
for this and the one this language already uses for a condition; a second word
for the same idea would be ADR-084’s cost for nothing.
.. in a pattern is inclusive at both ends.200..299 matches 200 through 299. A pattern is a set of values and a
reader reads it as one; there is no counting-to-n in it, which is what makes
the exclusive reading natural in a for and unnatural here.
..<, everywhere in the language.for i in 0..<n is the loop that stops at n - 1. .. is inclusive wherever
it is written — a for, a slice, a pattern — and ..< is the exclusive one.
One meaning per spelling is the whole of the argument. The alternative is
.. exclusive in an expression and inclusive in a pattern, which is a rule a
reader has to hold in their head and a rule the compiler cannot help them
with: both forms parse in both places and mean different things. Kotlin and
Swift are the precedent for ..< and both read it the same way — up to, not
including — beside a .. that includes its end, which is exactly the pair this
decision takes.
..< is never written in a pattern. A pattern is inclusive, and an
exclusive one is written by moving the end: 200..298. That keeps every
pattern one shape.
..= goes, because .. is what it says.0..=n stops parsing. It is the inclusive range this language already had, and
D4 makes 0..n the same range — so keeping it would be the two-spellings defect
of ADR-140 introduced by the record that argues one meaning per
spelling. The refusal names the form: .. includes its end; write 0..n.
Nothing writes it, which is what makes this a line rather than a migration:
no .nika file in examples/, in tests/ or in crates/nikaia-std/ writes a
..= range, and outside the parser and the emitter the tree’s four mentions are
two Rust for loops in the compiler’s own source, one prose sentence on Part I
3.3, and one test asserting the lowering.
Every a..b that is a range becomes a..<b. It is a rewrite a reader can
check by eye and a compiler can check exactly, because the old spelling keeps
parsing and changes meaning — so the migration is done in one change, with the
pages and the corpus in it, and not spread out.
calc.nika matches step instead of step.0, and the six shapes stop
being absences a reader trips over...< in the range rule, ..= refused, and the migration of
every range in the corpus and the specification.@ (n @ 1..5), which is a seventh shape and waits for
a program that wants it.else. What is still
this record’s is whether a set of ranges or an or-pattern counts as
coverage — that record’s §4 says so and waits on D1.Steps 2 and 3 — the range spelling and its migration. ..< parses and
excludes its end, .. includes it, and ..= is refused naming 0..n as the
form it was and 0..<n as the one that stops before its end. Both expression
chains take both, which is not a detail: the grammar keeps a second chain for
the positions where a { is a body rather than a struct literal, and a
spelling added to one and not the other is a form that works in half the
language.
The migration was in the same change, and that is the point of it. The old
spelling keeps parsing and changes meaning, so a migration spread over two
changes is a corpus that means something nobody wrote in between. Nineteen
ranges in examples/, benches/ and tests/samples/, every Nikaia source
inside the compiler’s own tests, and Part I 3.3’s own example — and a test that
walks every .nika file in the tree and fails on one left behind, because a
reader can check it by eye is exactly the claim a test should be holding
instead.
Steps 1 and 4 — the six shapes, in the change after it. A tuple pattern, an
or-pattern, an inclusive range, a guard, a nested pattern and .. for a
struct’s rest; calc.nika matches step and reads the pair by name rather than
step.0 and step.1.
What made them nest is one change in the tree: a tuple pattern’s parts are
patterns rather than names. A binding is a one-segment path, so
Message::Write(text) is what it always was and
Event::Click(Point { x, .. }) is the same shape one level deeper — the
emitter, the binding walk and the refusal below all recurse, and none of them
has a case for nested.
The guard stands on the arm and not on the pattern, because that is what it is: the pattern says which values reach the arm and the guard says which of those the arm takes. An or-pattern therefore has one guard rather than one per alternative — and a guarded arm covers nothing, which the completeness check had to learn: the values the guard turns away reach the arms below. Rust reads it the same way, which is what keeps this compiler’s answer and the backend’s from disagreeing.
NK1155 is D1’s binding-set rule: every alternative of an | binds the
same names, because the arm’s body reads them and does not know which
alternative matched.
And it answered a question that was parked (ADR-146 §4): an or-pattern does count as coverage, one case per variant it names. That record said so and waited on the form to exist.
#218 carried it.