ADR-084: break and continue Are in the Language, Unlabelled and Carrying No Value

Status: Superseded by ADR-276 — its D1-D8 are D11, D12, D13, D16, D17, D18, D1 and D19 there Date: September 14, 2026 Target Version: Nikaia 0.0.8 Supersedes: ADR-070 D2 in part. D2’s finding — “break and continue are not in the grammar, not in the parser, and not among the 29 reserved words” — was true when it was written and is the thing this changes. D1 of that record is untouched, and §2 D7 says why: the argument for a loop keyword needs a break that carries a value, and this one does not Related: ADR-071 (which reserved both words, so this costs no program a name), ADR-051 D1 (a keyword is a reserved word), ADR-034 (a handler that diverts may not be overlapped — §3 adds a fourth way to divert), ADR-055 §6 (the checker is the diagnostic and the lowering is the guarantee), ADR-011 D2 (name for name), Part I 3.3 Evidence: break-continue-cost.md — the construct was built and measured before this was written, and every number below is from there


1. Context

ADR-070 D2 found the absence while answering a different question, named it exactly, and deliberately left it open. ADR-071 reserved break, continue and loop so that the question could stay open at no cost — and it then cost nothing for exactly as long as it was open, which is what a reservation is for.

What was asked of the question, and what came back. The construct was built first and decided second: a keyword is the most expensive thing a language adds and the hardest thing it removes (ADR-070 D1), so the three questions it has to answer — what does it cost at run time, what does it cost to compile, what does it break — were answered with numbers rather than with an estimate. break-continue-cost.md is that measurement in full; the three answers it produced are the premises of everything below.

The one that decided it. A for in this language cannot be stopped. return leaves the function, so it is an exit only where the loop is the last thing the function does; a flag stops the body while the loop keeps turning. Measured on a for that wants to stop early at n = 2,000,000, the flag version takes two million turns to do 1,415 turns’ work — −99.9 %, and the gap is not a constant factor but unbounded in n.

That is not a performance argument and it is important that it is not read as one. A loop that cannot stop is not a slow loop; it is a loop that has to be written as something else, and the something else is a flag whose meaning a reader has to reconstruct from three places.

2. Decisions

D1 — break and continue are statements of the language.

break leaves the innermost loop around it. continue starts that loop’s next turn. Both are statements, not expressions, exactly as while and for are statements (Part I 3.3) — and for the same reason: a loop repeats until something stops holding, and what that is worth as a value is nothing.

D2 — No label.

break and continue act on the innermost loop and there is no way to name another one.

A label is a second naming scheme: a name that is not a value, scoped to a construct rather than to a block, invisible to every analysis that asks where a value goes. The unlabelled form costs none of that, and §3 shows what that is worth in practice — six analyses in this compiler needed an arm for these two statements and every one of those arms does nothing, because all six are about where a value goes and a jump carries none. A label would give each of them a question it does not have today.

The case it does not reach — leaving an outer loop from an inner one — is served by a flag or by return, and what that costs is unmeasured. The condition for reopening this is a measurement, not an opinion: the shape, written out, against the labelled form, on a program someone actually has.

D3 — Neither carries a value, and break x is refused rather than read as two statements.

Rust’s break carries a value out of a loop, which is what makes loop { … } an expression there. Nikaia’s loops are statements and hand back nothing, so there is nothing for a break to carry.

The refusal is the decision, not the absence. Without one, break i parses as a break and then a statement i — the program compiles, the value is dropped, and nothing says so. That is the failure the while rule’s own comment records from the day while was missing (three statements, no error), and it is worse here because the author wrote something that means one thing in the language beside this one. NK1133 says what is true rather than guessing at intent: nothing after a break in the same block is reached.

D4 — A jump does not leave a function, and the four places that means something are refused by name.

This is not a design choice; it is what a jump is. break compiles to a branch to a label, and a label in another function is not a place a branch can go. Four constructs of this language are a separate function in the language below:

construct what it is below
a lambda — fn (x) { … } a Rust closure
a task — spawn fn { … } async move { … } (ADR-055 §6)
an overlap branch one async { … } per branch (ADR-050 D2)
a DSL fold’s init, step, merge closures handed to the fold driver

