From fcbb6f45d899d50ced5562293a80e1ae1f7f6667 Mon Sep 17 00:00:00 2001 From: Antonio Sarosi Date: Mon, 6 Jul 2026 17:52:37 +0200 Subject: [PATCH] Allow break and continue in match-arm expression position (B-619) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `break`/`continue` were rejected in expression position (e.g. a bare `match`/`catch` arm value like `0 => break`) with E0010 "Expected expression, found break", even though `return` is accepted there and the braced form `0 => { break; }` already works. Root cause: the expression parser (`parse_primary_expr`) recognizes `return` and `throw` as diverging expressions but had no case for `break`/`continue`, so they fell through to the generic "Expected expression" error. Statement position was fine, which is why the braced arm worked. Fix — make break/continue symmetric with return: - Parser: new BREAK_EXPR/CONTINUE_EXPR CST nodes, parsed in `parse_primary_expr` mirroring RETURN_EXPR; like `parse_return_expr` they don't consume a trailing `;` (that stays owned by the enclosing statement). - AST lowering: each desugars to a single-statement block (`{ break; }` / `{ continue; }`), reusing the tested Stmt::Break/Stmt::Continue machinery (divergence-to-never typing, defer replay/unwatch, defer-escape diagnostics). They carry no value, so no dedicated Expr variant is needed and the braceless form behaves identically to the already-accepted braced arm. - Formatter: break/continue arms wrap into a block with a trailing `;`, exactly as return arms do, so output round-trips through BREAK_STMT/CONTINUE_STMT and stays idempotent. Tests: parser unit test for BREAK_EXPR/CONTINUE_EXPR in match arms; a formatter idempotency test; and a new baml_src/ns_match_arm_break_continue end-to-end fixture whose test blocks execute the loop-control semantics in both statement and value arm positions. Fixes B-619 --- .../baml_compiler2_ast/src/lower_expr_body.rs | 24 ++++ .../crates/baml_compiler_parser/src/parser.rs | 77 ++++++++++ .../baml_compiler_syntax/src/syntax_kind.rs | 11 ++ .../crates/baml_fmt/src/ast/expressions.rs | 56 +++++--- baml_language/crates/baml_fmt/src/lib.rs | 52 +++++++ .../match_arm_break_continue.baml | 75 ++++++++++ .../baml_tests/snapshots/baml_src/_root.snap | 3 + .../baml_src/match_arm_break_continue.snap | 131 ++++++++++++++++++ 8 files changed, 411 insertions(+), 18 deletions(-) create mode 100644 baml_language/crates/baml_tests/baml_src/ns_match_arm_break_continue/match_arm_break_continue.baml create mode 100644 baml_language/crates/baml_tests/snapshots/baml_src/match_arm_break_continue.snap diff --git a/baml_language/crates/baml_compiler2_ast/src/lower_expr_body.rs b/baml_language/crates/baml_compiler2_ast/src/lower_expr_body.rs index b536b8d3149..6d4c2ec3255 100644 --- a/baml_language/crates/baml_compiler2_ast/src/lower_expr_body.rs +++ b/baml_language/crates/baml_compiler2_ast/src/lower_expr_body.rs @@ -787,6 +787,8 @@ impl LoweringContext { SyntaxKind::CATCH_EXPR => self.lower_catch_expr(node), SyntaxKind::THROW_EXPR => self.lower_throw_expr(node), SyntaxKind::RETURN_EXPR => self.lower_return_expr(node), + SyntaxKind::BREAK_EXPR => self.lower_jump_expr(node, Stmt::Break), + SyntaxKind::CONTINUE_EXPR => self.lower_jump_expr(node, Stmt::Continue), SyntaxKind::BLOCK_EXPR => { if let Some(block) = baml_compiler_syntax::ast::BlockExpr::cast(node.clone()) { self.lower_block_expr(&block) @@ -4512,6 +4514,28 @@ impl LoweringContext { self.alloc_expr(Expr::Return { value }, node.span_range()) } + /// Lower a `break`/`continue` used in expression position (`BREAK_EXPR` / + /// `CONTINUE_EXPR`, e.g. a bare match arm `0 => break`) into a block that + /// holds the corresponding jump statement: `{ break; }` / `{ continue; }`. + /// + /// `break`/`continue` carry no value, so — unlike `return` — they need no + /// dedicated `Expr` variant. Desugaring to a single-statement block reuses + /// the fully-tested `Stmt::Break`/`Stmt::Continue` machinery (divergence + /// typing to `never`, defer replay/unwatch, defer-escape diagnostics) and + /// makes the braceless form behave identically to the already-accepted + /// braced arm. + fn lower_jump_expr(&mut self, node: &SyntaxNode, jump: Stmt) -> ExprId { + let span = node.span_range(); + let stmt = self.alloc_stmt(jump, span); + self.alloc_expr( + Expr::Block { + stmts: vec![stmt], + tail_expr: None, + }, + span, + ) + } + /// Lower `defer { BODY }` (BEP-042). The CST shape is /// `DEFER_STMT [ KW_DEFER BLOCK_EXPR ]`. Unlike `spawn`, the body is NOT a /// lambda — it is lowered inline as an [`Expr::Block`] in the enclosing diff --git a/baml_language/crates/baml_compiler_parser/src/parser.rs b/baml_language/crates/baml_compiler_parser/src/parser.rs index 6e55f033cc5..44d77112b87 100644 --- a/baml_language/crates/baml_compiler_parser/src/parser.rs +++ b/baml_language/crates/baml_compiler_parser/src/parser.rs @@ -4678,6 +4678,28 @@ impl<'a> Parser<'a> { }); } + /// Parse `break` in expression position as a `BREAK_EXPR` — a diverging + /// expression of type `never`, mirroring `parse_return_expr`. This is what + /// lets a braceless `break` be a `catch`/`match` arm value (`0 => break`). + /// Statement position is still handled by `parse_break_stmt` (see + /// `parse_stmt`), so this only fires when `break` is reached through + /// expression parsing. Unlike the statement form, we don't eat a trailing + /// `;` here — that belongs to the enclosing statement, not the expression. + fn parse_break_expr(&mut self) { + self.with_node(SyntaxKind::BREAK_EXPR, |p| { + p.expect(TokenKind::Break); + }); + } + + /// Parse `continue` in expression position as a `CONTINUE_EXPR` — the + /// `continue` counterpart of `parse_break_expr`. Statement-position + /// `continue` is still handled by `parse_continue_stmt`. + fn parse_continue_expr(&mut self) { + self.with_node(SyntaxKind::CONTINUE_EXPR, |p| { + p.expect(TokenKind::Continue); + }); + } + fn parse_if_expr(&mut self) { // `if let PATTERN = SCRUTINEE { ... }` is a distinct refutable form. // Decided here by peeking past `if` for a `let` token — patterns @@ -6324,6 +6346,15 @@ impl<'a> Parser<'a> { // `catch`/`match` arm value. Statement-position `return` is taken by // `parse_stmt` before reaching here. self.parse_return_expr(); + } else if self.at(TokenKind::Break) { + // Break expression (diverging, type `never`) — lets `break` be a + // `catch`/`match` arm value, symmetric with `return`. + // Statement-position `break` is taken by `parse_stmt` first. + self.parse_break_expr(); + } else if self.at(TokenKind::Continue) { + // Continue expression (diverging, type `never`) — the `continue` + // counterpart of the `break` case above. + self.parse_continue_expr(); } else if self.at(TokenKind::Word) { // Collect text as owned String so the borrow is released before any &mut calls. let text: String = self.current().map(|t| t.text.clone()).unwrap_or_default(); @@ -9341,6 +9372,52 @@ function Demo() -> int { assert_eq!(throw_expr_count, 2); } + #[test] + fn parses_break_and_continue_as_match_arm_expressions() { + // B-619: bare `break`/`continue` are valid in match-arm expression + // position (symmetric with `return`), producing BREAK_EXPR/CONTINUE_EXPR + // nodes — no `E0010 Expected expression` error. + let source = r#" +function Demo(n: int) -> int { + let x = n; + while (true) { + match (x) { + 0 => break, + 1 => continue, + _ => { x = x - 1; } + } + } + x +} +"#; + + let (root, errors) = parse_source(source); + assert_no_errors(&errors); + + let break_expr_count = root + .descendants() + .filter(|n| n.kind() == SyntaxKind::BREAK_EXPR) + .count(); + assert_eq!(break_expr_count, 1, "expected one BREAK_EXPR node"); + + let continue_expr_count = root + .descendants() + .filter(|n| n.kind() == SyntaxKind::CONTINUE_EXPR) + .count(); + assert_eq!(continue_expr_count, 1, "expected one CONTINUE_EXPR node"); + + // Bare `break`/`continue` in statement position still parse as the + // statement forms — the expression forms only fire through expression + // parsing (the match arms above). + assert_eq!( + root.descendants() + .filter(|n| n.kind() == SyntaxKind::BREAK_STMT) + .count(), + 0, + "arm-position break should not be a BREAK_STMT" + ); + } + #[test] fn parses_catch_on_throw_expression_with_expected_binding() { let source = r#" diff --git a/baml_language/crates/baml_compiler_syntax/src/syntax_kind.rs b/baml_language/crates/baml_compiler_syntax/src/syntax_kind.rs index 09b51facf34..dd2b57b9f2f 100644 --- a/baml_language/crates/baml_compiler_syntax/src/syntax_kind.rs +++ b/baml_language/crates/baml_compiler_syntax/src/syntax_kind.rs @@ -343,6 +343,17 @@ pub enum SyntaxKind { /// arm value (e.g. `_ => return 0`) without the statement-only restriction. /// Statement-position `return` still parses as `RETURN_STMT`. RETURN_EXPR, + /// `break` in expression position — a diverging expression of type `never` + /// (mirrors `RETURN_EXPR`). Lets `break` appear as a `catch`/`match` arm + /// value (e.g. `0 => break`) without the statement-only restriction. + /// Statement-position `break` still parses as `BREAK_STMT`. + BREAK_EXPR, + /// `continue` in expression position — a diverging expression of type + /// `never` (mirrors `RETURN_EXPR`). Lets `continue` appear as a + /// `catch`/`match` arm value (e.g. `0 => continue`) without the + /// statement-only restriction. Statement-position `continue` still parses + /// as `CONTINUE_STMT`. + CONTINUE_EXPR, /// `spawn name_expr? block` — BEP-034 spawn expression. /// Structure: `KW_SPAWN [expr] BLOCK_EXPR`. SPAWN_EXPR, diff --git a/baml_language/crates/baml_fmt/src/ast/expressions.rs b/baml_language/crates/baml_fmt/src/ast/expressions.rs index 2b8bb68d319..5bb925934f7 100644 --- a/baml_language/crates/baml_fmt/src/ast/expressions.rs +++ b/baml_language/crates/baml_fmt/src/ast/expressions.rs @@ -54,13 +54,22 @@ pub enum Expression { /// `return` requires, so the output round-trips through `RETURN_STMT` (i.e. is /// idempotent). Return(VerbatimSpan), + /// A braceless `break` in expression position (a `BREAK_EXPR`, e.g. a + /// `catch`/`match` arm value like `0 => break`). Handled exactly like + /// [`Expression::Return`] and backed by the same [`VerbatimSpan`]: when an arm + /// printer wraps it into a block it appends the `;` that a block-position + /// `break` requires, so the output round-trips through `BREAK_STMT`. + Break(VerbatimSpan), + /// A braceless `continue` in expression position (a `CONTINUE_EXPR`). The + /// `continue` counterpart of [`Expression::Break`]. + Continue(VerbatimSpan), Unknown(VerbatimSpan), } /// A node the strong AST does not model and prints verbatim: an unmodeled /// expression (e.g. `defer { … }`, `throw e`, `await f`, `spawn { … }`, -/// `x.as`) held as [`Expression::Unknown`], or a braceless `return …` held as -/// [`Expression::Return`]. +/// `x.as`) held as [`Expression::Unknown`], or a braceless jump held as +/// [`Expression::Return`], [`Expression::Break`], or [`Expression::Continue`]. /// /// Rather than a single whole-node span, this carries the node's true first and /// last *token* ranges. The trivia classifier keys leading/trailing comments to @@ -204,6 +213,8 @@ impl FromCST for Expression { } SyntaxKind::LAMBDA_EXPR => Expression::Lambda(Box::new(LambdaExpr::from_cst(elem)?)), SyntaxKind::RETURN_EXPR => Expression::Return(VerbatimSpan::from_element(&elem)), + SyntaxKind::BREAK_EXPR => Expression::Break(VerbatimSpan::from_element(&elem)), + SyntaxKind::CONTINUE_EXPR => Expression::Continue(VerbatimSpan::from_element(&elem)), _ => Expression::Unknown(VerbatimSpan::from_element(&elem)), }; Ok(expr) @@ -253,7 +264,7 @@ impl Expression { } Expression::ByteString(bs) => Some(usize::from(bs.span().len())), Expression::Lambda(_) => None, - Expression::Return(_) => None, + Expression::Return(_) | Expression::Break(_) | Expression::Continue(_) => None, Expression::Unknown(unknown) => { // Unmodeled nodes (e.g. `await f`, `x.as`, `spawn { … }`, // `throw e`) print their source verbatim (see `print`). When that @@ -305,12 +316,13 @@ impl Printable for Expression { Expression::BacktickString(bt) => bt.print(shape, printer), Expression::ByteString(bs) => bs.print(shape, printer), Expression::Lambda(lambda) => lambda.print(shape, printer), - // Print the raw `return …` text. The arm printers add the `;` when - // they wrap this into a block (see `CatchArm`/`MatchArm`). A braceless - // `return` only appears as a whole arm value, never nested inside - // another expression, so it always reports multi-lined. - Expression::Return(ret) => { - printer.print_input_range_trimmed_start(ret.content_range()); + // Print the raw `return …` / `break` / `continue` text. The arm + // printers add the `;` when they wrap this into a block (see + // `CatchArm`/`MatchArm`). A braceless jump only appears as a whole + // arm value, never nested inside another expression, so it always + // reports multi-lined. + Expression::Return(jump) | Expression::Break(jump) | Expression::Continue(jump) => { + printer.print_input_range_trimmed_start(jump.content_range()); PrintInfo::default_multi_lined() } // Unmodeled nodes print their source verbatim. Report `multi_lined` @@ -355,8 +367,10 @@ impl Printable for Expression { Expression::BacktickString(bt) => bt.leftmost_token(), Expression::ByteString(bs) => bs.leftmost_token(), Expression::Lambda(lambda) => lambda.leftmost_token(), - Expression::Return(ret) => ret.first_token, - Expression::Unknown(unknown) => unknown.first_token, + Expression::Return(span) + | Expression::Break(span) + | Expression::Continue(span) + | Expression::Unknown(span) => span.first_token, } } fn rightmost_token(&self) -> TextRange { @@ -387,8 +401,10 @@ impl Printable for Expression { Expression::BacktickString(bt) => bt.rightmost_token(), Expression::ByteString(bs) => bs.rightmost_token(), Expression::Lambda(lambda) => lambda.rightmost_token(), - Expression::Return(ret) => ret.last_token, - Expression::Unknown(unknown) => unknown.last_token, + Expression::Return(span) + | Expression::Break(span) + | Expression::Continue(span) + | Expression::Unknown(span) => span.last_token, } } } @@ -1830,13 +1846,17 @@ impl MatchArm { /// newline are already emitted; the caller emits the closing `}`). /// /// `arm_indent` is the arm's own indent; the body is printed one level deeper. -/// A braceless `return` body additionally gets its statement `;` — and its -/// trailing trivia is deliberately left for the arm level so a same-line comment -/// stays attached to the arm (emitted after the wrapped `},`) instead of being -/// split from the `;` or dropped/duplicated when the arm has no comma (B-629). +/// A braceless jump body (`return`/`break`/`continue`) additionally gets its +/// statement `;` — and its trailing trivia is deliberately left for the arm +/// level so a same-line comment stays attached to the arm (emitted after the +/// wrapped `},`) instead of being split from the `;` or dropped/duplicated when +/// the arm has no comma (B-629). fn print_wrapped_arm_body(printer: &mut Printer, body: &Expression, arm_indent: usize) { let inner_indent = arm_indent + printer.config.indent_width; - if matches!(body, Expression::Return(_)) { + if matches!( + body, + Expression::Return(_) | Expression::Break(_) | Expression::Continue(_) + ) { printer.print_standalone_leading_and_body(body, inner_indent); printer.print_str(";"); } else { diff --git a/baml_language/crates/baml_fmt/src/lib.rs b/baml_language/crates/baml_fmt/src/lib.rs index 87b3e16be43..e4fed9b1b44 100644 --- a/baml_language/crates/baml_fmt/src/lib.rs +++ b/baml_language/crates/baml_fmt/src/lib.rs @@ -475,6 +475,58 @@ mod catch_format_tests { } } +#[cfg(test)] +mod match_arm_jump_format_tests { + //! B-619: a braceless `break`/`continue` match arm is wrapped into a block + //! with a trailing `;` — the same treatment `return` gets — so the output + //! round-trips through `BREAK_STMT`/`CONTINUE_STMT` and is idempotent. + + use super::*; + + #[test] + fn braceless_break_and_continue_arms_wrap_into_blocks() { + let source = r#"function f(n: int) -> int { + let x = n; + while (true) { + match (x) { + 0 => break, + 1 => continue, + _ => { x = x - 1; } + } + } + x +} +"#; + let expected = r#"function f(n: int) -> int { + let x = n; + while (true) { + match (x) { + 0 => { + break; + }, + 1 => { + continue; + }, + _ => { + x = x - 1; + }, + } + } + x +} +"#; + let options = FormatOptions::default(); + let formatted = + format(source, &options).expect("formatter should succeed on break/continue arms"); + assert_eq!( + formatted, expected, + "formatter output didn't match expected\n--- got ---\n{formatted}\n--- want ---\n{expected}" + ); + let second = format(&formatted, &options).expect("formatter should be idempotent"); + assert_eq!(formatted, second, "formatter should be idempotent"); + } +} + #[cfg(test)] mod is_format_tests { //! Formatter tests for ` is `. Each case rounds the diff --git a/baml_language/crates/baml_tests/baml_src/ns_match_arm_break_continue/match_arm_break_continue.baml b/baml_language/crates/baml_tests/baml_src/ns_match_arm_break_continue/match_arm_break_continue.baml new file mode 100644 index 00000000000..d656bcf38c2 --- /dev/null +++ b/baml_language/crates/baml_tests/baml_src/ns_match_arm_break_continue/match_arm_break_continue.baml @@ -0,0 +1,75 @@ +// B-619: bare `break`/`continue` as a `match` arm value. +// +// `break`/`continue` are diverging expressions of type `never` (like `return`), +// so they may appear as a braceless `match` arm value without a surrounding +// block. Previously only the block form (`0 => { break; }`) was accepted; the +// braceless form emitted `E0010 "Expected expression, found break"`. These +// exercise the fix end-to-end — the loop-control semantics must match the +// block form. + +// ── statement position (match result discarded) ────────────────────────────── + +// Bare `break` stops the loop at 6; bare `continue` skips the add for 3. +function sum_skip_three_break_at_six() -> int { + let total = 0; + let i = 0; + while (true) { + i = i + 1; + match (i) { + 6 => break, + 3 => continue, + _ => { total = total + i; } + } + } + total +} + +test "bare_break_and_continue_arms_control_the_loop" { + // Adds 1 + 2 + 4 + 5 (skips 3, breaks before 6) = 12. + assert.is_true(baml.deep_equals(sum_skip_three_break_at_six(), 12)) +} + +// ── value position (never unifies with the other arm) ──────────────────────── + +// The bare `break` arm sits where the `match` value is bound to `step`; its +// `never` type unifies with the `int` arm, and when it fires control leaves the +// loop before `step` is used. +function countdown_sum(start: int) -> int { + let total = 0; + let i = start; + while (true) { + let step = match (i) { + 0 => break, + _ => i, + }; + total = total + step; + i = i - 1; + } + total +} + +test "bare_break_in_value_position_unifies_and_breaks" { + // 4 + 3 + 2 + 1 = 10, then i == 0 breaks. + assert.is_true(baml.deep_equals(countdown_sum(4), 10)) +} + +// The bare `continue` arm in value position: its `never` unifies with the `int` +// arm, and when it fires the loop restarts without adding. +function sum_1_to_5_skip_3() -> int { + let total = 0; + let i = 0; + while (i < 5) { + i = i + 1; + let add = match (i) { + 3 => continue, + _ => i, + }; + total = total + add; + } + total +} + +test "bare_continue_in_value_position_unifies_and_skips" { + // 1 + 2 + 4 + 5 (skips 3) = 12. + assert.is_true(baml.deep_equals(sum_1_to_5_skip_3(), 12)) +} diff --git a/baml_language/crates/baml_tests/snapshots/baml_src/_root.snap b/baml_language/crates/baml_tests/snapshots/baml_src/_root.snap index e684b1547d1..3757b7154c1 100644 --- a/baml_language/crates/baml_tests/snapshots/baml_src/_root.snap +++ b/baml_language/crates/baml_tests/snapshots/baml_src/_root.snap @@ -216,6 +216,9 @@ function $init_test(registry: unknown) -> null { load_var ?1 call ? pop 1 + load_var ?1 + call ? + pop 1 load_const ? return } diff --git a/baml_language/crates/baml_tests/snapshots/baml_src/match_arm_break_continue.snap b/baml_language/crates/baml_tests/snapshots/baml_src/match_arm_break_continue.snap new file mode 100644 index 00000000000..f8a08508cbe --- /dev/null +++ b/baml_language/crates/baml_tests/snapshots/baml_src/match_arm_break_continue.snap @@ -0,0 +1,131 @@ +--- +source: crates/baml_tests/tests/baml_src.rs +--- +function match_arm_break_continue.$init_test_ns_match_arm_break_continue_match_arm_break_continue(registry: testing.TestCollector) -> null { + load_var registry + load_const "bare_break_and_continue_arms_control_the_loop" + make_closure ., 0 + load_const null + call testing.TestCollector.register_test + pop 1 + load_var registry + load_const "bare_break_in_value_position_unifies_and_breaks" + make_closure ., 0 + load_const null + call testing.TestCollector.register_test + pop 1 + load_var registry + load_const "bare_continue_in_value_position_unifies_and_skips" + make_closure ., 0 + load_const null + call testing.TestCollector.register_test + pop 1 + load_const null + return +} + +function match_arm_break_continue.countdown_sum(start: int) -> int { + load_const 0 + store_var total + load_var start + store_var i + + L0: + load_const true + pop_jump_if_false L2 + load_var i + load_const 0 + cmp_int_op == + pop_jump_if_false L1 + jump L2 + + L1: + load_var i + store_var step + load_var2 2 4 + add_int + store_var total + load_var i + load_const 1 + sub_int + store_var i + jump L0 + + L2: + load_var total + return +} + +function match_arm_break_continue.sum_1_to_5_skip_3() -> int { + load_const 0 + store_var total + load_const 0 + store_var i + + L0: + load_var i + load_const 5 + cmp_int_op < + pop_jump_if_false L1 + jump L2 + + L1: + load_var total + return + + L2: + load_var i + load_const 1 + add_int + store_var i + load_var i + load_const 3 + cmp_int_op == + pop_jump_if_false L3 + jump L0 + + L3: + load_var i + store_var add + load_var2 1 3 + add_int + store_var total + jump L0 +} + +function match_arm_break_continue.sum_skip_three_break_at_six() -> int { + load_const 0 + store_var total + load_const 0 + store_var i + + L0: + load_const true + pop_jump_if_false L3 + load_var i + load_const 1 + add_int + store_var i + load_var i + load_const 6 + cmp_int_op == + pop_jump_if_false L1 + jump L3 + + L1: + load_var i + load_const 3 + cmp_int_op == + pop_jump_if_false L2 + jump L0 + + L2: + load_var2 1 2 + add_int + store_var total + jump L0 + + L3: + load_var total + return +}