Skip to content

Commit ae3fbae

Browse files
feat(loops): wire break / continue end-to-end (closes #459) (#465)
## Summary Closes #459 — `break` and `continue` now parse, type-check (rejected outside loop bodies with a clear error), and lower to JS `break;`/`continue;` in the Deno-ESM and Node JS backends. Pre-fix: `BREAK`/`CONTINUE` were lexer-reserved tokens with no parser production consuming them; any use was a syntax error. ## Pipeline changes | File | Change | |---|---| | `lib/ast.ml` | `ExprBreak of Span.t`, `ExprContinue of Span.t` | | `lib/parser.mly` | `BREAK`/`CONTINUE` productions in `expr_assign` (diverging prefix, next to `RETURN`/`RESUME`) | | `lib/resolve.ml` | pass-through (`resolve_expr` + `lower_expr`) | | `lib/typecheck.ml` | new `ctx.in_loop : mutable bool` flipped on `StmtWhile`/`StmtFor` body entry; `synth` returns `ty_never`; new `NotInLoop of string` error | | `lib/borrow.ml` | pass-through (span lookup, visit-recurse, free-var collection, main checker) | | `lib/quantity.ml`, `lib/effect_sites.ml` | pass-through (no resources, no call sites) | | `lib/codegen_deno.ml`, `lib/js_codegen.ml` | statement-position lowering to bare JS keywords | ## Test fixture `tests/codegen-deno/loop_break_continue.affine` + harness — 14 assertions across: - `while` + `break` (threshold-driven early exit) - `while` + `continue` (skip-evens accumulator) - `for` + `break` (find-first-match) - `for` + `continue` (count-positive filter) - Edge cases: break on first iteration, no-break path, empty array ## Out of scope - **Non-JS backends** (wasm/GC/lua/c/rust/etc.): fall through existing wildcards. Full backend support files separately if needed. - **JS-codegen expression-position IIFE wrapper** (legacy MVP path) emits `(() => { break; })()` which would throw `SyntaxError: Illegal break statement` at runtime — legal AffineScript places break/continue inside loop bodies so the statement path fires. Deno backend uses the correct statement-position emit. ## Test plan - [x] `./tools/run_codegen_deno_tests.sh`: 15/15 harnesses green - [x] `dune test`: 352/352 unit tests green - [x] Misuse check: `pub fn bad() -> () { break; }` emits the new `NotInLoop` error with the expected message ## Refs - Closes #459 - Refs hyperpolymath/standards#284 (workarounds documented in the "Seam findings" section that surfaced this gap) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a050c65 commit ae3fbae

11 files changed

Lines changed: 164 additions & 2 deletions

lib/ast.ml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,8 @@ type expr =
146146
| ExprUnary of unary_op * expr
147147
| ExprBlock of block
148148
| ExprReturn of expr option
149+
| ExprBreak of Span.t (** break (in loop) — #459 *)
150+
| ExprContinue of Span.t (** continue (in loop) — #459 *)
149151
| ExprTry of {
150152
et_body : block;
151153
et_catch : match_arm list option;

lib/borrow.ml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,8 @@ let rec expr_span (expr : expr) : Span.t =
460460
| [] -> match blk_expr with Some e -> expr_span e | None -> Span.dummy
461461
end
462462
| ExprReturn _ -> Span.dummy
463+
| ExprBreak sp -> sp
464+
| ExprContinue sp -> sp
463465
| ExprTry _ -> Span.dummy
464466
| ExprHandle { eh_body; _ } -> expr_span eh_body
465467
| ExprResume _ -> Span.dummy
@@ -705,6 +707,8 @@ let compute_last_use_index (symbols : Symbol.t) (blk : block)
705707
| ExprLambda lam -> visit_expr idx lam.elam_body
706708
| ExprReturn (Some e) -> visit_expr idx e
707709
| ExprReturn None -> ()
710+
| ExprBreak _ -> ()
711+
| ExprContinue _ -> ()
708712
| ExprHandle eh ->
709713
visit_expr idx eh.eh_body;
710714
List.iter (fun arm ->
@@ -949,6 +953,7 @@ let rec check_expr (ctx : context) (state : state) (symbols : Symbol.t) (expr :
949953
| ExprTupleIndex (e, _) | ExprRowRestrict (e, _) | ExprSpan (e, _) ->
950954
collect_free acc e
951955
| ExprReturn None | ExprResume None | ExprUnsafe [] -> acc
956+
| ExprBreak _ | ExprContinue _ -> acc
952957
| ExprResume (Some e) -> collect_free acc e
953958
| ExprTuple es | ExprArray es -> List.fold_left collect_free acc es
954959
| ExprRecord er ->
@@ -1227,6 +1232,11 @@ let rec check_expr (ctx : context) (state : state) (symbols : Symbol.t) (expr :
12271232
| None -> Ok ()
12281233
end
12291234

1235+
(* #459: break/continue carry no expression and own no borrows; safe
1236+
no-ops for the borrow checker (the typecheck loop-context guard is
1237+
what enforces well-formedness). *)
1238+
| ExprBreak _ | ExprContinue _ -> Ok ()
1239+
12301240
| ExprTry et ->
12311241
(* CFG-join (CORE-01 pt3 Slice C / #177): body runs first, then
12321242
either succeeds (no-exception path → post-body state

lib/codegen_deno.ml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -695,6 +695,13 @@ let rec gen_expr ctx (expr : expr) : string =
695695
| ExprBlock block -> gen_block_expr ctx block
696696
| ExprReturn (Some e) -> iife ctx ("return " ^ gen_expr ctx e ^ ";")
697697
| ExprReturn None -> iife ctx "return Unit;"
698+
(* #459: break/continue lower to the corresponding JS keywords. The
699+
wrapping IIFE pattern used for `return` doesn't work here — JS's
700+
`break`/`continue` only target the nearest enclosing loop and an
701+
IIFE wraps the keyword in a new function frame. Emit a bare
702+
statement and rely on the parent block-flatten machinery. *)
703+
| ExprBreak _ -> iife ctx "break;"
704+
| ExprContinue _ -> iife ctx "continue;"
698705
| ExprLambda { elam_params; elam_body; elam_ret_ty = _ } ->
699706
let ps = List.map (fun (p : param) -> mangle p.p_name.name) elam_params in
700707
"((" ^ String.concat ", " ps ^ ") => " ^ gen_expr ctx elam_body ^ ")"
@@ -867,6 +874,8 @@ and gen_stmt_expr ctx (e : expr) : string =
867874
match unspan e with
868875
| ExprReturn (Some e) -> "return " ^ gen_expr ctx e ^ ";"
869876
| ExprReturn None -> "return;"
877+
| ExprBreak _ -> "break;"
878+
| ExprContinue _ -> "continue;"
870879
| ExprIf { ei_cond; ei_then; ei_else } ->
871880
let elseb = match ei_else with
872881
| Some e -> " else { " ^ gen_branch ctx e ^ " }"

lib/effect_sites.ml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ let rec visit_expr (visit : expr -> unit) (e : expr) : unit =
8585
| ExprBlock b -> visit_block visit b
8686
| ExprReturn eo | ExprResume eo ->
8787
(match eo with Some e -> go_expr e | None -> ())
88+
| ExprBreak _ | ExprContinue _ -> ()
8889
| ExprTry t ->
8990
visit_block visit t.et_body;
9091
(match t.et_catch with Some arms -> List.iter go_arm arms | None -> ());

lib/js_codegen.ml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,13 @@ let rec gen_expr ctx (expr : expr) : string =
252252
"(() => { return " ^ gen_expr ctx e ^ "; })()"
253253
| ExprReturn None ->
254254
"(() => { return Unit; })()"
255+
(* #459: see codegen_deno's matching comment. In statement position
256+
`break`/`continue` lower to bare JS keywords; the expression-
257+
position fallback uses an IIFE that can't actually escape the
258+
loop, but legal AffineScript places these inside a loop body so
259+
the statement path (gen_stmt) is what fires. *)
260+
| ExprBreak _ -> "(() => { break; })()"
261+
| ExprContinue _ -> "(() => { continue; })()"
255262
| ExprLambda { elam_params; elam_body; elam_ret_ty = _ } ->
256263
let param_strs = List.map (fun (p : param) -> mangle p.p_name.name) elam_params in
257264
"((" ^ String.concat ", " param_strs ^ ") => " ^ gen_expr ctx elam_body ^ ")"

lib/parser.mly

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -769,6 +769,8 @@ expr_assign:
769769
is correct, since `return` diverges and was never a useful operand. */
770770
| RETURN e = expr? { ExprReturn e }
771771
| RESUME e = expr? { ExprResume e }
772+
| BREAK { ExprBreak (mk_span $startpos $endpos) }
773+
| CONTINUE { ExprContinue (mk_span $startpos $endpos) }
772774
| lhs = expr_or EQ rhs = expr_assign
773775
{ ExprLet { el_mut = false; el_quantity = None;
774776
el_pat = PatVar (mk_ident "_" $startpos(lhs) $endpos(lhs));

lib/quantity.ml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,8 @@ let rec infer_usage_expr (env : env) (expr : expr) : unit =
524524
| ExprReturn e_opt ->
525525
Option.iter (infer_usage_expr env) e_opt
526526

527+
| ExprBreak _ | ExprContinue _ -> ()
528+
527529
| ExprTry et ->
528530
infer_usage_block env et.et_body;
529531
Option.iter (fun arms ->

lib/resolve.ml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,9 @@ let rec resolve_expr (ctx : context) (expr : expr) : unit result =
326326
| Some e -> resolve_expr ctx e
327327
| None -> Ok ())
328328

329+
| ExprBreak _ -> Ok ()
330+
| ExprContinue _ -> Ok ()
331+
329332
| ExprHandle eh ->
330333
let* () = resolve_expr ctx eh.eh_body in
331334
List.fold_left (fun acc arm ->
@@ -909,6 +912,8 @@ let rec lower_expr quals (e : expr) : expr =
909912
| ExprUnary (op, e1) -> ExprUnary (op, lower_expr quals e1)
910913
| ExprBlock b -> ExprBlock (lower_block quals b)
911914
| ExprReturn eo -> ExprReturn (Option.map (lower_expr quals) eo)
915+
| ExprBreak sp -> ExprBreak sp
916+
| ExprContinue sp -> ExprContinue sp
912917
| ExprTry r ->
913918
ExprTry { et_body = lower_block quals r.et_body;
914919
et_catch = Option.map (List.map (lower_arm quals)) r.et_catch;

lib/typecheck.ml

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ let rec expr_summary (expr : expr) : string =
7777
| ExprIndex _ -> "index"
7878
| ExprArray _ -> "array"
7979
| ExprReturn _ -> "return"
80+
| ExprBreak _ -> "break"
81+
| ExprContinue _ -> "continue"
8082
| ExprTry _ -> "try"
8183
| ExprHandle _ -> "handle"
8284
| ExprResume _ -> "resume"
@@ -117,6 +119,10 @@ type type_error =
117119
named a module qualifier not introduced by any `use` in the
118120
current program (ADR-014, #228). Symmetric to the value-path
119121
resolution check done by #178. *)
122+
| NotInLoop of string
123+
(** `break` / `continue` used outside any enclosing loop body
124+
(issue #459). The string carries the keyword name for the
125+
error message. *)
120126

121127
(* Known exports of stdlib/prelude.affine. Mirrors the same list in
122128
lib/face.ml — when an UnboundVariable fires at type-check time with
@@ -187,6 +193,11 @@ let show_type_error = function
187193
Add `use %s;` to bring the module into scope, or `use %s::{Item};` to \
188194
import items unqualified."
189195
m m m
196+
| NotInLoop kw ->
197+
Printf.sprintf
198+
"`%s` used outside a loop body (#459). `break` and `continue` must be \
199+
lexically enclosed by a `while` or `for` loop."
200+
kw
190201

191202
let format_type_error = show_type_error
192203

@@ -243,6 +254,12 @@ type context = {
243254
value-path lowering done by [Resolve.lower_qualified_value_paths]
244255
(#178). Populated at [check_program] entry from
245256
[prog.prog_imports]. *)
257+
mutable in_loop : bool;
258+
(** #459: tracks whether the synth/check walker is currently inside
259+
a loop body. Set true on entry to a [StmtWhile]/[StmtFor] body,
260+
restored on exit. Read by [ExprBreak]/[ExprContinue] handlers to
261+
reject loop-control expressions outside of a loop with
262+
[NotInLoop]. *)
246263
}
247264

248265
type 'a result = ('a, type_error) Result.t
@@ -275,6 +292,7 @@ let create_context (symbols : Symbol.t) : context =
275292
declared_effects = Hashtbl.create 16;
276293
call_effects = Hashtbl.create 64;
277294
module_quals = Hashtbl.create 4;
295+
in_loop = false;
278296
}
279297

280298
(** ADR-014 / #228. Strip a leading `Mod::` qualifier from a folded
@@ -1108,6 +1126,16 @@ let rec synth (ctx : context) (expr : expr) : ty result =
11081126
Ok ty_never
11091127
end
11101128

1129+
(* Break / continue — diverging like return. Loop-context check
1130+
happens at the statement-walker boundary (StmtWhile/StmtFor flip
1131+
ctx.in_loop); top-level break/continue is rejected there. #459. *)
1132+
| ExprBreak _ ->
1133+
if ctx.in_loop then Ok ty_never
1134+
else Error (NotInLoop "break")
1135+
| ExprContinue _ ->
1136+
if ctx.in_loop then Ok ty_never
1137+
else Error (NotInLoop "continue")
1138+
11111139
(* Variant constructor: Type::Variant *)
11121140
| ExprVariant ({ name = _type_name; _ }, { name = variant_name; _ }) ->
11131141
begin match Hashtbl.find_opt ctx.constructor_env variant_name with
@@ -1235,6 +1263,8 @@ and synth_list (ctx : context) (exprs : expr list) : (ty list) result =
12351263
and always_diverges (e : expr) : bool =
12361264
match e with
12371265
| ExprReturn _ -> true
1266+
| ExprBreak _ -> true
1267+
| ExprContinue _ -> true
12381268
| ExprBlock blk -> block_always_diverges blk
12391269
| ExprIf { ei_cond = _; ei_then; ei_else = Some else_e } ->
12401270
always_diverges ei_then && always_diverges else_e
@@ -1298,7 +1328,11 @@ and check_stmt (ctx : context) (stmt : stmt) : unit result =
12981328
check ctx rhs lhs_ty
12991329
| StmtWhile (cond, body) ->
13001330
let* () = check ctx cond ty_bool in
1301-
let* _ty = synth_block ctx body in
1331+
let prev = ctx.in_loop in
1332+
ctx.in_loop <- true;
1333+
let res = synth_block ctx body in
1334+
ctx.in_loop <- prev;
1335+
let* _ty = res in
13021336
Ok ()
13031337
| StmtFor (pat, iter_expr, body) ->
13041338
let* iter_ty = synth ctx iter_expr in
@@ -1309,7 +1343,11 @@ and check_stmt (ctx : context) (stmt : stmt) : unit result =
13091343
(n, Hashtbl.find_opt ctx.name_types n)
13101344
) bindings in
13111345
List.iter (fun (n, t) -> bind_var ctx n t) bindings;
1312-
let* _ty = synth_block ctx body in
1346+
let prev = ctx.in_loop in
1347+
ctx.in_loop <- true;
1348+
let res = synth_block ctx body in
1349+
ctx.in_loop <- prev;
1350+
let* _ty = res in
13131351
List.iter (fun (n, old_sc) ->
13141352
match old_sc with
13151353
| Some sc -> Hashtbl.replace ctx.name_types n sc
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// issue #459 — `break` and `continue` inside `while` / `for` loops.
3+
// Pre-fix the lexer reserved BREAK/CONTINUE tokens but no production
4+
// rule consumed them, so any use was a syntax error. Downstream TS
5+
// ports (standards#284) had to restructure into combined-guard or
6+
// sentinel-boolean forms. Now they parse, type-check (rejected
7+
// outside a loop with NotInLoop), and lower to JS `break;`/`continue;`.
8+
9+
// `break` exits a `while` early.
10+
pub fn sum_until(limit: Int) -> Int {
11+
let mut acc = 0;
12+
let mut i = 0;
13+
while i < 100 {
14+
if acc >= limit { break; }
15+
acc = acc + i;
16+
i = i + 1;
17+
}
18+
return acc;
19+
}
20+
21+
// `continue` skips the rest of the body.
22+
pub fn sum_odd(n: Int) -> Int {
23+
let mut acc = 0;
24+
let mut i = 0;
25+
while i < n {
26+
i = i + 1;
27+
if i % 2 == 0 { continue; }
28+
acc = acc + i;
29+
}
30+
return acc;
31+
}
32+
33+
// `break` inside `for`.
34+
pub fn find_first(arr: [Int], target: Int) -> Int {
35+
let mut found = -1;
36+
let mut idx = 0;
37+
for x in arr {
38+
if x == target { found = idx; break; }
39+
idx = idx + 1;
40+
}
41+
return found;
42+
}
43+
44+
// `continue` inside `for`.
45+
pub fn count_positive(arr: [Int]) -> Int {
46+
let mut n = 0;
47+
for x in arr {
48+
if x <= 0 { continue; }
49+
n = n + 1;
50+
}
51+
return n;
52+
}

0 commit comments

Comments
 (0)