Skip to content

ctrlflow-1: break/continue/return legal in expression position (FB-015)#35

Merged
vpisarev merged 3 commits into
masterfrom
ctrlflow-1
Jul 10, 2026
Merged

ctrlflow-1: break/continue/return legal in expression position (FB-015)#35
vpisarev merged 3 commits into
masterfrom
ctrlflow-1

Conversation

@vpisarev

Copy link
Copy Markdown
Owner

What

break, continue and return (with a value or bare) may now appear as the
value of a match arm or if branch — exactly the way throw already does:

val r = [:: for e <- xs { match e { | 0 => continue | x => x } }]   // filter
val keep = if w == "STOP" { break } else { tagged }
fun first_pos(xs: int list): int { for x <- xs { match x { | v when v>0 => return v | _ => {} } }; -1 }
for i <- 0:n { if done(i) { return } else {} }                       // bare return, same-line

All of these previously failed — | _ => continue with a type error, bare
return before }/| with a misleading unexpected token '}'. This is
additive (accepts strictly more programs) and unblocks the imperative-fold
reform
, where match e { | A(x) => acc += x | _ => continue } is the natural
fold-body idiom.

Why it was two different bugs

The brief framed both as "parser limitations"; they weren't:

Symptom Real stage Fix
jump as arm/branch value typecheck Ast.fx get_exp_ctx: ExpBreak/Continue/Return carry pseudo-type TypErr (like ExpThrow) instead of TypVoid — unifies with any sibling without binding it
bare return before } / | lexer Lexer.fx: a return whose next significant char is } ) ] , | is bare

throw is the template: it already flows through all four passes as TypErr.
Mirroring that for the three jump nodes is the whole change — ~20 lines of
source
, zero backend change.

Zero backend change — verified

break/continue/return/throw already lower uniformly to void/err K-nodes routed
through the block cleanup chain in C_gen_code.fx; tail dispatch is
position-agnostic. Consequences, all checked:

  • Bootstrap regen touched only Ast.c + Lexer.c — no other module's C
    changed ⇒ existing programs' K-form is byte-identical.
  • -pr-resolve census byte-identical (351 sites) — resolution-neutral.
  • Legality unchanged, targeted messages (cannot use 'break' outside of loop, return-type mismatch for bare-return-in-non-void). break/continue
    in @parallel/fold stay rejected in both statement and expression
    position (no widening).

Leak oracle

Ref-counted locals live at the jump must be freed by the cleanup chain.
Machine-checked, not eyeballed:

  • Whole suite + a stress with 1000×1000 double locals live at a deep
    return/break/continue — clean under ASAN_OPTIONS=detect_leaks=1 at
    O0 and O3.
  • Confirmed at the C level: jump → block label → FX_FREE_ARR(&big)
    FX_CHECK_*. K-form shows the matrices survive DCE (read into the branch
    condition).

Tests

  • test/test_ctrlflow.fx — 10 directed cases: {break, continue, bare return, return value} × {match arm, if branch, both branches jump, nested break→innermost, comprehension/loop body} with ref-counted temps live at the
    jump, values asserted.
  • test/negative/216–219 — targeted diagnostics replacing unexpected token '}'.
  • Full ladder green: test_all 147, fxtest all (unit + negative + ir + cfold
    • corpus O0/O3), determinism, sanitize, fixpoint.

Ride-along

  • FB-018 (separate commit): Array.diag(d) used size(a) before a is
    defined → size(d); locked by matrix.diag_from_vector.
  • Doc note: functional style is the compiler's house style (the scan_ resolver
    stays idiomatic recursion — an imperative rewrite would be micro-slower and no
    clearer).

Details: docs/ctrlflow1_report.md.

vpisarev and others added 3 commits July 10, 2026 14:38
break, continue and return (with a value or bare) may now appear as the value
of a match arm or if branch, exactly the way throw already does. Two additive
frontend fixes; zero backend change.

- Ast.fx (get_exp_ctx): ExpBreak/ExpContinue/ExpReturn now carry the pseudo-type
  TypErr (like ExpThrow) instead of TypVoid, so a jumping arm/branch unifies
  with any value-producing sibling without binding it. The return-value type
  check is unaffected (the value's type is taken from e_opt separately), so a
  bare return in a non-void function still reports a return-type mismatch.
- Lexer.fx: a `return` whose next significant char is one of } ) ] , | (tokens
  that cannot begin an expression) is classified bare, fixing `{ return }` and
  `return | ...` which previously misreported as `unexpected token '}'`.

K-normalization still lowers all three to void K-nodes routed through the block
cleanup chain, so generated C is byte-identical for existing programs: the
bootstrap regen touched only Ast.c + Lexer.c, and the -pr-resolve census
(351 sites) is unchanged. Legality (check_inside_for) and the @parallel/fold
restrictions fire identically in statement and expression position.

Tests: test/test_ctrlflow.fx (10 directed cases: the {break,continue,bare
return,return value} x {match arm,if branch,both jump,nested break,comprehension
body} matrix with ref-counted temps live at the jump, values asserted);
test/negative/216-219 (targeted diagnostics replacing `unexpected token '}'`).
Leak oracle: the suite and a 1000x1000-local stress are clean under ASan
detect_leaks at O0 and O3 -- every cleanup section runs on the jump. Full ladder
green (test_all 146, fxtest all, determinism, sanitize, fixpoint).

Unblocks the imperative-fold reform. See docs/ctrlflow1_report.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The one-argument diag built its dimension from the not-yet-defined result `a`
instead of the input vector `d`, so the array form was unusable (unnoticed
because nothing instantiates it -- generic bodies typecheck at instantiation;
exposed by the annotate-2 sweep). One token: size(a) -> size(d). Locked by
matrix.diag_from_vector in test/test_matrix.fx.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…piler

Most of the compiler is accumulator recursion / immutable bindings / match;
that is the house style to match. Historically more robust (fewer side effects
=> fewer state bugs). An imperative rewrite (e.g. accumulator recursion -> a
for-loop with break/continue) is not automatically better: not faster (often
micro-slower -- break/continue are checked per block cleanup section) and longer
to write. Prompted by the ctrlflow-1 decision to leave lookup_id_opt's scan_
as idiomatic recursion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vpisarev vpisarev merged commit a136582 into master Jul 10, 2026
3 checks passed
@vpisarev vpisarev deleted the ctrlflow-1 branch July 10, 2026 11:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant