Skip to content

baml_language: add if-let, while-let, and let-else#3525

Open
codeshaunted wants to merge 1 commit into
canaryfrom
avery/pattern-control-flow
Open

baml_language: add if-let, while-let, and let-else#3525
codeshaunted wants to merge 1 commit into
canaryfrom
avery/pattern-control-flow

Conversation

@codeshaunted
Copy link
Copy Markdown
Collaborator

@codeshaunted codeshaunted commented May 13, 2026

Summary

Adds Rust-style pattern-matching control flow on top of the existing match infrastructure. Each construct desugars at AST lowering so HIR / TIR / MIR / codegen see only match expressions.

Construct Desugar
if let <pat> = <expr> { then } [else { else }] match (<expr>) { <pat> => <then>, _ => <else_or_empty> }
while let <pat> = <expr> { body } while (true) { match (<expr>) { <pat> => <body>, _ => { break } } }
let <pat> = <expr> else { div }; let <pat> = match (<expr>) { <pat> => <binding>, _ => { div } }; (or stand-alone match for wildcard patterns)

The parser gets two new syntax kinds (IF_LET_EXPR, WHILE_LET_STMT) and LET_STMT is extended with an optional else { ... } block. A parse_let_header_no_else helper keeps the if-let / while-let header from accidentally swallowing an else that belongs to the enclosing construct.

Synthetic AST nodes get zero-width spans at the end of the enclosing statement so HIR's position-based scope_at_offset doesn't let them shadow user-written body scopes — without this, references to pattern bindings inside the body would fail to resolve and codegen would silently emit Constant::Null.

Formatter

LetStmt::print_header is factored out and shared with IfLetExpr::print / WhileLetStmt::print so comment trivia around let <pat> = <expr> survives the formatter. ElseExpr gets an IfLet arm so else if let chains print correctly. LetStmt gains an optional let_else: Option<(Else, BlockExpr)>.

Tests

  • 61 runtime tests in crates/baml_tests/tests/{if_let,while_let,let_else,let_constructs_extra}.rs. These execute the compiled bytecode and assert the produced value — covering success / mismatch paths, shadowing, nesting, lambda capture, else if let chains, scrutinee evaluation order (once for if-let / let-else, once-per-iteration for while-let), binding mutability, null / optional and user-class narrows, throwing scrutinee, for-loop interaction.
  • 13 formatter trivia tests in crates/baml_fmt/tests/let_constructs_fmt.rs covering line + block comments at every position around the header and body, blank-line preservation, and format(format(x)) == format(x) idempotency for each construct.
  • 17 compile-tier .baml files under crates/baml_tests/projects/compiles/parser_statements/ running through lexer → parser → HIR → TIR → MIR → codegen → formatter.
  • Scope-bleed regressions in diagnostic_errors/let_bindings_scope/ (binding leaks past }, into else, into sibling arms — all produce unresolved name).
  • Refutability assertions in diagnostic_errors/let_refutability/ — 8 cases violating Rust's rule that if-let / while-let / let-else require refutable patterns. Currently caught via BAML's match-arm exhaustiveness as unreachable arm; the snapshots act as a migration hook for when a dedicated diagnostic gets wired up.
  • Malformed-syntax recovery cases in broken_syntax/let_constructs/ covering missing pattern, missing =, missing initializer, missing body, missing else block, non-block else, missing semicolon, else if let without =.

Test plan

  • `cargo nextest r --no-fail-fast --no-default-features --features ring-crypto` — 5620 / 5620 pass on top of canary
  • CI green

Summary by CodeRabbit

New Features

  • Added if let expressions enabling type-safe pattern matching with optional else branches for fallthrough handling
  • Added while let loops supporting pattern-based conditional iteration that terminates when patterns no longer match
  • Added let ... else statements for robust pattern binding with mandatory divergence paths (return, throw, break, continue)

Review Change Stack

Adds Rust-style pattern-matching control flow on top of the existing
match infrastructure. Each construct desugars at AST lowering so HIR /
TIR / MIR / codegen see only match expressions.

  - `if let <pat> = <expr> { then } [else { else }]`
      → match (<expr>) { <pat> => <then>, _ => <else_or_empty> }
  - `while let <pat> = <expr> { body }`
      → while (true) { match (<expr>) { <pat> => <body>, _ => { break } } }
  - `let <pat> = <expr> else { div };`
      → let <pat> = match (<expr>) { <pat> => <binding>, _ => { div } };
        (or a stand-alone match statement when the pattern binds no name)

