ADR-137: match Takes Six More Pattern Shapes, a Guard Is if, and .. in a Pattern Is Inclusive — So ..< Is the Exclusive Range Everywhere

Status: 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

1. Context

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.

2. Decisions

D1 — The six shapes.

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.

D2 — A guard is 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.

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

D4 — So the exclusive range is ..<, 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.

D5 — ..= 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.

D6 — The migration is the corpus’s and the pages’, and it is mechanical.

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.

3. Consequences

4. What this does not decide

5. What is built

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.

This site is open source. Improve this page.