A break inside one of those whose loop is outside it is NK1132, and the message names which of the four stands in the way. It has to be this compiler’s message: without it the answer is rustc’s — “break outside of a loop”, on a line of a file nobody wrote — which Part III C.1 calls a bug in this compiler rather than a poor error message.

The way out is in the help, and it is a real restriction rather than an oversight: decide inside, act outside. The one worth knowing before writing code is the lock doors (ADR-065), whose block is a lambda — and there the restriction is doing work, because the alternative is a lock held across a jump.

D5 — A catch handler is not one of them.

for p in paths {
    let text = fs::read_to_string(p) catch { break }
    seen += text.len()
}

is a correct program. A handler lowers to a match arm (Kap 7.1), and a match arm is not a function boundary. D4’s table makes the opposite look like the rule, which is why this is a decision with a test that runs rather than an omission from the table.

D6 — The refusal is in the checker and in the emitter, and the second one is the guarantee.

The checker’s boundaries are found by a walk, and a walk can miss a corner. One did, and it is in the record because the general lesson is worth more than the case: the block walk that reaches a fold’s lambdas does not descend into a spawn, because a task’s body is a detached context (Part I 5.4) everywhere else it is asked about — so fold(N, zero, fn(acc, m) { let h = spawn fn { break } }) went past it.

The fix is not a better walk. Flow — the emitter’s record of what surrounds a statement, already carrying throws, caught and in_lambda — gained in_loop, true in a loop’s body and false in Flow::PLAIN, which is where a lambda, a task, an overlap branch and a function body each start. Every statement is emitted through one place, so a jump with no loop cannot be written whatever a walk did or did not reach.

That is ADR-055 §6’s division applied again — the checker is the diagnostic and the lowering is the guarantee — and it is the general answer to “is this walk complete?”, which is a question no walk can answer about itself.

D7 — ADR-070 D1 stands: there is still no loop keyword.

D1’s no has a written condition for reopening, and this record does not meet it. ADR-070 D2 stated it exactly:

while true { … } whose break hands back a value reads as a lie, because the head says the loop does not end and the body says what it ends with.

D3 above refuses a break that hands back a value, so the lie has nothing to be told with. while true { … } is still the unconditional loop, loop stays reserved and unused, and ADR-071 D3 is what that reservation was for.

What does change is that the shape becomes usable: until now a while true could only be left by return, which is why no .nika file in the tree writes one. §3 has the consequence of that.

D8 — The two grammar rules stand last in the statement alternation, and the comment there says why.

An ordering is normally a matter of reading order and this one was measured, twice, because the difference is a factor of two hundred.

The parser tries a statement’s alternatives in order. Placed where they read most naturally — beside return_stmt, ahead of assign_stmt — the two rules are tried and fail for every statement that is not a jump, at about 740 instructions a statement and +1.28 % on a statement-dense file. Placed last, what reaches them is only the } that ends a block, where the whole alternation fails anyway: about 665 instructions per block, and a program has far fewer blocks than statements.

It costs nothing in correctness, and that is why the ordering is free to choose: both words are reserved (ADR-071 D1), so NAME cannot take one and no earlier arm can swallow a jump. The comment at the rules carries the reason, because an ordering with no reason attached is an ordering the next person tidies.

3. Consequences

No program changes and no name is taken from anyone. ADR-071 reserved both words one day before this, so the addition widens what parses and nothing else. That is the reservation paying for itself, and it is the part that would have been expensive later.

Part I 3.3 gains the two constructs, and 2.1’s note about the three reserved non-constructs now names only loop.

Two diagnostics, NK1132 (D4) and NK1133 (D3), in Part III C.3’s table.

The ordering analysis learns a fourth way to divert. ADR-034 refuses to overlap a statement whose catch handler can return, because the statement after it is then conditional on this one having succeeded and ADR-033 D5 forbids running a conditional operation early. A handler that jumps makes the next statement conditional in exactly the same way, so contracts::order’s diverts counts break and continue — and is exact about the one case where it must not: a jump bound to a loop written inside the handler lands in the handler, so what follows is reached either way.