Parser adds two new syntax kinds (IF_LET_EXPR, WHILE_LET_STMT) and
extends LET_STMT with an optional `else { ... }` block. The
parse_let_header_no_else helper keeps the if-let/while-let header from
swallowing an `else` that belongs to the enclosing construct.

The desugared synthetic AST nodes use zero-width spans at the end of
the enclosing statement so HIR's position-based scope_at_offset doesn't
let them shadow user-written body scopes. Without this, references to
pattern bindings inside the body would fail to resolve and codegen
would silently emit Constant::Null.

Formatter is updated end-to-end: LetStmt gains an optional
`let_else: Option<(Else, BlockExpr)>` and a shared `print_header`
helper so IfLetExpr / WhileLetStmt can reuse the trivia-preserving
header printing instead of re-implementing it (which would silently
drop comments around `=`). ElseExpr gets an `IfLet` arm so `else if let`
chains print correctly.

Tests
-----
  - 17 compile-tier .baml files exercising the new constructs end-to-
    end (lexer → parser → HIR → TIR → MIR → codegen → formatter).
  - 61 runtime tests in baml_tests/tests/{if_let,while_let,let_else,
    let_constructs_extra}.rs covering: success/mismatch paths,
    shadowing, nesting, lambda capture, else-if-let chains, scrutinee
    evaluation order (once vs. per-iteration), binding mutability,
    null/optional and user-class narrows, for-loop interaction,
    throwing scrutinee.
  - 13 formatter trivia tests in baml_fmt/tests/let_constructs_fmt.rs
    covering line + block comments at every header/body position,
    blank-line preservation, and format-is-idempotent for each
    construct.
  - Scope-bleed regressions in diagnostic_errors/let_bindings_scope/
    (bindings leaking past `}`, into else, into sibling arms) — each
    produces `unresolved name`.
  - Refutability assertions in diagnostic_errors/let_refutability/ —
    eight cases violating Rust's rule that if-let / while-let /
    let-else require refutable patterns. Currently caught via BAML's
    match-arm exhaustiveness as `unreachable arm`; snapshots act as a
    migration hook for when a dedicated diagnostic gets wired up.
  - Malformed-syntax recovery cases in broken_syntax/let_constructs.
@vercel
Copy link
Copy Markdown

vercel Bot commented May 13, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
beps Ready Ready Preview, Comment May 13, 2026 6:56am
promptfiddle Ready Ready Preview, Comment May 13, 2026 6:56am

Request Review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 13, 2026

📝 Walkthrough

Walkthrough

This pull request introduces Rust-inspired pattern-matching constructs to BAML: if let, while let, and let...else. The changes span the full compiler stack—parser, syntax, AST, lowering to intermediate representation, formatting, and runtime semantics—with extensive test coverage for parsing, scoping, diagnostics, and execution behavior.

Changes

Pattern matching constructs (if let, while let, let...else)