This is a rule written ahead of the case that needs it. diverts is asked only about branches of an explicit overlap, where D4 refuses a jump before it gets there. It is in because an analysis should be right about what a construct means rather than about which constructs happen to reach it.

while true becomes writable, and the generated file says something about it. rustc answers “denote infinite loops with loop { … }” on the emitted line — a warning naming a file nobody wrote, which is Part III C.1’s class one severity down. It is not caused by this record; it is made common by it, since the shape had no use before. It is on [#227](https://github.com/Nikaia-Language/Nikaia/issues/227) as the defect it is, with its one-line fix, because what goes in the emitted preamble is a decision about every file this compiler writes.

#183’s unreachable-return entry has to be rewritten — the one ADR-093 later closed. Its entry — a while true cannot be left, so a function ending in one should not need an unreachable return — argued that the work is small here and large in Rust because break does not exist. It does now. The test stops being one on the condition and becomes “the condition is the literal true and no break in the body is bound to this loop”: still small, because the checker already keeps the loop count that answers it, and no longer the sentence that is written.

One defect was found and half-closed, and one was found and not. A grammar fold’s init, step and merge are not checked at all — an undeclared name in one reaches the backend — because check’s grammar walk takes a rule’s action block and nothing else. This closes the half a jump can reach and no more, since walking those bodies with the whole checker would newly refuse programs for reasons that have nothing to do with this construct. And && and || are missing from the head of an if, a while and a for, found by writing the loop a language without break has to write — while i < n && running does not parse. Both are on [#206](https://github.com/Nikaia-Language/Nikaia/issues/206).

What did not happen is the interesting part. Six analyses in this compiler walk statements — the sharing classes, stored views, touch sets, the sync walk, the DSL scan and the type walk — and each needed an arm for the two new statements. Every one of those arms does nothing. The reason is one sentence: all of them are about where a value goes, and a jump carries none. It is also D2’s argument, arrived at from the other side.

And the compiler named all six itself. Those matches have no _ arm — the AST’s comment on LitInterpolated records why, from the day an analysis walked past a string as if it held no code — so adding a variant turned “which analyses must be told about this?” from a question into a list of compile errors. This is the first construct added since that rule was paid for, and it is what it was bought for.

4. What is built

All of it, and it was built before this record rather than after: 435 lines across ten files of the compiler, 18 tests in crates/nikaia/tests/jumps.rs, one benchmark and one measurement.

Where What
ast/mod.rs Stmt::Break and Stmt::Continue — no payload, no label (D1, D2, D3)
parser/mod.rs break_stmt and continue_stmt, last in the alternation (D8)
emit/mod.rs break; and continue; (ADR-011 D2), and Flow::in_loop (D6)
check/mod.rs the loop count, the four boundaries, NK1132 and NK1133
contracts/order.rs diverts counts a jump, exactly (§3)
five more walkers one arm each, all of them doing nothing (§3)

The tests are run rather than read, wherever there is something to run — returns.rs’s rule, and for its reason: both readings of a jump type-check, so a test that only compiled the output would be asserting that the emitter agrees with itself. What a break does is visible in what the program prints. The exceptions are the refusals, which have nothing to run, and the two that assert on emitted text: that the lowering is the same word, and that D6’s backstop holds where the checker’s walk does not reach.

The numbers, in full in break-continue-cost.md:

question answer
run time, while that stops mid-body −25.0 % at n = 2,000,000 (4.0 → 3.0 instructions a turn)
run time, for that stops early −99.9 % — 2,000,000 turns become 1,415, and the gap is unbounded in n
run time, continue −1.0 % — it buys shape, not speed, and that is the honest answer for it
compile time +0.13 % to +0.32 %, 665 instructions per block (D8)

Reproduce it with

cargo test -p nikaia --test measure -- --ignored --nocapture what_a_jump

which prints the three A/Bs at three sizes each, from benches/jumps.nika — one file holding both halves of every pair, so one lowering and one rustc invocation produce both and nothing but the construct differs.

This site is open source. Improve this page.