Layer / File(s) Summary
Syntax kind definitions
baml_language/crates/baml_compiler_syntax/src/syntax_kind.rs
Added IF_LET_EXPR and WHILE_LET_STMT variants documenting their match-based desugaring.
Parser let-header refactoring and if/while let dispatch
baml_language/crates/baml_compiler_parser/src/parser.rs
Refactored LET_STMT parsing to support optional let-else flag; added parse_let_header_no_else helper to prevent else-clause consumption in if let/while let headers; implemented dispatch logic to recognize and route both constructs.
CST-to-AST mapping
baml_language/crates/baml_compiler_syntax/src/ast.rs
Updated LetStmt::initializer() and BlockExpr::elements() to recognize IF_LET_EXPR and WHILE_LET_STMT CST nodes during AST traversal.
Expression AST nodes for if-let
baml_language/crates/baml_fmt/src/ast/expressions.rs
Added Expression::IfLet variant and new IfLetExpr public struct containing let-style header, then-block, and optional else-branch (if/if-let/block); extended ElseExpr with IfLet variant; integrated printing and token-span computation.
Statement AST nodes for let-else and while-let
baml_language/crates/baml_fmt/src/ast/statements.rs
Extended LetStmt with optional let_else field and added print_header helper; added Statement::WhileLet variant and WhileLetStmt struct for loop pattern matching; integrated printing and token-span calculation.
IR lowering and desugaring
baml_language/crates/baml_compiler2_ast/src/lower_expr_body.rs
Implemented lowering: lower_block_expr detects let-else and expands via helpers, lower_expr_inner routes IF_LET_EXPR to dedicated handler, new functions desugar all three constructs into match-based IR with synthetic zero-width ranges for scope control.
Formatter regression tests
baml_language/crates/baml_fmt/tests/let_constructs_fmt.rs
Test suite validating comment/trivia preservation and idempotency across all three constructs, with targeted tests for inline/line comments, nested forms, and end-to-end combinations.
Parser-level broken syntax tests
baml_language/crates/baml_tests/projects/broken_syntax/let_constructs/main.baml
Intentionally malformed cases omitting required components (pattern, =, initializer, body, else block, semicolon) to exercise parser recovery.
Compile-time tests
baml_language/crates/baml_tests/projects/compiles/parser_statements/{if_let,if_let_edge_cases,let_else,let_else_edge_cases,while_let,while_let_edge_cases}.baml
Test files covering type narrowing, else/else-if chains, nested forms, binding scope boundaries, shadowing restoration, lambda capture, sequential chaining, loop control flow, and edge-case scoping and shadowing.
Diagnostic tests
baml_language/crates/baml_tests/projects/diagnostic_errors/{let_bindings_scope,let_refutability}/main.baml
Scope-bleed and refutability rule validation: bindings outside valid scope and irrefutable patterns triggering unreachable-arm diagnostics.
Runtime tests
baml_language/crates/baml_tests/tests/{if_let,let_else,while_let,let_constructs_extra}.rs
Tokio-based runtime suites validating type narrowing, branch selection, expression value production, lambda capture, control flow (break/continue), shadowing/restoration, accumulation across iterations, scrutinee evaluation order, optional/union/class narrowing, and loop interactions.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 A pattern emerges, so Rust-like and bright,
If-let, while-let, and else light the night,
Bindings narrow and shadow restore,
Match desugaring opens each door,
With tests ten-fold, the features take flight! 🌟

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding three Rust-style pattern-matching control-flow constructs (if-let, while-let, let-else) to the BAML language.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch avery/pattern-control-flow

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@baml_language/crates/baml_compiler_parser/src/parser.rs`:
- Around line 2932-2940: parse_let_header_no_else currently calls
parse_let_stmt_inner which eats a trailing semicolon (via
p.eat(TokenKind::Semicolon)), causing header parsing used by if/while/for to
incorrectly accept statement-level semicolons; change the code so
parse_let_header_no_else does not trigger that semicolon consumption — either
add a parameter to parse_let_stmt_inner (e.g., allow_trailing_semicolon: bool)
and pass false from parse_let_header_no_else, or extract the header-parsing
portion into a new helper (e.g., parse_let_header_inner) that omits the
p.eat(TokenKind::Semicolon) call; update callers accordingly (including the
other header caller around lines ~2974-2984) so only statement-level let parsing
consumes the semicolon.

In `@baml_language/crates/baml_compiler2_ast/src/lower_expr_body.rs`:
- Around line 3267-3276: The else-arm of a `let ... else` is being lowered into
a normal match arm (via lower_expr, alloc_pattern, MatchArm, alloc_match_arm)
without ensuring it actually diverges, so non-diverging else blocks slip
through; before constructing arm_div call into the divergence-check logic (e.g.,
check that the lowered else_block_node is syntactically/semantically divergent
or that self.lower_expr(else_block_node) produces a Divergent kind) and if it
does not, emit a compile error rejecting the `let ... else` form, otherwise
proceed to allocate the wildcard pattern and match arm as now; ensure the check
happens in the same lowering branch that creates arm_div so downstream phases
retain the guarantee that the else arm must diverge.
- Around line 3287-3307: The code drops WATCH_LET semantics by always emitting
Stmt::Let { is_watched: false, ... } in the binding branch and returning a plain
Stmt::Expr in the no-binding branch; fix by propagating the "watched" flag into
both branches: determine whether this lowering came from a WATCH_LET (e.g., a
boolean like is_watched or by inspecting the incoming node from
lower_block_expr), then use that flag when constructing the outer let (set
Stmt::Let { is_watched: true/flag, ... } instead of false) and, in the
no-binding path, do not return Stmt::Expr(match_expr) — allocate a Stmt::Let
with outer_pattern and initializer Some(match_expr) and is_watched set to the
same flag (or otherwise ensure a watched-let sentinel is emitted) so WATCH_LET
semantics are preserved; update uses of alloc_stmt, outer_let, match_stmt,
binding_name, outer_pattern, and Stmt::Let accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 02989fac-5daf-420b-b43c-a0739a969954

📥 Commits

Reviewing files that changed from the base of the PR and between b602e7d and b43bda2.

⛔ Files ignored due to path filters (38)
  • baml_language/crates/baml_tests/snapshots/broken_syntax/let_constructs/baml_tests__broken_syntax__let_constructs__01_lexer__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/broken_syntax/let_constructs/baml_tests__broken_syntax__let_constructs__02_parser__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/broken_syntax/let_constructs/baml_tests__broken_syntax__let_constructs__05_diagnostics.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/parser_statements/baml_tests__compiles__parser_statements__01_lexer__if_let.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/parser_statements/baml_tests__compiles__parser_statements__01_lexer__if_let_edge_cases.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/parser_statements/baml_tests__compiles__parser_statements__01_lexer__let_else.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/parser_statements/baml_tests__compiles__parser_statements__01_lexer__let_else_edge_cases.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/parser_statements/baml_tests__compiles__parser_statements__01_lexer__while_let.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/parser_statements/baml_tests__compiles__parser_statements__01_lexer__while_let_edge_cases.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/parser_statements/baml_tests__compiles__parser_statements__02_parser__if_let.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/parser_statements/baml_tests__compiles__parser_statements__02_parser__if_let_edge_cases.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/parser_statements/baml_tests__compiles__parser_statements__02_parser__let_else.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/parser_statements/baml_tests__compiles__parser_statements__02_parser__let_else_edge_cases.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/parser_statements/baml_tests__compiles__parser_statements__02_parser__while_let.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/parser_statements/baml_tests__compiles__parser_statements__02_parser__while_let_edge_cases.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/parser_statements/baml_tests__compiles__parser_statements__03_hir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/parser_statements/baml_tests__compiles__parser_statements__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/parser_statements/baml_tests__compiles__parser_statements__04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/parser_statements/baml_tests__compiles__parser_statements__05_diagnostics.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/parser_statements/baml_tests__compiles__parser_statements__06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/parser_statements/baml_tests__compiles__parser_statements__10_formatter__if_let.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/parser_statements/baml_tests__compiles__parser_statements__10_formatter__if_let_edge_cases.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/parser_statements/baml_tests__compiles__parser_statements__10_formatter__let_else.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/parser_statements/baml_tests__compiles__parser_statements__10_formatter__let_else_edge_cases.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/parser_statements/baml_tests__compiles__parser_statements__10_formatter__while_let.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/parser_statements/baml_tests__compiles__parser_statements__10_formatter__while_let_edge_cases.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/let_bindings_scope/baml_tests__diagnostic_errors__let_bindings_scope__01_lexer__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/let_bindings_scope/baml_tests__diagnostic_errors__let_bindings_scope__02_parser__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/let_bindings_scope/baml_tests__diagnostic_errors__let_bindings_scope__03_hir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/let_bindings_scope/baml_tests__diagnostic_errors__let_bindings_scope__04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/let_bindings_scope/baml_tests__diagnostic_errors__let_bindings_scope__05_diagnostics.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/let_bindings_scope/baml_tests__diagnostic_errors__let_bindings_scope__10_formatter__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/let_refutability/baml_tests__diagnostic_errors__let_refutability__01_lexer__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/let_refutability/baml_tests__diagnostic_errors__let_refutability__02_parser__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/let_refutability/baml_tests__diagnostic_errors__let_refutability__03_hir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/let_refutability/baml_tests__diagnostic_errors__let_refutability__04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/let_refutability/baml_tests__diagnostic_errors__let_refutability__05_diagnostics.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/let_refutability/baml_tests__diagnostic_errors__let_refutability__10_formatter__main.snap is excluded by !**/*.snap
📒 Files selected for processing (20)
  • baml_language/crates/baml_compiler2_ast/src/lower_expr_body.rs
  • baml_language/crates/baml_compiler_parser/src/parser.rs
  • baml_language/crates/baml_compiler_syntax/src/ast.rs
  • baml_language/crates/baml_compiler_syntax/src/syntax_kind.rs
  • baml_language/crates/baml_fmt/src/ast/expressions.rs
  • baml_language/crates/baml_fmt/src/ast/statements.rs
  • baml_language/crates/baml_fmt/tests/let_constructs_fmt.rs
  • baml_language/crates/baml_tests/projects/broken_syntax/let_constructs/main.baml
  • baml_language/crates/baml_tests/projects/compiles/parser_statements/if_let.baml
  • baml_language/crates/baml_tests/projects/compiles/parser_statements/if_let_edge_cases.baml
  • baml_language/crates/baml_tests/projects/compiles/parser_statements/let_else.baml
  • baml_language/crates/baml_tests/projects/compiles/parser_statements/let_else_edge_cases.baml
  • baml_language/crates/baml_tests/projects/compiles/parser_statements/while_let.baml
  • baml_language/crates/baml_tests/projects/compiles/parser_statements/while_let_edge_cases.baml
  • baml_language/crates/baml_tests/projects/diagnostic_errors/let_bindings_scope/main.baml
  • baml_language/crates/baml_tests/projects/diagnostic_errors/let_refutability/main.baml
  • baml_language/crates/baml_tests/tests/if_let.rs
  • baml_language/crates/baml_tests/tests/let_constructs_extra.rs
  • baml_language/crates/baml_tests/tests/let_else.rs
  • baml_language/crates/baml_tests/tests/while_let.rs

Comment on lines +2932 to +2940
/// Parse a let-header (pattern + initializer) wrapped in a `LET_STMT` node.
///
/// Used by `if let`, `while let`, and for-in to embed a pattern + value
/// without the surrounding statement-level semantics. In those contexts a
/// trailing `else` always belongs to the enclosing construct, never to a
/// let-else binding.
fn parse_let_header_no_else(&mut self) {
self.parse_let_stmt_inner(/* allow_let_else = */ false);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't let header parsing consume statement semicolons.

parse_let_header_no_else() still routes through parse_let_stmt_inner(), so if let x = y; { ... } / while let x = y; { ... } are accepted even though the helper is supposed to strip statement-level syntax. The same shared p.eat(TokenKind::Semicolon) also makes let ... else { ... } accept a missing trailing ;, which contradicts the grammar described in this PR.

Suggested fix
 fn parse_let_stmt(&mut self) {
-    self.parse_let_stmt_inner(/* allow_let_else = */ true);
+    self.parse_let_stmt_inner(/* allow_let_else = */ true, /* allow_semicolon = */ true);
 }
 
 fn parse_let_header_no_else(&mut self) {
-    self.parse_let_stmt_inner(/* allow_let_else = */ false);
+    self.parse_let_stmt_inner(/* allow_let_else = */ false, /* allow_semicolon = */ false);
 }
 
-fn parse_let_stmt_inner(&mut self, allow_let_else: bool) {
+fn parse_let_stmt_inner(&mut self, allow_let_else: bool, allow_semicolon: bool) {
     self.with_node(SyntaxKind::LET_STMT, |p| {
+        let mut saw_let_else = false;
+
         // pattern / initializer...
 
-        if allow_let_else && p.at(TokenKind::Else) {
+        if allow_let_else && p.at(TokenKind::Else) {
+            saw_let_else = true;
             p.bump(); // else
             if p.at(TokenKind::LBrace) {
                 p.parse_block_expr();
             } else {
                 p.error_unexpected_token("block after 'else' in let-else".to_string());
             }
         }
 
-        p.eat(TokenKind::Semicolon);
+        if allow_semicolon {
+            if saw_let_else {
+                p.expect(TokenKind::Semicolon);
+            } else {
+                p.eat(TokenKind::Semicolon);
+            }
+        }
     });
 }

Also applies to: 2974-2984

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@baml_language/crates/baml_compiler_parser/src/parser.rs` around lines 2932 -
2940, parse_let_header_no_else currently calls parse_let_stmt_inner which eats a
trailing semicolon (via p.eat(TokenKind::Semicolon)), causing header parsing
used by if/while/for to incorrectly accept statement-level semicolons; change
the code so parse_let_header_no_else does not trigger that semicolon consumption
— either add a parameter to parse_let_stmt_inner (e.g.,
allow_trailing_semicolon: bool) and pass false from parse_let_header_no_else, or
extract the header-parsing portion into a new helper (e.g.,
parse_let_header_inner) that omits the p.eat(TokenKind::Semicolon) call; update
callers accordingly (including the other header caller around lines ~2974-2984)
so only statement-level let parsing consumes the semicolon.

Comment on lines +3267 to +3276
// Match-diverge arm: lowers the user's else block. Expected to
// diverge (return / throw / break / continue) — same as Rust.
let div_body = self.lower_expr(else_block_node);
let wildcard = self.alloc_pattern(Pattern::Wildcard, synth_range);
let arm_div = MatchArm {
pattern: wildcard,
guard: None,
body: div_body,
};
let arm_div_id = self.alloc_match_arm(arm_div, else_block_node.text_range());
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Reject non-diverging let ... else blocks before desugaring.

Once this branch is lowered into a plain match, downstream phases can no longer tell that the else arm was required to diverge. That means forms like let x: int = value else { 0 }; become normal value-producing matches and keep executing, which breaks the feature's stated Rust-style semantics.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@baml_language/crates/baml_compiler2_ast/src/lower_expr_body.rs` around lines
3267 - 3276, The else-arm of a `let ... else` is being lowered into a normal
match arm (via lower_expr, alloc_pattern, MatchArm, alloc_match_arm) without
ensuring it actually diverges, so non-diverging else blocks slip through; before
constructing arm_div call into the divergence-check logic (e.g., check that the
lowered else_block_node is syntactically/semantically divergent or that
self.lower_expr(else_block_node) produces a Divergent kind) and if it does not,
emit a compile error rejecting the `let ... else` form, otherwise proceed to
allocate the wildcard pattern and match arm as now; ensure the check happens in
the same lowering branch that creates arm_div so downstream phases retain the
guarantee that the else arm must diverge.

Comment on lines +3287 to +3307
if binding_name.is_some() {
// Binding case — outer let binds the pattern's names from the
// match result.
let outer_let = self.alloc_stmt(
Stmt::Let {
pattern: outer_pattern,
initializer: Some(match_expr),
is_watched: false,
origin: LetOrigin::Source,
},
range,
);
vec![outer_let]
} else {
// No-binding case — the match is a statement on its own; we
// discard `outer_pattern` since it doesn't introduce any names
// and its only purpose was the type/wildcard check that the arm
// pattern already performs.
let _ = outer_pattern;
let match_stmt = self.alloc_stmt(Stmt::Expr(match_expr), range);
vec![match_stmt]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve WATCH_LET semantics in the let-else path.

lower_block_expr routes WATCH_LET nodes here, but the binding case always emits Stmt::Let { is_watched: false, ... }, and the no-binding branch drops the watched binding entirely by returning only a Stmt::Expr. watch let ... else will therefore behave like a plain let/match instead of a watched binding.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@baml_language/crates/baml_compiler2_ast/src/lower_expr_body.rs` around lines
3287 - 3307, The code drops WATCH_LET semantics by always emitting Stmt::Let {
is_watched: false, ... } in the binding branch and returning a plain Stmt::Expr
in the no-binding branch; fix by propagating the "watched" flag into both
branches: determine whether this lowering came from a WATCH_LET (e.g., a boolean
like is_watched or by inspecting the incoming node from lower_block_expr), then
use that flag when constructing the outer let (set Stmt::Let { is_watched:
true/flag, ... } instead of false) and, in the no-binding path, do not return
Stmt::Expr(match_expr) — allocate a Stmt::Let with outer_pattern and initializer
Some(match_expr) and is_watched set to the same flag (or otherwise ensure a
watched-let sentinel is emitted) so WATCH_LET semantics are preserved; update
uses of alloc_stmt, outer_let, match_stmt, binding_name, outer_pattern, and
Stmt::Let accordingly.

@github-actions
Copy link
Copy Markdown

Binary size checks failed

1 violations · ✅ 6 passed

⚠️ Please fix the size gate issues or acknowledge them by updating baselines.

Artifact Platform Gzip Baseline Delta Status
bridge_cffi Linux 6.6 MB 6.4 MB +157.0 KB (+2.4%) FAIL
bridge_cffi-stripped Linux 5.6 MB 5.7 MB -47.1 KB (-0.8%) OK
bridge_cffi macOS 5.4 MB 5.1 MB +329.5 KB (+6.5%) OK
bridge_cffi-stripped macOS 4.6 MB 4.7 MB -48.8 KB (-1.0%) OK
bridge_cffi Windows 5.4 MB 5.1 MB +290.0 KB (+5.7%) OK
bridge_cffi-stripped Windows 4.7 MB 4.7 MB +26.1 KB (+0.6%) OK
bridge_wasm WASM 3.6 MB 3.5 MB +46.2 KB (+1.3%) OK
Details & how to fix

Violations:

  • bridge_cffi (Linux) gzip_bytes: 6.6 MB exceeds limit of 6.5 MB (exceeded by +92.7 KB, policy: max_gzip_bytes)

Add/update baselines:

.ci/size-gate/x86_64-unknown-linux-gnu.toml:

[artifacts.bridge_cffi]
file_bytes = 17666544
stripped_bytes = 17666536
gzip_bytes = 6592737

Generated by cargo size-gate · workflow run

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