Skip to content

BEP-049 M1–M4: backtick strings, interpolation, control flow, tagged templates#3577

Merged
codeshaunted merged 57 commits into
canaryfrom
avery/string-interpolation
Jun 18, 2026
Merged

BEP-049 M1–M4: backtick strings, interpolation, control flow, tagged templates#3577
codeshaunted merged 57 commits into
canaryfrom
avery/string-interpolation

Conversation

@codeshaunted

@codeshaunted codeshaunted commented May 26, 2026

Copy link
Copy Markdown
Collaborator

Implements BEP-049 (String Interpolation) milestones M1 through M4.

What's in this PR

M1 — backtick string literals `...`: new BACKTICK token, multi-tick delimiter ladders, escapes, multi-line auto-dedent (§12), usable in any expression position.

M2 — ${expr} interpolation: ${...} as a block expression (§4), implicit .to_string() for non-string values (§11), and strict typing (§7/§11) — interpolating a nullable or non-stringable value is a compile-time error.

M3 — block control flow: ${for (let x in xs)}…${endfor}, ${if (c)}…${else if}…${else}…${endif} using host syntax, with §13 whitespace control.

M4 — tagged templates (§10): tag`...` with the //baml:tagged_string marker, TaggedString { parts, values }, tag resolution + signature validation, body-lambda params scoping into ${…}, and full VM execution — static layouts (M4e.1a) and ${for}/${if} runtime flattening (M4e.1b).

Architecture: check-then-lower (clean diagnostics)

Both backtick forms are unified behind a first-class Expr::Template { tag: Default { elaborated } | Custom { tag, body }, segments } node that survives through TIR. Untagged backticks no longer desugar early — interpolation diagnostics point at the original ${…} span instead of leaking a synthetic .to_string() call. This matches how tagged templates (and TypeScript) check the structured node and lower late.

Testing

Full workspace suite green. Adds unit + runtime coverage for backtick literals, interpolation, strict-type diagnostics, control flow, and tagged-template execution (static, ${for}/${if}, nested, mixed, captures, body-param injection, nested lambdas capturing loop-locals).

Not in this PR

M5 (built-in prompt tag), M6 (legacy #"..."# interop + migrator), M7 (drop minijinja).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • BEP-049 backtick string literals with ${} interpolation, including ${for} / ${if} control-flow.
    • Tagged templates with custom tag expressions and structured TaggedString template metadata.
    • Backtick-aware LLM prompts using prompt closures, plus typed prompt/context helpers in the standard library.
  • Improvements
    • BEP-049 dedentation and block-tag trimming for multiline literals.
    • Extended escape decoding with backtick-specific line-ending normalization.
    • Formatter/lexer/parser now properly recognize and preserve backtick delimiter/interpolation syntax.
  • Bug Fixes
    • More precise template diagnostics and improved diagnostic span stability.

@vercel

vercel Bot commented May 26, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
beps Ready Ready Preview, Comment Jun 18, 2026 7:46am
new-boundaryml-website Error Error Jun 18, 2026 7:46am
promptfiddle Ready Ready Preview, Comment Jun 18, 2026 7:46am
promptfiddle2 Ready Ready Preview, Comment Jun 18, 2026 7:46am

Request Review

Lands the inert form of BEP-049 backtick strings: lex, parse, lower, and
format `` `...` `` (including multi-tick ladder + auto-dedent), with `${...}`
left as literal text until M2 wires interpolation.

- Lexer: new `Backtick` token + `BACKTICK` syntax kind.
- Parser: anchored-close ladder per BEP §8 (incl. JEP-326 cases). Detects
  empty multi-tick (`` `` ``) at the opener and emits a clean diagnostic
  instead of silently consuming downstream content.
- AST: `BacktickStringLiteral` with `value()` that decodes escapes and runs
  multi-line content through `baml_base::dedent::preprocess_template`.
- HIR lowering: backtick literal → `Expr::Literal::String`.
- Formatter: prints backtick strings verbatim, round-trip idempotent.
- Consolidation: moves `unescape_string_literal` into `baml_base::escape`
  (kills two near-identical copies) and adds `unescape_backtick_string_literal`
  for `` \` `` and `\${` escapes. Lifts `preprocess_template` out of
  `sys_llm::jinja::render` into `baml_base::dedent` so both prompt rendering
  and the new backtick lowering share a single dedenter.

Tests:
- Unit: 3 lexer + 8 parser + 9 escape + 6 dedent + 6 HIR lowering + 3 fmt.
- Fixture pipeline: `baml_tests/projects/compiles/backtick_strings/`
  covers lex → parser → HIR → TIR → MIR → diagnostics → codegen → formatter.
- Runtime: 4 end-to-end tests in `bex_engine` execute backtick functions
  on the VM and assert the returned `BexExternalValue::String` matches.

Tracking doc at `architecture/bep_049_milestones.md` lays out M1–M7.

Lexer snapshot churn is mechanical: existing `.baml` fixtures with literal
backticks in docstrings/error-message strings used to lex as `Error "\``"`
and now lex as `Backtick "\``"`.
@github-actions

github-actions Bot commented May 27, 2026

Copy link
Copy Markdown

No description provided.

@coderabbitai

coderabbitai Bot commented May 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Implements BEP-049 backtick interpolated string literals and tagged-template expressions end-to-end: shared dedent/escape utilities with Unicode-safe processing, a new Backtick lexer token with multi-tick delimiter parsing, backtick AST wrappers with dedent and segment building, compiler AST Expr::Template nodes with tagged/untagged variants, CST-to-AST lowering with prompt-closure synthesis, HIR/MIR/TIR pipeline extensions for capture analysis and type inference, LLM runtime integration with per-attempt prompt compilation, formatter support for verbatim backtick output, comprehensive fixture coverage, and binary size-gate refresh.

Changes

BEP-049 End-to-end Implementation

Layer / File(s) Summary
Template preprocessing, escape utilities, and TaggedString runtime struct
baml_language/crates/baml_base/src/dedent.rs, baml_language/crates/baml_base/src/escape.rs, baml_language/crates/baml_base/src/lib.rs, baml_language/crates/baml_builtins2/baml_std/baml/core.baml
Exports preprocess_template computing longest-common leading-whitespace prefix across non-blank lines with UTF-8 byte-safe stripping, unescape_string_literal and unescape_backtick_string_literal for flavor-specific escape decoding (backtick includes \`` / $recognition and CRLF normalization), registers both as public crate modules, and definesTaggedString { parts: string[], values: unknown[] }runtime struct withparts.length == values.length + 1` invariant.
Backtick lexical tokenization and parser grammar
baml_language/crates/baml_compiler_lexer/src/tokens.rs, baml_language/crates/baml_compiler_syntax/src/syntax_kind.rs, baml_language/crates/baml_compiler_parser/src/parser.rs
Adds TokenKind::Backtick, syntax kinds for BACKTICK_STRING_LITERAL and ${...} block-tag markers (BACKTICK_FOR_OPEN/ENDFOR/IF_OPEN/ELSE_IF/ELSE/ENDIF), implements multi-tick delimiter-ladder backtick parsing with anchored close and escaped character handling, routes ${...} into block-tag vs expression forms, adds Pratt-postfix tagged-template wrapping when backtick follows expression with no newline, makes match scrutinee parentheses optional (: Type only in paren form), accepts client in field/member contexts with disambiguation, restricts Word regex to reject trailing $ so interpolation operators lex separately, and extends parser test coverage.
CST/AST wrappers for backtick literals
baml_language/crates/baml_compiler_syntax/Cargo.toml, baml_language/crates/baml_compiler_syntax/src/ast.rs
Imports baml_base dependency and shared unescape helpers, adds typed BacktickStringLiteral/BacktickText/BacktickInterpolation wrappers, implements full API with full_text(), delimiter_count(), raw_inner(), is_multiline(), value(), segments(), segments_with_errors(), builds two-pass flat-part stream from CST tokens/nodes and reconstructs segments with escape decoding and dedent via placeholder strategy, applies BEP §13 "alone-on-line" whitespace trimming for block tags while excluding inline ${expr}, validates block structure with stack-based error detection for unclosed/mismatched/stray tags, and wires backtick/tagged-template nodes into PromptField.backtick_string(), LetStmt.initializer(), BlockExpr.elements(), and Field.name() accessors.
Compiler AST template model and lowering diagnostics
baml_language/crates/baml_compiler2_ast/src/ast.rs, baml_language/crates/baml_compiler2_ast/src/lowering_diagnostic.rs, baml_language/crates/baml_compiler2_ast/src/docstring.rs, baml_language/crates/baml_compiler2_ast/src/auto_derive_json.rs, baml_language/crates/baml_compiler2_ast/src/lib.rs
Adds Expr::Template with TemplateTag::Default { elaborated: ExprId } (default realization) and TemplateTag::Custom { tag, body }, TemplateSegment tree including Text/Interp/For (iterator and C-style) / If with branches and optional else, FunctionDef::is_tagged_template_tag field to mark tagged-string tags with //baml:tagged_string marker, LlmBodyDef::stream_body: Option<(ExprBody, AstSourceMap)> for pre-lowered $stream companion, introduces has_baml_marker helper for directive detection, adds LoweringDiagnostic::MalformedTemplateBlock (error) and EmptyInterpolation (warning) variants, renames maybe_synthesize_json_methods to maybe_synthesize_derived_methods, re-exports shared unescape, and extends AST tests with backtick/tagged-template lowering and interpolation-chain validation.
CST-to-AST template lowering and LLM prompt closure synthesis
baml_language/crates/baml_compiler2_ast/src/lower_expr_body.rs, baml_language/crates/baml_compiler2_ast/src/lower_cst.rs, baml_language/crates/baml_compiler2_ast/src/companions.rs
Dispatches BACKTICK_STRING_LITERAL to lower_backtick_string_literal producing Expr::Template with default realization via elaborate_default_segments, dispatches TAGGED_TEMPLATE_EXPR to lower_tagged_template_expr producing custom tag closure, implements untagged elaboration with accumulator loops for text segments and implicit .to_string() coercions for ${expr}, supports iterator and C-style ${for} (translated to accumulator while), builds ${if} as inside-out host if-chain with empty string for missing else, implements tagged templates via build_prompt_tag_closure constructing baml.TaggedString { parts, values }, adds synthesize_llm_call_with_prompt for new-mode LLM function bodies with prompt_closure parameter, pre-computes stream_body and companion bodies from backtick prompts in lower_function, derives is_tagged_template_tag via has_baml_marker(..., "tagged_string"), sets is_tagged_template_tag: false on all synthesized lambdas, and propagates flag through llm_parse/make_llm_companion into generated companion function defs.
HIR scope/capture tracking and MIR custom template lowering
baml_language/crates/baml_compiler2_hir/src/item_tree.rs, baml_language/crates/baml_compiler2_hir/src/scope.rs, baml_language/crates/baml_compiler2_hir/src/builder.rs, baml_language/crates/baml_compiler2_mir/src/lower.rs
Adds Function::is_tagged_template_tag to HIR and propagates from AST during allocation, introduces Scope::is_template_body to mark synthetic template-body lambda scopes as transparent for inference ownership, implements walk_template_lambda_body that pushes synthetic lambda scope, walks flattened body, computes captures via analyze_lambda_captures, and performs transitive capture re-recording when nested, lowers custom templates in MIR via lower_tagged_template that resolves tag function and constructs hand-rolled body closure producing baml.TaggedString, implements tagged_body_param_bindings map for MIR-only closure parameters, supports static (text+interps) and dynamic (with control flow) paths, adds ensure_transitive_capture for nested lambda parameter forwarding, handles IO sys-op omitted-default materialization at call sites via sys_op_callee detection, and extends lower_path_expr to resolve tagged-body parameters as locals or transitive captures.
TIR type inference, tagged-tag validation, and diagnostic span stabilization
baml_language/crates/baml_compiler2_tir/src/builder.rs, baml_language/crates/baml_compiler2_tir/src/inference.rs, baml_language/crates/baml_compiler2_tir/src/infer_context.rs, baml_language/crates/baml_compiler2_tir/src/throws_analysis.rs, baml_language/crates/baml_compiler2_tir/src/normalize.rs, baml_language/crates/baml_compiler2_visualization/src/control_flow/from_ast.rs, baml_language/crates/baml_lsp2_actions/src/check.rs, baml_language/crates/baml_lsp2_actions/src/completions.rs
Extends TypeInferenceBuilder with template_body_params map to track synthetic parameter types per FileScopeId, threads params through ScopeInference with seed_template_body_params lambda inference helper, validates tagged-template tag function shape via validate_tagged_tag checking first parameter is body: (...) -> baml.TaggedString, implements Expr::Template synthesis for tagged (infers tag, binds/records body params, types body) and untagged (types elaboration, retains user diagnostics, enforces nullability/stringability of ${expr}), adds interpolation helpers (check_template_interps_stringable, check_interp_stringable, is_unit_tail) to enforce non-nullable and .to_string()-convertible values, extends throws analysis with recursive template-segment walking, refactors unknown-like detection via ty_contains_unknown_like with builtin-unknown toggle, adds type errors for invalid tags (TaggedTagNotAFunction, TaggedTagNotMarked, TaggedTagBadBodyParam) and invalid interpolations (InterpolatedValueMaybeNull, TypeNotInterpolatable), implements diagnostic span freezing via freeze_diagnostic_spans_from to preserve nested lambda error locations, stabilizes lambda diagnostic handling with lambda_diag_start capture, updates try_resolve_member_on_ty so union members must resolve on all arms, extends default-parameter forward-reference and throws fact collection for template segments, updates LSP completion detection to require annotation context check, and maps new type error variants to diagnostic IDs.
Compiler emission, PPIR companion reuse, and LLM prompt-closure runtime
baml_language/crates/baml_compiler2_emit/src/lib.rs, baml_language/crates/baml_compiler2_ppir/src/lib.rs, baml_language/crates/baml_builtins2/baml_std/baml/ns_llm/llm.baml, baml_language/crates/baml_builtins2/baml_std/baml/ns_llm/llm_types.baml
Refactors emit metadata to construct FunctionMeta::Llm when client is present (regardless of prompt), sets prompt_template from llm_meta.prompt when available or empty string otherwise, updates PPIR companion generation to prefer pre-lowered stream_body from DeclarativeMeta::Llm and propagate is_tagged_template_tag, adds prompt_closure: ((Context) -> PromptAst)? parameter to ExecutionContext and LLM function signatures, introduces OutputFormat/Role/ContextClient/Context typed prompt model classes, adds assembly helper assemble_prompt_ast(parts, values) and schema helpers render_output_format/build_output_format, implements PrimitiveClient.render_specialized_prompt branching on closure presence (legacy render_prompt vs closure invocation with per-attempt Context), updates call_llm_function and stream_llm_function to accept and pass prompt_closure to ExecutionContext, and extends execute_once_stream/execute_once_oneshot to use unified render_specialized_prompt path.
Formatter expression rendering for backtick literals
baml_language/crates/baml_fmt/src/ast/tokens.rs, baml_language/crates/baml_fmt/src/ast/expressions.rs, baml_language/crates/baml_fmt/src/lib.rs
Adds BacktickString formatter token with token_span construction from opening backtick through node end, implements Token/KnownKind/Printable traits with raw-text output via print_raw_token, multiline width detection via newline presence in span, and leftmost/rightmost token helpers returning backtick characters, adds Expression::BacktickString variant with FromCST construction and delegation to token methods, and includes round-trip and idempotence formatter tests.
Compile fixtures, diagnostic validation, and TIR regression tests
baml_language/crates/baml_tests/projects/compiles/backtick_strings/main.baml, baml_language/crates/baml_tests/projects/diagnostic_errors/backtick_strict_types/main.baml, baml_language/crates/baml_tests/projects/diagnostic_errors/control_flow/main.baml, baml_language/crates/baml_tests/src/compiler2_tir/phase3a.rs
Adds M1/M2/M3 backtick compile fixtures covering non-interpolated literals with escapes/multiline/multi-tick, ${expr} interpolation for primitives and function calls, and ${for}/${if} block control-flow, pins strict-type diagnostic fixtures for nullable interpolation scenarios, adjusts control-flow snapshot to use guaranteed-missing method call, adds phase3a gap tests verifying clean prompt-closure compilation, non-zero diagnostic spans for user-facing errors, and correct list element type pinning behavior.

CI Size-gate Updates

Layer / File(s) Summary
aarch64-apple-darwin size threshold and measurement refresh
baml_language/.cargo/size-gate.toml, baml_language/.ci/size-gate/aarch64-apple-darwin.toml
Raises baml-cli and packed-program macOS arm64 max_file_bytes ceilings in the gate config and updates recorded timestamp, git SHA, and binary artifact byte metrics in the CI size-gate snapshot to the 2026-06-17 build baseline.

Sequence Diagram(s)

sequenceDiagram
  rect rgba(135, 206, 250, 0.5)
    note over BAML Source,Lexer: Lexing
    BAML Source->>Lexer: backtick literal or tag`...`
    Lexer->>Parser: Backtick, Dollar, LBrace, Word, RBrace tokens
  end
  rect rgba(144, 238, 144, 0.5)
    note over Parser,SyntaxAST: Parsing & Syntax
    Parser->>Parser: parse_backtick_string (delimiter ladder, ${for}/${if}/${expr})
    Parser->>SyntaxAST: BACKTICK_STRING_LITERAL or TAGGED_TEMPLATE_EXPR node
    SyntaxAST->>SyntaxAST: BacktickStringLiteral API — dedent, escape decode, segments
  end
  rect rgba(255, 218, 185, 0.5)
    note over lower_cst,TIR: Lowering & Typing
    lower_cst->>lower_expr_body: detect backtick prompt in llm.prompt_field()
    lower_expr_body->>CompilerAST: produce Expr::Template (TemplateTag::Default or Custom)
    lower_cst->>lower_expr_body: synthesize_llm_call_with_prompt → stores stream_body
    TIR->>TIR: validate_tagged_tag, seed template_body_params, check interp nullability
  end
  rect rgba(221, 160, 221, 0.5)
    note over PPIR,LLM Runtime: Execution
    PPIR->>PPIR: prefer pre-lowered stream_body over synthesized fallback
    LLM Runtime->>LLM Runtime: call_llm_function with prompt_closure parameter
    LLM Runtime->>LLM Runtime: branch on prompt_closure — closure vs legacy Jinja
  end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~150 minutes

Possibly related PRs

  • BoundaryML/baml#3094: Both PRs add new LLM function body lowering paths that ultimately call baml.llm.call_llm_function for bytecode execution; main PR adds backtick prompt closure variant while retrieved PR synthesizes generic LLM function bodies.
  • BoundaryML/baml#3096: Both PRs extend the template-string preprocessing pipeline; main PR introduces preprocess_template in baml_base::dedent, while retrieved PR uses this new function in sys_llm Jinja prompt template construction.
  • BoundaryML/baml#3311: Both PRs modify the LLM prompt-rendering pipeline (baml_std/ns_llm/llm.baml), particularly render_prompt and PrimitiveClient::render_prompt flow; main PR adds backtick/tagged-template closure-based rendering, retrieved PR adds early return_type resolution for output format rendering.

Poem

🐰 Hop, hop, the backtick lands!
With ${expr} in my paws and plans,
Tagged templates bloom from dedented strings,
The prompt closure flows on compiler wings.
No more Jinja—new-mode reigns!
`Hello, ${world}` 🎉 the rabbit explains.

🚥 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 PR title 'BEP-049 M1–M4: backtick strings, interpolation, control flow, tagged templates' clearly and concisely summarizes the main changes: implementation of BEP-049 milestones M1 through M4 covering backtick string literals, ${expr} interpolation, block control flow, and tagged templates. It is specific, descriptive, and accurately reflects the primary objective of this comprehensive feature implementation.
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/string-interpolation

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.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🧹 Nitpick comments (2)
baml_language/crates/baml_compiler_syntax/src/ast.rs (1)

1063-1073: 💤 Low value

Dedent condition checks decoded content instead of source multiline status.

The condition decoded.contains('\n') determines whether to apply dedent based on the decoded content having newlines. This differs from is_multiline() which correctly checks whether the source spans multiple lines. Per BEP-049, dedent should apply only to source-level multiline strings, not to single-line sources containing \n escape sequences.

Practically, preprocess_template on single-line content with embedded newlines produces the same result, so tests pass. However, the semantic intent is clearer with is_multiline().

♻️ Suggested fix for clarity
     pub fn value(&self) -> String {
         let Some(inner) = self.raw_inner() else {
             return String::new();
         };
         let decoded = unescape_backtick_string_literal(&inner);
-        if decoded.contains('\n') {
+        if self.is_multiline() {
             baml_base::dedent::preprocess_template(&decoded)
         } else {
             decoded
         }
     }
🤖 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_syntax/src/ast.rs` around lines 1063 -
1073, The value() method currently decides to call
baml_base::dedent::preprocess_template based on decoded.contains('\n'); change
it to use the source-level multiline check by calling is_multiline() instead
(i.e., replace the decoded.contains('\n') condition with self.is_multiline()).
Keep the rest of the logic (unescape_backtick_string_literal(&inner) and
returning either preprocess_template(&decoded) or decoded) unchanged so dedent
runs only for source multiline strings.
baml_language/crates/baml_compiler2_ast/src/lower_expr_body.rs (1)

2300-2301: 💤 Low value

Remove redundant import.

rowan::ast::AstNode is already imported at line 10, so the local use statement on line 2301 is unnecessary.

♻️ Remove redundant import
 fn lower_backtick_string_literal(&mut self, node: &SyntaxNode) -> ExprId {
     use baml_compiler_syntax::BacktickStringLiteral;
-    use rowan::ast::AstNode;

     let value = BacktickStringLiteral::cast(node.clone())
🤖 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
2300 - 2301, Remove the redundant import of rowan::ast::AstNode in
lower_expr_body.rs: delete the duplicate `use rowan::ast::AstNode;` line (the
file already imports AstNode earlier), leaving only the single existing import
to avoid duplicate imports and clippy/warning noise; locate the extra `use` near
the block that also imports `baml_compiler_syntax::BacktickStringLiteral` and
remove it.
🤖 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 1840-1849: The helper find_first_backtick_pos (and the similar
helper at the other location) only skips basic trivia via is_basic_trivia, but
parse_backtick_string is entered via self.at(TokenKind::Backtick) which skips
full trivia (including comments), so these helpers can miss an opening backtick
when comments lie between current and the backtick. Fix both helpers to skip the
same set of trivia that self.at uses (i.e., include comments) — replace the
is_basic_trivia check with the broader trivia predicate used by self.at (or
reuse the same utility method used by self.at, or add a small wrapper like
is_full_trivia/is_trivia_including_comments) so the loop advances past comments
as well and then checks for TokenKind::Backtick; update both occurrences
(find_first_backtick_pos and the other helper around the parse_backtick_string
flow) to use this corrected trivia-skipping logic.

---

Nitpick comments:
In `@baml_language/crates/baml_compiler_syntax/src/ast.rs`:
- Around line 1063-1073: The value() method currently decides to call
baml_base::dedent::preprocess_template based on decoded.contains('\n'); change
it to use the source-level multiline check by calling is_multiline() instead
(i.e., replace the decoded.contains('\n') condition with self.is_multiline()).
Keep the rest of the logic (unescape_backtick_string_literal(&inner) and
returning either preprocess_template(&decoded) or decoded) unchanged so dedent
runs only for source multiline strings.

In `@baml_language/crates/baml_compiler2_ast/src/lower_expr_body.rs`:
- Around line 2300-2301: Remove the redundant import of rowan::ast::AstNode in
lower_expr_body.rs: delete the duplicate `use rowan::ast::AstNode;` line (the
file already imports AstNode earlier), leaving only the single existing import
to avoid duplicate imports and clippy/warning noise; locate the extra `use` near
the block that also imports `baml_compiler_syntax::BacktickStringLiteral` and
remove it.
🪄 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: e5e898f2-8a4b-4eaf-afda-5b333e80befe

📥 Commits

Reviewing files that changed from the base of the PR and between 763fa16 and 3ab9197.

⛔ Files ignored due to path filters (55)
  • baml_language/Cargo.lock is excluded by !**/*.lock
  • baml_language/crates/baml_tests/snapshots/broken_syntax/banned_expressions/baml_tests__broken_syntax__banned_expressions__01_lexer__banned_expressions.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/broken_syntax/error_cases/baml_tests__broken_syntax__error_cases__01_lexer__syntax_errors.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/broken_syntax/mismatched_brackets/baml_tests__broken_syntax__mismatched_brackets__01_lexer__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/broken_syntax/namespaces_same_name/baml_tests__broken_syntax__namespaces_same_name__01_lexer__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/broken_syntax/parser_error_recovery/baml_tests__broken_syntax__parser_error_recovery__01_lexer__partial_input.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/broken_syntax/type_annotation_errors/baml_tests__broken_syntax__type_annotation_errors__01_lexer__type_malformed.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/__assert_std__/baml_tests__compiles____assert_std____01_lexer__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/__baml_std__/baml_tests__compiles____baml_std____01_lexer__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/__testing_std__/baml_tests__compiles____testing_std____01_lexer__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/backtick_strings/baml_tests__compiles__backtick_strings__01_lexer__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/backtick_strings/baml_tests__compiles__backtick_strings__02_parser__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/backtick_strings/baml_tests__compiles__backtick_strings__03_hir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/backtick_strings/baml_tests__compiles__backtick_strings__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/backtick_strings/baml_tests__compiles__backtick_strings__04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/backtick_strings/baml_tests__compiles__backtick_strings__05_diagnostics.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/backtick_strings/baml_tests__compiles__backtick_strings__06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/backtick_strings/baml_tests__compiles__backtick_strings__10_formatter__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/deep_method_call/baml_tests__compiles__deep_method_call__01_lexer__deep_method_call.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/host_callable_call/baml_tests__compiles__host_callable_call__01_lexer__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/is_operator/baml_tests__compiles__is_operator__01_lexer__is_operator.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/json_alias_basic/baml_tests__compiles__json_alias_basic__01_lexer__json_alias_basic.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/json_cross_namespace_static_call/baml_tests__compiles__json_cross_namespace_static_call__01_lexer__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/json_cross_namespace_static_call/baml_tests__compiles__json_cross_namespace_static_call__01_lexer__ns_pkg_ns_inner_box.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/json_llm_return_type/baml_tests__compiles__json_llm_return_type__01_lexer__json_llm_return_type.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/json_map_literal/baml_tests__compiles__json_map_literal__01_lexer__json_map_literal.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/json_to_from_string_composite_generic/baml_tests__compiles__json_to_from_string_composite_generic__01_lexer__json_to_from_string_composite_generic.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/json_to_from_string_concrete/baml_tests__compiles__json_to_from_string_concrete__01_lexer__json_to_from_string_concrete.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/json_to_from_string_generic_forwarding/baml_tests__compiles__json_to_from_string_generic_forwarding__01_lexer__json_to_from_string_generic_forwarding.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/json_to_from_string_three_level/baml_tests__compiles__json_to_from_string_three_level__01_lexer__json_to_from_string_three_level.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/lambda_fat_arrow/baml_tests__compiles__lambda_fat_arrow__01_lexer__lambda_fat_arrow.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/lambda_field_access/baml_tests__compiles__lambda_field_access__01_lexer__lambda_field_access.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/lexical_scoping/baml_tests__compiles__lexical_scoping__01_lexer__lexical_scoping.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/patterns_new/baml_tests__compiles__patterns_new__01_lexer__patterns_new.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/reflect_type_of_user_generic/baml_tests__compiles__reflect_type_of_user_generic__01_lexer__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/stream_llm_inferred_typeargs/baml_tests__compiles__stream_llm_inferred_typeargs__01_lexer__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/type_annotation/baml_tests__compiles__type_annotation__01_lexer__type_alias_interaction.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/type_annotation/baml_tests__compiles__type_annotation__01_lexer__type_as_identifier.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/type_annotation/baml_tests__compiles__type_annotation__01_lexer__type_positions.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/captured_field_chain/baml_tests__diagnostic_errors__captured_field_chain__01_lexer__captured_field_chain.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/catch_return_type_mismatch/baml_tests__diagnostic_errors__catch_return_type_mismatch__01_lexer__catch_return_type_mismatch.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/catch_throw_regressions/baml_tests__diagnostic_errors__catch_throw_regressions__01_lexer__regressions.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/format_checks/baml_tests__diagnostic_errors__format_checks__01_lexer__match_exprs.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/generic_call_ambiguity/baml_tests__diagnostic_errors__generic_call_ambiguity__01_lexer__array_compare.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/generic_call_ambiguity/baml_tests__diagnostic_errors__generic_call_ambiguity__01_lexer__generic_call.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/generics/baml_tests__diagnostic_errors__generics__01_lexer__classes.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/instanceof_removed/baml_tests__diagnostic_errors__instanceof_removed__01_lexer__instanceof_removed.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/json_static_method_arity/baml_tests__diagnostic_errors__json_static_method_arity__01_lexer__json_static_method_arity.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/match_exhaustiveness/baml_tests__diagnostic_errors__match_exhaustiveness__01_lexer__match_exhaustiveness.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/patterns_new/baml_tests__diagnostic_errors__patterns_new__01_lexer__patterns_new.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/stream_types/baml_tests__diagnostic_errors__stream_types__01_lexer__builtin_refs.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/test_with_runner_ambiguity/baml_tests__diagnostic_errors__test_with_runner_ambiguity__01_lexer__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/type_reflection_strict/baml_tests__diagnostic_errors__type_reflection_strict__01_lexer__reflect_intrinsic_errors.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/type_reflection_strict/baml_tests__diagnostic_errors__type_reflection_strict__01_lexer__strict_normalization.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/unknown_type_error/baml_tests__diagnostic_errors__unknown_type_error__01_lexer__unknown_type_error.snap is excluded by !**/*.snap
📒 Files selected for processing (15)
  • baml_language/crates/baml_base/src/dedent.rs
  • baml_language/crates/baml_base/src/escape.rs
  • baml_language/crates/baml_base/src/lib.rs
  • baml_language/crates/baml_compiler2_ast/src/lib.rs
  • baml_language/crates/baml_compiler2_ast/src/lower_expr_body.rs
  • baml_language/crates/baml_compiler_lexer/src/tokens.rs
  • baml_language/crates/baml_compiler_parser/src/parser.rs
  • baml_language/crates/baml_compiler_syntax/Cargo.toml
  • 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/tokens.rs
  • baml_language/crates/baml_fmt/src/lib.rs
  • baml_language/crates/baml_tests/projects/compiles/backtick_strings/main.baml
  • baml_language/crates/bex_engine/tests/backtick_strings.rs

Comment thread baml_language/crates/baml_compiler_parser/src/parser.rs
Lands the active form of BEP-049 backtick strings: `${expr}` interpolates,
with full block-expression body semantics (BEP §4 — statements + optional
trailing expression). Plus TypeScript-parity tightening for escape decoding
identified by auditing typescript-go's scanner.

## Parser / segments / lowering

- Parser: detect adjacent `${` (no trivia between `$` and `{`) and dispatch
  to `parse_backtick_interpolation`, which reuses `parse_block_expr` for
  the body — so the full host block grammar (let, while, if, throw, return,
  …) works inside `${...}`.
- `BacktickSegment` AST type: alternating `Text` / `Interp` segments
  consumed by the lowerer. M4's tagged-template lowering will share the
  same primitive — different transform, same input.
- Multi-line dedent (BEP §12) treats `${...}` as opaque placeholders for
  min-indent calculation per §12 rule 8.
- Lowering: text segments + `Interp.to_string()` concatenated into a
  left-folded `+` chain. Empty / statement-only blocks (BEP §4 unit case)
  emit `""` directly — no `unit.to_string()` failure.

## Implicit `.to_string()` (BEP §11)

- Added `to_string(self) -> string` to `Int`, `Bool`, `Float`, `String`
  in `baml_std` (Rust impls in `bex_vm::package_baml::*`). String's
  variant is a no-op so the lowering doesn't need to special-case it.
- Untagged `${expr}` lowers to `expr.to_string()`. Non-string types
  (incl. `int?`, `null`, user classes without `to_string`) produce a
  compile error — BEP §7 explicitly endorses this: "surfaces null-handling
  bugs in prompts at type-check time instead of at LLM-call time."

## TS-parity escape tightening (audit vs typescript-go/internal/scanner)

- AA: `\r\n` and bare `\r` in backtick text normalize to `\n` (mirrors
  scanner.go:1650-1660).
- BB: `\b`, `\v`, `\f` extended escapes added to the canonical escape
  decoder (mirrors scanner.go:1721-1736). Applied to both quote and
  backtick flavors.

## Bug fixes surfaced by audit

- Lexer regex previously absorbed a trailing `$` into the preceding Word
  (`before-$` → one token), masking `${` adjacency. Tightened to require
  any `$` to be a separator between identifier chunks. Caught by
  `backtick_nested_in_interpolation`.
- `BacktickStringLiteral::delimiter_count` used `filter_map(into_token)
  .take_while(BACKTICK)`, silently skipping past child nodes and
  miscounting the closing run as part of the opener for `${a}${b}`
  (adjacent interps). Now breaks on first non-BACKTICK element.

## Tests

- 38 runtime tests in `bex_engine` exercising every shape: while-loop in
  interp body, recursive function calls, mixed-type implicit to_string,
  TS-parity escapes, CR/CRLF normalization, comments inside `${...}`,
  trailing `$`, multi-tick + interp, nested backticks.
- 18 parser unit tests (incl. segments adjacency regression, empty
  multi-tick, JEP-326 anchored close, escape-doesn't-interpolate).
- 9 escape-decoder unit tests + 7 lowering unit tests.
- Compile-pipeline fixtures:
  - `compiles/backtick_strings/main.baml` extended with M2 cases —
    8 stages green (lexer → parser → HIR → TIR → MIR → diagnostics →
    codegen → formatter).
  - `diagnostic_errors/backtick_strict_types/main.baml` pins the
    strict-type compile errors for null / class-without-to_string /
    optional interpolation cases.

## Fixture / snapshot churn (incidental)

- `__baml_std__` snapshots: updated for 4 new `to_string` methods.
- `bytecode_display_expanded_unoptimized`: builtin offsets shifted.
- `diagnostic_errors/control_flow/main.baml`: previously errored on
  `z.to_string()` (Int had no to_string); rewrote to use a guaranteed-
  missing method so the project keeps its tier classification.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
baml_language/crates/baml_compiler_parser/src/parser.rs (1)

1919-1923: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Consume only the immediate raw token after \.

The second bump_raw() skips intervening whitespace/newlines, so inputs like `\ ${name}` or `\ ` can swallow the later $/` and suppress interpolation or the closing delimiter. After consuming the backslash, this path needs to emit exactly one raw token, not “next non-trivia token.”

Proposed fix
+    /// Consume exactly one raw token at `self.current` without skipping trivia.
+    fn bump_exact_raw(&mut self) -> bool {
+        let Some(token) = self.tokens.get(self.current) else {
+            return false;
+        };
+        self.events.push(Event::Token {
+            kind: token_kind_to_syntax_kind(token.kind),
+            text: token.text.clone(),
+        });
+        self.current += 1;
+        true
+    }
+
     fn parse_backtick_content(&mut self, opening_ticks: usize) {
         let mut loop_counter = 0;
         loop {
@@
             if self.at_raw(TokenKind::Backslash) {
                 self.bump_raw();
                 if self.current < self.tokens.len() {
-                    self.bump_raw();
+                    self.bump_exact_raw();
                 }
                 continue;
             }
🤖 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 1919 -
1923, After detecting a raw backslash (self.at_raw(TokenKind::Backslash)) we
should consume exactly one immediate token (the token directly following the
backslash) instead of calling bump_raw which advances to the next non-trivia
token; change the second consume to advance/consume only the next token in the
token stream (the token at self.current, after the backslash bump) so
interpolation delimiters or closing backticks aren't skipped. Update the logic
around self.bump_raw(), self.current and self.tokens so the backslash path
consumes exactly one following token (the immediate token) rather than “next
non-trivia token.”
🧹 Nitpick comments (1)
baml_language/crates/baml_compiler_lexer/src/tokens.rs (1)

853-873: ⚡ Quick win

Add a regression case for before-${x} token splitting.

Line 856 validates ${...} tokenization, but it doesn’t cover the specific guard documented in Lines 112-116 (preventing Word("before-$")). Please add a case asserting Word("before-"), Dollar, LBrace, Word("x"), RBrace for `before-${x}`.

Proposed test addition
     #[test]
     fn test_backtick_with_interpolation_tokens() {
         // ${...} inside backtick — lexer emits the raw tokens; parser assembles
         let source = "`Hello ${name}`";
         let tokens = lex_no_whitespace(source);

         assert_eq!(
             tokens,
             vec![
                 TokenKind::Backtick,
                 TokenKind::Word, // Hello
                 TokenKind::Dollar,
                 TokenKind::LBrace,
                 TokenKind::Word, // name
                 TokenKind::RBrace,
                 TokenKind::Backtick,
             ]
         );

         assert_eq!(reconstruct_source(&lex(source)), source);
+
+        // Regression: `${` must not be absorbed into a trailing-$ word segment.
+        let source2 = "`before-${x}`";
+        let tokens2 = lex_no_whitespace(source2);
+        assert_eq!(
+            tokens2,
+            vec![
+                TokenKind::Backtick,
+                TokenKind::Word, // before-
+                TokenKind::Dollar,
+                TokenKind::LBrace,
+                TokenKind::Word, // x
+                TokenKind::RBrace,
+                TokenKind::Backtick,
+            ]
+        );
+        assert_eq!(reconstruct_source(&lex(source2)), source2);
     }
🤖 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_lexer/src/tokens.rs` around lines 853 -
873, Update the backtick interpolation test to cover the guard that prevents
combining a trailing '$' into a Word token: inside the
test_backtick_with_interpolation_tokens (which uses lex_no_whitespace and
lex/reconstruct_source), add an assertion for the source "`before-${x}`" that
expects tokens [TokenKind::Backtick, TokenKind::Word (with text "before-"),
TokenKind::Dollar, TokenKind::LBrace, TokenKind::Word (with text "x"),
TokenKind::RBrace, TokenKind::Backtick] and also assert
reconstruct_source(&lex(source)) == source to ensure round-trip correctness;
locate uses of lex_no_whitespace, lex, and reconstruct_source in the test to add
this new case.
🤖 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_syntax/src/ast.rs`:
- Around line 1168-1196: The dedent remapping in BacktickStringLiteral::segments
misaligns when the first decoded part is RawPart::Interp because the current
loop only advances the split iterator for RawPart::Text; fix by changing the
remapping loop that walks decoded (the for p in &mut decoded after
dedented.split(PLACEHOLDER)) so that it consumes iter.next() for every part
(call iter.next() and ignore its value for RawPart::Interp, assign it to
RawPart::Text for Text parts) thereby preserving split positions, keep the
existing tail-handling logic that appends any leftover rest to the last
RawPart::Text, and add a new test covering a multiline template that starts with
an interpolation to prevent regressions.

In `@baml_language/crates/baml_compiler2_ast/src/lower_expr_body.rs`:
- Around line 2300-2302: The doc comment on lower_backtick_string_literal is
stale: update the sentence "Implicit `.to_string()` for non-string types is task
`#13`" to state that lowering now performs implicit `.to_string()` on non-string
interpolations (reflecting the conversion logic in the interpolation-handling
block that forces non-string child expressions to strings), so the comment
matches the implemented behavior in lower_backtick_string_literal and the
adjacent interpolation-lowering code paths.

---

Outside diff comments:
In `@baml_language/crates/baml_compiler_parser/src/parser.rs`:
- Around line 1919-1923: After detecting a raw backslash
(self.at_raw(TokenKind::Backslash)) we should consume exactly one immediate
token (the token directly following the backslash) instead of calling bump_raw
which advances to the next non-trivia token; change the second consume to
advance/consume only the next token in the token stream (the token at
self.current, after the backslash bump) so interpolation delimiters or closing
backticks aren't skipped. Update the logic around self.bump_raw(), self.current
and self.tokens so the backslash path consumes exactly one following token (the
immediate token) rather than “next non-trivia token.”

---

Nitpick comments:
In `@baml_language/crates/baml_compiler_lexer/src/tokens.rs`:
- Around line 853-873: Update the backtick interpolation test to cover the guard
that prevents combining a trailing '$' into a Word token: inside the
test_backtick_with_interpolation_tokens (which uses lex_no_whitespace and
lex/reconstruct_source), add an assertion for the source "`before-${x}`" that
expects tokens [TokenKind::Backtick, TokenKind::Word (with text "before-"),
TokenKind::Dollar, TokenKind::LBrace, TokenKind::Word (with text "x"),
TokenKind::RBrace, TokenKind::Backtick] and also assert
reconstruct_source(&lex(source)) == source to ensure round-trip correctness;
locate uses of lex_no_whitespace, lex, and reconstruct_source in the test to add
this new case.
🪄 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: 47ec80ea-80ac-4bcb-af3d-65b1627839d4

📥 Commits

Reviewing files that changed from the base of the PR and between 3ab9197 and c59c46a.

⛔ Files ignored due to path filters (27)
  • baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_describe_builtin_item_by_definition.snap is excluded by !**/*.snap
  • baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_describe_builtin_string.snap is excluded by !**/*.snap
  • baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_describe_truncation_hint.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/__baml_std__/baml_tests__compiles____baml_std____03_hir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/__baml_std__/baml_tests__compiles____baml_std____04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/__baml_std__/baml_tests__compiles____baml_std____06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/backtick_strings/baml_tests__compiles__backtick_strings__01_lexer__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/backtick_strings/baml_tests__compiles__backtick_strings__02_parser__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/backtick_strings/baml_tests__compiles__backtick_strings__03_hir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/backtick_strings/baml_tests__compiles__backtick_strings__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/backtick_strings/baml_tests__compiles__backtick_strings__04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/backtick_strings/baml_tests__compiles__backtick_strings__06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/backtick_strings/baml_tests__compiles__backtick_strings__10_formatter__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/backtick_strict_types/baml_tests__diagnostic_errors__backtick_strict_types__01_lexer__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/backtick_strict_types/baml_tests__diagnostic_errors__backtick_strict_types__02_parser__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/backtick_strict_types/baml_tests__diagnostic_errors__backtick_strict_types__03_hir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/backtick_strict_types/baml_tests__diagnostic_errors__backtick_strict_types__04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/backtick_strict_types/baml_tests__diagnostic_errors__backtick_strict_types__05_diagnostics.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/backtick_strict_types/baml_tests__diagnostic_errors__backtick_strict_types__10_formatter__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/control_flow/baml_tests__diagnostic_errors__control_flow__01_lexer__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/control_flow/baml_tests__diagnostic_errors__control_flow__02_parser__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/control_flow/baml_tests__diagnostic_errors__control_flow__03_hir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/control_flow/baml_tests__diagnostic_errors__control_flow__04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/control_flow/baml_tests__diagnostic_errors__control_flow__05_diagnostics.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/control_flow/baml_tests__diagnostic_errors__control_flow__10_formatter__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/null_handling/baml_tests__diagnostic_errors__null_handling__04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/null_handling/baml_tests__diagnostic_errors__null_handling__05_diagnostics.snap is excluded by !**/*.snap
📒 Files selected for processing (18)
  • baml_language/crates/baml_base/src/escape.rs
  • baml_language/crates/baml_builtins2/baml_std/baml/bool.baml
  • baml_language/crates/baml_builtins2/baml_std/baml/float.baml
  • baml_language/crates/baml_builtins2/baml_std/baml/int.baml
  • baml_language/crates/baml_builtins2/baml_std/baml/string.baml
  • baml_language/crates/baml_compiler2_ast/src/lib.rs
  • baml_language/crates/baml_compiler2_ast/src/lower_expr_body.rs
  • baml_language/crates/baml_compiler_lexer/src/tokens.rs
  • baml_language/crates/baml_compiler_parser/src/parser.rs
  • baml_language/crates/baml_compiler_syntax/src/ast.rs
  • baml_language/crates/baml_tests/projects/compiles/backtick_strings/main.baml
  • baml_language/crates/baml_tests/projects/diagnostic_errors/backtick_strict_types/main.baml
  • baml_language/crates/baml_tests/projects/diagnostic_errors/control_flow/main.baml
  • baml_language/crates/bex_engine/tests/backtick_strings.rs
  • baml_language/crates/bex_vm/src/package_baml/float.rs
  • baml_language/crates/bex_vm/src/package_baml/int.rs
  • baml_language/crates/bex_vm/src/package_baml/primitives.rs
  • baml_language/crates/bex_vm/src/package_baml/string.rs
✅ Files skipped from review due to trivial changes (1)
  • baml_language/crates/baml_tests/projects/diagnostic_errors/backtick_strict_types/main.baml

Comment thread baml_language/crates/baml_compiler_syntax/src/ast.rs
Comment thread baml_language/crates/baml_compiler2_ast/src/lower_expr_body.rs Outdated
Three follow-ups from CodeRabbit review:

1. ast.rs:1196 — segments() dedent remapping misaligned text pieces when
   the first decoded part was Interp (multi-line literal starting with
   `${...}`). The old loop walked decoded and advanced the split iterator
   only for Text parts; the leading empty piece (for "no Text before
   first Interp") was assigned to the first Text part, shifting every
   subsequent Text by one. Rewrote to walk the pieces/interps in lockstep
   instead — one piece per text position, interps interleaved. Skips
   empty Text pieces to preserve the "no empty Text segments" invariant.
   Regression test: backtick_segments_multiline_starts_with_interp.

2. parser.rs:1849 — count_consecutive_backticks /
   find_first_backtick_pos only skipped basic trivia, but the entry
   guard `self.at(TokenKind::Backtick)` skips comments too. A literal
   like `let s = /*c*/ <backtick>ok<backtick>` reached the helpers with
   comment tokens still in front and returned 0 → spurious parse error.
   Both helpers now use the existing comment-aware
   `skip_trivia_and_comments_from`. Regression tests:
   backtick_after_block_comment_parses, backtick_after_line_comment_parses.

3. lower_expr_body.rs:2302 — doc comment said implicit `.to_string()`
   was deferred to task #13, but the lowering already applies it. Updated
   to reflect the actual behavior.
Six items from two ultrareview runs:

- bug_001 (normal): preprocess_template panicked on multi-byte Unicode
  whitespace (NBSP U+00A0, LINE SEPARATOR U+2028) mixed with ASCII
  indent. min_indent computed in bytes, then sliced at a non-char-
  boundary. Reachable via macOS Option+Space or rich-text paste. Walk
  leading whitespace by char; strip per-line up to target_bytes while
  always landing on a char boundary (over-strip by one whitespace char
  at worst). 3 new tests in baml_base::dedent.

- bug_011 (normal): backslash escape inside a backtick string
  overshot — the second bump_raw() skipped trivia before taking the
  escape target, so backslash + space + closing-backtick silently
  consumed the closer as the escape target. Added bump_one_token_raw
  that emits exactly the token at self.current with no trivia walk;
  use it for the escape target. Regression test:
  backtick_backslash_followed_by_whitespace_does_not_eat_closer.

- bug_008 + bug_002 (normal, same helper): has_backtick_close_ahead_from
  walked tokens to EOF without scope or comment-awareness. Any
  downstream backtick literal OR backtick run inside a // line comment
  (lexer doesn't tokenize comments — // is two Slash tokens, content
  goes through raw) satisfied the look-ahead and disabled the
  empty-multi-tick guard. Bound the scan to the current top-level
  item (abort on intervening function/class/etc. keyword) and skip
  comments via skip_comment_at. Tests verify the diagnostic fires at
  the actual bad opener (byte offset check), not from a downstream
  remnant. 2 new tests.

- bug_006 (nit): segments() hardcoded U+F8FF as the in-band dedent
  placeholder, but U+F8FF is a legitimate PUA codepoint (Apple logo
  on macOS) that users can type or paste. Collision silently dropped
  the user's char and misaligned interpolation positions. Walk the
  PUA range U+E000..U+F8FF and pick the first codepoint that doesn't
  appear in content. Practically always U+E000. 1 new test.

- bug_010 (nit, doc): escape.rs module doc listed 6 always-on escapes
  but \b/\v/\f are also unconditionally decoded (TS parity, pinned
  by test). Updated to list all 9 with a TS-parity note.
`if` and `while` already accept paren-optional conditions; `match`
previously required them. Bring it in line — both `match (x) { ... }`
and `match x { ... }` now parse. The optional `: Type` annotation is
only accepted in the parenthesized form (parens are the syntactic anchor
that disambiguates the colon from a binding ascription).

The no-paren form guards against object-literal postfix capture by
incrementing `suppress_object_literal_depth` around the scrutinee
parse, mirroring how `parse_spawn_expr` handles the same hazard. So
`match Foo { _ => 0 }` correctly parses Foo as the scrutinee and
`{ _ => 0 }` as the match body — not as a struct constructor.

Tests:
- match_scrutinee_parens_optional
- match_scrutinee_with_type_annotation_no_parens
- match_paren_form_still_works

Snapshot churn (all real improvements, not regressions):
- compiler2_mir::match_expr previously captured `baml.sys.panic
  ("parse error")` because its fixture used the no-paren form;
  now compiles correctly to a real jump table.
- backtick_strings 03_hir / 04_tir / 04_5_mir / 06_codegen all
  dropped a trailing `+ ""` op — incidental fallout from the
  segments() lockstep rewrite in PR #3577's earlier round (stale
  snapshots that weren't re-run until this full-workspace pass).

@coderabbitai coderabbitai Bot left a comment

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.

🧹 Nitpick comments (1)
baml_language/crates/baml_compiler_parser/src/parser.rs (1)

7772-7785: ⚡ Quick win

Add a negative test for unparenthesized type-ascribed scrutinees.

You’ve documented : Type as parenthesized-only, but current additions only assert positive forms. Add a match x: int { ... } test that expects diagnostics to lock that contract.

Suggested test
+    #[test]
+    fn match_scrutinee_type_annotation_requires_parens() {
+        let source = "
+function Demo(x: int) -> int {
+    match x: int {
+        _ => 0,
+    }
+}
+";
+        let (_root, errors) = parse_source(source);
+        assert!(
+            !errors.is_empty(),
+            "expected parse errors for unparenthesized type-ascribed scrutinee"
+        );
+    }
🤖 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 7772 -
7785, Add a negative test that ensures unparenthesized type-ascribed scrutinees
(e.g., `match x: int { ... }`) produce diagnostics: create a new test function
(e.g., match_scrutinee_with_type_annotation_unparenthesized) that calls
parse_source with a source containing `match x: int { _ => 0, }`, then assert
that the returned errors are non-empty (e.g., assert!(errors.len() > 0)) or
otherwise verify the expected diagnostic appears; use the same helpers seen in
the file (parse_source and the errors variable) to locate and validate the
failure.
🤖 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.

Nitpick comments:
In `@baml_language/crates/baml_compiler_parser/src/parser.rs`:
- Around line 7772-7785: Add a negative test that ensures unparenthesized
type-ascribed scrutinees (e.g., `match x: int { ... }`) produce diagnostics:
create a new test function (e.g.,
match_scrutinee_with_type_annotation_unparenthesized) that calls parse_source
with a source containing `match x: int { _ => 0, }`, then assert that the
returned errors are non-empty (e.g., assert!(errors.len() > 0)) or otherwise
verify the expected diagnostic appears; use the same helpers seen in the file
(parse_source and the errors variable) to locate and validate the failure.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: a7464d8b-0070-4c5e-90a2-cfffc7288af2

📥 Commits

Reviewing files that changed from the base of the PR and between 6a33cb4 and a8b734b.

⛔ Files ignored due to path filters (5)
  • baml_language/crates/baml_tests/snapshots/compiler2_mir/baml_tests__compiler2_mir__match_expr.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/backtick_strings/baml_tests__compiles__backtick_strings__03_hir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/backtick_strings/baml_tests__compiles__backtick_strings__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/backtick_strings/baml_tests__compiles__backtick_strings__04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/backtick_strings/baml_tests__compiles__backtick_strings__06_codegen.snap is excluded by !**/*.snap
📒 Files selected for processing (1)
  • baml_language/crates/baml_compiler_parser/src/parser.rs

…-interpolation

# Conflicts:
#	baml_language/crates/baml_tests/tests/bytecode_format/snapshots/bytecode_format__bytecode_display_expanded.snap
#	baml_language/crates/baml_tests/tests/bytecode_format/snapshots/bytecode_format__bytecode_display_expanded_unoptimized.snap
@codeshaunted

Copy link
Copy Markdown
Collaborator Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 12

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
baml_language/crates/baml_compiler_parser/src/parser.rs (1)

7249-7251: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Route backtick config values through expression parsing.

looks_like_config_expression() treats quoted/raw strings as expressions but omits TokenKind::Backtick, so a config value starting with a backtick falls through to expect(TokenKind::Word) instead of parse_expr().

Proposed fix
         // Regular string literals are always expressions
-        if self.at(TokenKind::Quote) {
+        if self.at(TokenKind::Quote) || self.at(TokenKind::Backtick) {
             return true;
         }

Also applies to: 7261-7274

🤖 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 7249 -
7251, The `looks_like_config_expression()` function is missing a check for
`TokenKind::Backtick`, causing backtick-prefixed config values to not be routed
through `parse_expr()` as intended. Update the `looks_like_config_expression()`
function to include `TokenKind::Backtick` alongside the existing checks for
quoted and raw strings, ensuring that backtick expressions are handled
consistently with other expression types in config value parsing.
🧹 Nitpick comments (1)
baml_language/crates/baml_tests/tests/backtick_block_diagnostics.rs (1)

60-75: ⚡ Quick win

Add untagged-path assertions for the remaining structural cases.

mismatched_if_close_diagnoses and stray_endif_diagnoses currently validate only the prompt path, while this suite otherwise targets both prompt + untagged behavior of the shared segment builder.

Suggested test additions
 #[test]
 fn mismatched_if_close_diagnoses() {
     // ${if} closed by ${endfor}
     assert_has(&prompt("${if (true)}a${endfor}"), "${endif}");
+    assert_has(&untagged("${if (true)}a${endfor}"), "${endif}");
 }

 #[test]
 fn stray_endif_diagnoses() {
     assert_has(&prompt("a${endif}b"), "stray ${endif}");
+    assert_has(&untagged("a${endif}b"), "stray ${endif}");
 }
🤖 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_tests/tests/backtick_block_diagnostics.rs` around
lines 60 - 75, Add untagged-path assertions to the mismatched_if_close_diagnoses
and stray_endif_diagnoses test functions to ensure consistency with the rest of
the test suite. Both functions currently only assert on the prompt path using
assert_has with prompt(), but should also include corresponding assertions using
the untagged() function with the same test inputs and expected diagnostic
messages, following the pattern already established in stray_endfor_diagnoses.
🤖 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 2198-2218: The function `has_backtick_close_ahead_from`
prematurely stops searching for closing backticks when it encounters keyword
tokens like `TokenKind::Function`, even if those keywords appear inside the
backtick literal itself (e.g., ````function keyword````). Instead of using these
token kinds as unconditional item boundaries, refine the boundary detection
logic to only return false when a keyword token is confirmed to be outside the
backtick literal being scanned. Track the nesting depth or structure of the
backtick literal to distinguish between tokens that are actual item boundaries
versus tokens that are legitimate content within the backtick delimiters, so
that keywords appearing inside multi-tick literals do not prematurely terminate
the search for the closing delimiter.
- Around line 2450-2458: The block-tag classification in the parser uses
is_basic_trivia() to skip trivia when lookahead-checking for the If token after
else, but basic trivia does not include block comments. This causes block
comments between else and if (or between if and opening braces) to break the
lookahead logic, resulting in incorrect tag classification. Replace the
is_basic_trivia() call used during the lookahead skip loops with a method that
skips all trivia including block comments (or introduce such a helper if it does
not exist), ensuring consistent trivia handling at all block-tag classification
points including the else-if check around the given location and the similar
checks mentioned to also apply at lines 2479-2493.
- Around line 2577-2593: The condition in the parenthesized template for-loop
parsing only checks for TokenKind::Let but should also accept contextual const
keywords. Update the condition that currently checks if self.at(TokenKind::Let)
to also allow contextual const (look at how the host for parser handles this
contextual keyword check), so that expressions like ${for (const x in xs)} route
to parse_for_in_pattern instead of falling through to the C-style for loop path
and erroring.
- Around line 5680-5697: The `has_newline_ahead()` function skips over block
comments without checking if they contain newline tokens inside them, causing
tagged templates to be incorrectly parsed when there is a line break within an
intervening block comment. Modify the `has_newline_ahead()` function to treat
any newline tokens found within block comments as line terminators for the
purpose of tagged template parsing, ensuring that constructs like `tag /*\n*/
`body`` are not misparsed as tagged templates.

In `@baml_language/crates/baml_compiler_syntax/src/ast.rs`:
- Around line 1709-1717: The validation for FlatPart::Else and FlatPart::ElseIf
only checks if the immediate stack frame is Open::If, but does not validate
branch ordering within an if-chain. This allows invalid sequences like multiple
${else} blocks or ${else if} after ${else} to pass validation. Enhance the
validation by tracking the last branch type seen within the current if-chain
(initial, else-if, or else) in the stack state, then add checks to reject ${else
if} when the last branch was ${else}, reject ${else} when it already appeared,
and create corresponding error kind variants and diagnostic mappings for these
new invalid-order cases.

In `@baml_language/crates/baml_compiler2_hir/src/builder.rs`:
- Around line 689-693: The if branch that handles Expr::Block only calls
walk_block_contents on the block's statements and tail expression, which skips
recording the block's ExprId itself in expr_scopes. This causes MIR/TIR scope
lookups for the synthetic body to fail. Add a call to walk_expr for the
flatten_body ExprId with push_block_scope set to false (to avoid duplicating the
scope already created by walk_block_contents) so that the block expression is
properly recorded in expr_scopes, matching the behavior of the else branch that
already calls walk_expr.

In `@baml_language/crates/baml_compiler2_mir/src/lower.rs`:
- Around line 4689-4691: The issue is that `tagged_body_param_names` only
resolves body params when they exist in `self.locals`, but when a nested lambda
inside `${...}` references one of these params, `lower_lambda` clears
`self.locals`, preventing the nested lambda from capturing the param since HIR
has no `BindingId` for it. Fix this by either threading the synthetic body
params through the lambda capture path so nested lambdas can access them even
after `self.locals` is cleared, or by converting these params into real scoped
bindings with proper `BindingId`s before MIR lowering so they are available in
the HIR binding system. This fix should be applied wherever
`tagged_body_param_names` is set and wherever nested lambdas are lowered to
ensure consistent param resolution across all interpolation contexts.
- Around line 6451-6455: When handling ParamBinding::OmittedDefault with a
Some(callee_loc) for a method sys-op call, the param_index is receiver-relative
(self parameter excluded) but sysop_default_operand passes it directly to
defaults.param_default which indexes the full signature including self. Adjust
param_index by adding 1 to account for the self parameter when the callee is a
method, before passing it to self.sysop_default_operand(callee_loc,
param_index). Check if the callee_loc refers to a method and only apply the
adjustment in that case.

In `@baml_language/crates/baml_compiler2_tir/src/builder.rs`:
- Around line 8145-8150: The CStyleFor case in the throws analysis for
ast::TemplateSegment::CStyleFor is missing extraction and processing of throw
facts from the init statement's initializer. Currently it only processes cond
and body, but ignores init. Extract the initializer from the init statement
using the same approach shown in lines 2947-2955 (the forward-reference walker),
and call self.collect_throw_facts_from_expr on that extracted initializer with
the appropriate body parameter before processing cond and body.
- Around line 2939-2967: The condition expression in the C-style for loop is
being processed before the loop variable's pattern is pushed onto the shadowed
list, which causes references to the loop variable in the condition to be
incorrectly treated as forward references. Move the pattern shadowing operations
earlier in the sequence: call Self::push_pattern_bindings(p, body, shadowed)
before processing the condition via
Self::collect_default_expr_forward_references(*cond, ...), so that the loop
variable binding is visible when evaluating the condition expression, while
preserving the correct order where the initializer is processed before shadowing
and the inner content is processed after.

In `@baml_language/crates/baml_compiler2_tir/src/throws_analysis.rs`:
- Around line 422-427: The CStyleFor arm in the throws analysis is missing
traversal of the init and step statements, which can throw exceptions but are
currently ignored. Modify the pattern match for ast::TemplateSegment::CStyleFor
to destructure the init and step fields (currently captured by the .. wildcard).
Add calls to collect_from_expr or collect_from_template_segments (whichever is
appropriate for the statement types) for both the init and step fields, ensuring
they are analyzed before collecting from cond and the body segments.
- Around line 384-389: The custom tag template handling in the Expr::Template
match arm collects throws from the tag expression and segments, but omits the
throws that originate from invoking the tag function itself. In the block
starting at Line 384 where Expr::Template with ast::TemplateTag::Custom is
handled, after calling collect_from_expr for the tag and
collect_from_template_segments for the segments, add a call to collect the
throws that result from calling the tag function (similar to how function call
throws would be collected elsewhere in the codebase). This ensures that escaping
throws from tagged template invocations are fully reported.

---

Outside diff comments:
In `@baml_language/crates/baml_compiler_parser/src/parser.rs`:
- Around line 7249-7251: The `looks_like_config_expression()` function is
missing a check for `TokenKind::Backtick`, causing backtick-prefixed config
values to not be routed through `parse_expr()` as intended. Update the
`looks_like_config_expression()` function to include `TokenKind::Backtick`
alongside the existing checks for quoted and raw strings, ensuring that backtick
expressions are handled consistently with other expression types in config value
parsing.

---

Nitpick comments:
In `@baml_language/crates/baml_tests/tests/backtick_block_diagnostics.rs`:
- Around line 60-75: Add untagged-path assertions to the
mismatched_if_close_diagnoses and stray_endif_diagnoses test functions to ensure
consistency with the rest of the test suite. Both functions currently only
assert on the prompt path using assert_has with prompt(), but should also
include corresponding assertions using the untagged() function with the same
test inputs and expected diagnostic messages, following the pattern already
established in stray_endfor_diagnoses.
🪄 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: cf8929ab-afd2-4b73-a967-7663aa0a95f3

📥 Commits

Reviewing files that changed from the base of the PR and between 36dc05b and ccc0451.

⛔ Files ignored due to path filters (108)
  • baml_language/Cargo.lock is excluded by !**/*.lock
  • baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_builtin_namespace_llm.snap is excluded by !**/*.snap
  • baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_builtin_package_listing.snap is excluded by !**/*.snap
  • baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_describe_alias_string.snap is excluded by !**/*.snap
  • baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_describe_builtin_item_by_definition.snap is excluded by !**/*.snap
  • baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_describe_builtin_string.snap is excluded by !**/*.snap
  • baml_language/crates/baml_lsp2_actions/src/snapshots/baml_lsp2_actions__describe_tests__describe_builtin_string_with_compiler2_visible_files.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/type_reflection.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/broken_syntax/banned_expressions/baml_tests__broken_syntax__banned_expressions__01_lexer__banned_expressions.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/broken_syntax/error_cases/baml_tests__broken_syntax__error_cases__01_lexer__syntax_errors.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/broken_syntax/is_in_type_position/baml_tests__broken_syntax__is_in_type_position__01_lexer__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/broken_syntax/mismatched_brackets/baml_tests__broken_syntax__mismatched_brackets__01_lexer__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/broken_syntax/namespaces_same_name/baml_tests__broken_syntax__namespaces_same_name__01_lexer__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/broken_syntax/parser_error_recovery/baml_tests__broken_syntax__parser_error_recovery__01_lexer__partial_input.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/broken_syntax/type_annotation_errors/baml_tests__broken_syntax__type_annotation_errors__01_lexer__type_malformed.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/__assert_std__/baml_tests__compiles____assert_std____01_lexer__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/__baml_std__/baml_tests__compiles____baml_std____01_lexer__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/__baml_std__/baml_tests__compiles____baml_std____03_hir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/__baml_std__/baml_tests__compiles____baml_std____04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/__baml_std__/baml_tests__compiles____baml_std____04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/__baml_std__/baml_tests__compiles____baml_std____06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/__testing_std__/baml_tests__compiles____testing_std____01_lexer__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/backtick_strings/baml_tests__compiles__backtick_strings__01_lexer__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/backtick_strings/baml_tests__compiles__backtick_strings__02_parser__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/backtick_strings/baml_tests__compiles__backtick_strings__03_hir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/backtick_strings/baml_tests__compiles__backtick_strings__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/backtick_strings/baml_tests__compiles__backtick_strings__04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/backtick_strings/baml_tests__compiles__backtick_strings__05_diagnostics.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/backtick_strings/baml_tests__compiles__backtick_strings__06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/backtick_strings/baml_tests__compiles__backtick_strings__10_formatter__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/bigint_literal/baml_tests__compiles__bigint_literal__01_lexer__bigint_literal.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/comment_in_type/baml_tests__compiles__comment_in_type__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/comment_in_type/baml_tests__compiles__comment_in_type__06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/deep_method_call/baml_tests__compiles__deep_method_call__01_lexer__deep_method_call.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/host_callable_call/baml_tests__compiles__host_callable_call__01_lexer__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/is_operator/baml_tests__compiles__is_operator__01_lexer__is_operator.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/json_alias_basic/baml_tests__compiles__json_alias_basic__01_lexer__json_alias_basic.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/json_cross_namespace_static_call/baml_tests__compiles__json_cross_namespace_static_call__01_lexer__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/json_cross_namespace_static_call/baml_tests__compiles__json_cross_namespace_static_call__01_lexer__ns_pkg_ns_inner_box.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/json_llm_return_type/baml_tests__compiles__json_llm_return_type__01_lexer__json_llm_return_type.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/json_llm_return_type/baml_tests__compiles__json_llm_return_type__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/json_llm_return_type/baml_tests__compiles__json_llm_return_type__06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/json_map_literal/baml_tests__compiles__json_map_literal__01_lexer__json_map_literal.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/json_to_from_string_composite_generic/baml_tests__compiles__json_to_from_string_composite_generic__01_lexer__json_to_from_string_composite_generic.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/json_to_from_string_concrete/baml_tests__compiles__json_to_from_string_concrete__01_lexer__json_to_from_string_concrete.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/json_to_from_string_generic_forwarding/baml_tests__compiles__json_to_from_string_generic_forwarding__01_lexer__json_to_from_string_generic_forwarding.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/json_to_from_string_three_level/baml_tests__compiles__json_to_from_string_three_level__01_lexer__json_to_from_string_three_level.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/lambda_fat_arrow/baml_tests__compiles__lambda_fat_arrow__01_lexer__lambda_fat_arrow.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/lambda_field_access/baml_tests__compiles__lambda_field_access__01_lexer__lambda_field_access.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/lexical_scoping/baml_tests__compiles__lexical_scoping__01_lexer__lexical_scoping.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/llm_image_outputs/baml_tests__compiles__llm_image_outputs__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/llm_image_outputs/baml_tests__compiles__llm_image_outputs__06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/numeric_invariance_ok/baml_tests__compiles__numeric_invariance_ok__01_lexer__numeric_invariance_ok.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/optional_function_parameters/baml_tests__compiles__optional_function_parameters__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/optional_function_parameters/baml_tests__compiles__optional_function_parameters__06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/patterns_new/baml_tests__compiles__patterns_new__01_lexer__patterns_new.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/reflect_type_of_user_generic/baml_tests__compiles__reflect_type_of_user_generic__01_lexer__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/stream_llm_inferred_typeargs/baml_tests__compiles__stream_llm_inferred_typeargs__01_lexer__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/stream_llm_inferred_typeargs/baml_tests__compiles__stream_llm_inferred_typeargs__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/stream_llm_inferred_typeargs/baml_tests__compiles__stream_llm_inferred_typeargs__06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/testset_vibes_nested/baml_tests__compiles__testset_vibes_nested__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/testset_vibes_nested/baml_tests__compiles__testset_vibes_nested__06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/type_annotation/baml_tests__compiles__type_annotation__01_lexer__type_alias_interaction.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/type_annotation/baml_tests__compiles__type_annotation__01_lexer__type_as_identifier.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/type_annotation/baml_tests__compiles__type_annotation__01_lexer__type_positions.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/type_builder_errors/baml_tests__compiles__type_builder_errors__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/type_builder_errors/baml_tests__compiles__type_builder_errors__06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/type_builder_test/baml_tests__compiles__type_builder_test__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/type_builder_test/baml_tests__compiles__type_builder_test__06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/assignment_expr_position/baml_tests__diagnostic_errors__assignment_expr_position__01_lexer__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/backtick_strict_types/baml_tests__diagnostic_errors__backtick_strict_types__01_lexer__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/backtick_strict_types/baml_tests__diagnostic_errors__backtick_strict_types__02_parser__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/backtick_strict_types/baml_tests__diagnostic_errors__backtick_strict_types__03_hir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/backtick_strict_types/baml_tests__diagnostic_errors__backtick_strict_types__04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/backtick_strict_types/baml_tests__diagnostic_errors__backtick_strict_types__05_diagnostics.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/backtick_strict_types/baml_tests__diagnostic_errors__backtick_strict_types__10_formatter__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/captured_field_chain/baml_tests__diagnostic_errors__captured_field_chain__01_lexer__captured_field_chain.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/catch_return_type_mismatch/baml_tests__diagnostic_errors__catch_return_type_mismatch__01_lexer__catch_return_type_mismatch.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/catch_throw_regressions/baml_tests__diagnostic_errors__catch_throw_regressions__01_lexer__regressions.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/control_flow/baml_tests__diagnostic_errors__control_flow__01_lexer__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/control_flow/baml_tests__diagnostic_errors__control_flow__02_parser__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/control_flow/baml_tests__diagnostic_errors__control_flow__03_hir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/control_flow/baml_tests__diagnostic_errors__control_flow__04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/control_flow/baml_tests__diagnostic_errors__control_flow__05_diagnostics.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/control_flow/baml_tests__diagnostic_errors__control_flow__10_formatter__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/format_checks/baml_tests__diagnostic_errors__format_checks__01_lexer__match_exprs.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/generic_call_ambiguity/baml_tests__diagnostic_errors__generic_call_ambiguity__01_lexer__array_compare.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/generic_call_ambiguity/baml_tests__diagnostic_errors__generic_call_ambiguity__01_lexer__generic_call.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/generics/baml_tests__diagnostic_errors__generics__01_lexer__classes.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/instanceof_removed/baml_tests__diagnostic_errors__instanceof_removed__01_lexer__instanceof_removed.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/is_pattern_negative/baml_tests__diagnostic_errors__is_pattern_negative__01_lexer__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/json_static_method_arity/baml_tests__diagnostic_errors__json_static_method_arity__01_lexer__json_static_method_arity.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/match_exhaustiveness/baml_tests__diagnostic_errors__match_exhaustiveness__01_lexer__match_exhaustiveness.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/null_handling/baml_tests__diagnostic_errors__null_handling__04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/null_handling/baml_tests__diagnostic_errors__null_handling__05_diagnostics.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/numeric_invariance/baml_tests__diagnostic_errors__numeric_invariance__01_lexer__numeric_invariance.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/patterns_new/baml_tests__diagnostic_errors__patterns_new__01_lexer__patterns_new.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/runtime_id_misuse/baml_tests__diagnostic_errors__runtime_id_misuse__01_lexer__runtime_id.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/stream_types/baml_tests__diagnostic_errors__stream_types__01_lexer__builtin_refs.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/test_with_runner_ambiguity/baml_tests__diagnostic_errors__test_with_runner_ambiguity__01_lexer__main.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/type_reflection_strict/baml_tests__diagnostic_errors__type_reflection_strict__01_lexer__reflect_intrinsic_errors.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/type_reflection_strict/baml_tests__diagnostic_errors__type_reflection_strict__01_lexer__strict_normalization.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/unknown_type_error/baml_tests__diagnostic_errors__unknown_type_error__01_lexer__unknown_type_error.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/src/compiler2_tir/snapshots/baml_tests__compiler2_tir__phase3a__optional_param_default_forward_reference_checks_lambda_bodies.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/src/compiler2_tir/snapshots/baml_tests__compiler2_tir__phase5__snapshot_baml_package_items.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/tests/bytecode_format/snapshots/bytecode_format__bytecode_display_expanded.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/tests/bytecode_format/snapshots/bytecode_format__bytecode_display_expanded_unoptimized.snap is excluded by !**/*.snap
  • baml_language/sdks/nodejs/bridge_nodejs/dist/proto/baml_cffi.js is excluded by !**/dist/**
📒 Files selected for processing (61)
  • baml_language/crates/baml_base/src/dedent.rs
  • baml_language/crates/baml_base/src/escape.rs
  • baml_language/crates/baml_base/src/lib.rs
  • baml_language/crates/baml_builtins2/baml_std/baml/bool.baml
  • baml_language/crates/baml_builtins2/baml_std/baml/core.baml
  • baml_language/crates/baml_builtins2/baml_std/baml/float.baml
  • baml_language/crates/baml_builtins2/baml_std/baml/int.baml
  • baml_language/crates/baml_builtins2/baml_std/baml/ns_llm/llm.baml
  • baml_language/crates/baml_builtins2/baml_std/baml/ns_llm/llm_types.baml
  • baml_language/crates/baml_builtins2/baml_std/baml/string.baml
  • baml_language/crates/baml_compiler2_ast/src/ast.rs
  • baml_language/crates/baml_compiler2_ast/src/auto_derive_json.rs
  • baml_language/crates/baml_compiler2_ast/src/companions.rs
  • baml_language/crates/baml_compiler2_ast/src/docstring.rs
  • baml_language/crates/baml_compiler2_ast/src/lib.rs
  • baml_language/crates/baml_compiler2_ast/src/lower_cst.rs
  • baml_language/crates/baml_compiler2_ast/src/lower_expr_body.rs
  • baml_language/crates/baml_compiler2_ast/src/lower_type_expr.rs
  • baml_language/crates/baml_compiler2_ast/src/lowering_diagnostic.rs
  • baml_language/crates/baml_compiler2_emit/src/lib.rs
  • baml_language/crates/baml_compiler2_hir/src/builder.rs
  • baml_language/crates/baml_compiler2_hir/src/item_tree.rs
  • baml_language/crates/baml_compiler2_hir/src/scope.rs
  • baml_language/crates/baml_compiler2_mir/src/lower.rs
  • baml_language/crates/baml_compiler2_ppir/src/lib.rs
  • baml_language/crates/baml_compiler2_tir/src/builder.rs
  • baml_language/crates/baml_compiler2_tir/src/infer_context.rs
  • baml_language/crates/baml_compiler2_tir/src/inference.rs
  • baml_language/crates/baml_compiler2_tir/src/normalize.rs
  • baml_language/crates/baml_compiler2_tir/src/throws_analysis.rs
  • baml_language/crates/baml_compiler2_visualization/src/control_flow/from_ast.rs
  • baml_language/crates/baml_compiler_lexer/src/tokens.rs
  • baml_language/crates/baml_compiler_parser/src/parser.rs
  • baml_language/crates/baml_compiler_syntax/Cargo.toml
  • 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/tokens.rs
  • baml_language/crates/baml_fmt/src/lib.rs
  • baml_language/crates/baml_lsp2_actions/src/check.rs
  • baml_language/crates/baml_lsp2_actions/src/completions.rs
  • baml_language/crates/baml_tests/projects/compiles/backtick_strings/main.baml
  • baml_language/crates/baml_tests/projects/diagnostic_errors/backtick_strict_types/main.baml
  • baml_language/crates/baml_tests/projects/diagnostic_errors/control_flow/main.baml
  • baml_language/crates/baml_tests/src/compiler2_tir/mod.rs
  • baml_language/crates/baml_tests/src/compiler2_tir/phase3a.rs
  • baml_language/crates/baml_tests/tests/backtick_block_diagnostics.rs
  • baml_language/crates/baml_tests/tests/prompt_tag_e2e.rs
  • baml_language/crates/baml_tests/tests/prompt_tag_runtime.rs
  • baml_language/crates/baml_tests/tests/tagged_template_lowering.rs
  • baml_language/crates/baml_tests/tests/tagged_template_runtime.rs
  • baml_language/crates/bex_engine/tests/backtick_strings.rs
  • baml_language/crates/bex_vm/src/package_baml/float.rs
  • baml_language/crates/bex_vm/src/package_baml/int.rs
  • baml_language/crates/bex_vm/src/package_baml/primitives.rs
  • baml_language/crates/bex_vm/src/package_baml/string.rs
  • baml_language/crates/sys_llm/src/lib.rs
  • baml_language/crates/sys_ops/src/lib.rs
  • baml_language/crates/tools_onionskin/src/app.rs
  • baml_language/crates/tools_onionskin/src/compiler.rs
  • baml_language/sdks/nodejs/bridge_nodejs/typescript_src/proto/baml_cffi.js

Comment thread baml_language/crates/baml_compiler_parser/src/parser.rs
Comment thread baml_language/crates/baml_compiler_parser/src/parser.rs Outdated
Comment thread baml_language/crates/baml_compiler_parser/src/parser.rs Outdated
Comment thread baml_language/crates/baml_compiler_parser/src/parser.rs
Comment thread baml_language/crates/baml_compiler_syntax/src/ast.rs Outdated
Comment thread baml_language/crates/baml_compiler2_mir/src/lower.rs
Comment thread baml_language/crates/baml_compiler2_tir/src/builder.rs Outdated
Comment thread baml_language/crates/baml_compiler2_tir/src/builder.rs
Comment thread baml_language/crates/baml_compiler2_tir/src/throws_analysis.rs
Comment thread baml_language/crates/baml_compiler2_tir/src/throws_analysis.rs
codeshaunted and others added 4 commits June 16, 2026 14:21
`to_string` is now a magic method (BEP-049 §11): every user/stdlib class
gets a synthesized `function to_string(self) -> string throws never` whose
body delegates to `baml.unstable.string(self)`, exactly mirroring the
existing `to_json`/`from_json` auto-derive. A class that hand-writes its
own `to_string` keeps it (the synth is skipped when one already exists).

This makes any class instance interpolatable in `${…}` without a
hand-written `to_string`; the §11 "has no to_string method" type error no
longer applies to classes. The nullable-interpolation errors (`int | null`
"may be null") are unchanged — those remain a strict-typing diagnostic.

- auto_derive_json.rs: rename maybe_synthesize_json_methods ->
  maybe_synthesize_derived_methods; add has_to_string guard +
  synthesize_to_string(span).
- backtick_strict_types fixture: drop the FooNoToString case (B), which is
  no longer an error; keep nullable cases (A) + (E).
- backtick_strings.rs: positive runtime test — interpolating a class with
  no hand-written to_string renders via the universal formatter.
- Regenerate ~229 snapshots (every class method-list gains to_string;
  bytecode registry indices renumber accordingly).
Fix 9 unresolved review findings on the string-interpolation work.

Throws analysis (TIR):
- Collect throws from a tagged template's tag invocation — the tag fn is
  called, so its declared `throws` escape like a direct call's.
- Walk C-style `${for}` `init`/`step` statements for throws (both the
  throws_analysis segment walker and builder's throw-fact collector).
- Reorder C-style `${for}` forward-ref collection so `cond`/body are walked
  after the loop var is shadowed (a later param sharing the name must not
  flag them as forward references).

HIR:
- Record the tagged-template flatten block's own ExprId in `expr_scopes`
  (walk_expr with push_block_scope=false) so MIR/TIR scope lookups for the
  synthetic body resolve; the hand-inlined walk skipped that.

Parser:
- Empty-multi-tick guard: only treat an item keyword as a boundary when it
  begins a line, so a keyword in multi-tick content (``function kw``) no
  longer false-flags an "empty multi-tick" error.
- Skip comments in `${else}`/`${else if}` classification and in
  classify_backtick_if_is_block_tag, so an interposed comment (or a brace
  inside one) no longer misclassifies the tag.
- Accept contextual `const` in parenthesized template `for` headers.
- Treat a block comment's interior newline as a line terminator in
  has_newline_ahead, so `tag /*\n*/ `x`` is not parsed as a tagged template.

Syntax:
- Detect out-of-order `${if}` branches: a duplicate `${else}` or an
  `${else if}` after `${else}` now diagnose (new DuplicateElse /
  ElseIfAfterElse kinds + diagnostic mappings).

Regression tests: multi-tick-keyword content, const for-header, and the two
out-of-order-else cases.
A real lambda nested inside a tagged-template interpolation may reference the
tag's body-lambda parameter (e.g. `fmt`Hi ${((u) -> { name })("z")}``). That
param is a MIR-only local with no HIR binding — the tag resolves only at TIR
time — so neither TIR's standalone scope inference nor MIR's HIR-driven
capture analysis could see it through the normal paths. It failed to compile
with `[E0003] unresolved name` (and would have hit an unresolved-local panic
in MIR).

Fix spans three layers:

- TIR builder: when typing a Custom tagged template, record the tag's
  body-lambda params keyed by the synthetic template-body Lambda scope
  (`template_body_params`, bubbling up to the owning Function/Let scope like
  `nested_lambda_types`).
- TIR scope inference: a nested lambda's standalone `ScopeKind::Lambda`
  inference seeds those params (`seed_template_body_params`) from any
  `is_template_body` ancestor, so they type concretely instead of `Unknown`
  (which MIR forbids past its runtime-lowering boundary).
- MIR: `tagged_body_param_names` (a name set) becomes
  `tagged_body_param_bindings` (name → synthetic `BindingId::parameter`). A
  param referenced from a nested lambda — absent from the closure's own
  locals — is captured transitively by that BindingId via the existing
  capture-forwarding path (`ensure_transitive_capture`).

Adds a runtime regression test (was `[E0003]` before, renders "Hi World!" now).

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
baml_language/crates/baml_compiler2_mir/src/lower.rs (1)

5638-5682: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Resolve tagged-body params before falling back to HIR locals/captures.

The ResolvedName::Unknown branch only works when no outer binding has the same name. Because body params are MIR-only, resolve_name_at_in_scope can still return an enclosing ResolvedName::Local for a same-named outer variable, so a nested interpolation lambda captures the outer local instead of the tag body param. The same gap affects multi-segment roots like ctx.name/ctx.method() because the root path logic never consults tagged_body_param_bindings.

Centralize a helper that resolves a tagged-body param to Place::Local or Place::Capture(self.ensure_transitive_capture(...)), and use it before HIR-capture fallback for single-segment and multi-segment roots.

Suggested shape
+    fn tagged_body_param_place(&mut self, name: &Name) -> Option<Place> {
+        let binding_id = *self.tagged_body_param_bindings.get(name)?;
+        if let Some(&local) = self.locals.get(name) {
+            Some(Place::Local(local))
+        } else {
+            Some(Place::Capture(self.ensure_transitive_capture(binding_id)))
+        }
+    }

Use this helper before capture_index_for_name_at(...) in the single-name path branch, and as another root case in lower_multi_segment_path_as_field_chain.

Also applies to: 5713-5741

🤖 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_mir/src/lower.rs` around lines 5638 -
5682, The current implementation checks for tagged-body params only in the
ResolvedName::Unknown branch, but this misses cases where
resolve_name_at_in_scope returns a ResolvedName::Local for an outer binding with
the same name, causing nested interpolation lambdas to capture the wrong
variable. Create a centralized helper method that checks
tagged_body_param_bindings and returns the appropriate Place (either
Place::Local or Place::Capture with ensure_transitive_capture), then call this
helper in the ResolvedName::Local branch before falling back to
capture_index_for_name_at, and also apply the same pattern to multi-segment root
path resolution in lower_multi_segment_path_as_field_chain to ensure tagged-body
params are prioritized over HIR-based captures for both single-segment and
multi-segment roots.
🤖 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_compiler2_tir/src/inference.rs`:
- Around line 1061-1086: The doc comment describing "Search for a `Lambda`
expression..." currently precedes the `seed_template_body_params` function, but
it was originally intended to document the `find_lambda_by_span` function. In
Rust, doc comments attach to the immediately following item, so this comment is
now incorrectly documenting `seed_template_body_params` and leaving
`find_lambda_by_span` undocumented. Move the doc comment that currently appears
above `seed_template_body_params` to immediately precede the
`find_lambda_by_span` function instead.

---

Outside diff comments:
In `@baml_language/crates/baml_compiler2_mir/src/lower.rs`:
- Around line 5638-5682: The current implementation checks for tagged-body
params only in the ResolvedName::Unknown branch, but this misses cases where
resolve_name_at_in_scope returns a ResolvedName::Local for an outer binding with
the same name, causing nested interpolation lambdas to capture the wrong
variable. Create a centralized helper method that checks
tagged_body_param_bindings and returns the appropriate Place (either
Place::Local or Place::Capture with ensure_transitive_capture), then call this
helper in the ResolvedName::Local branch before falling back to
capture_index_for_name_at, and also apply the same pattern to multi-segment root
path resolution in lower_multi_segment_path_as_field_chain to ensure tagged-body
params are prioritized over HIR-based captures for both single-segment and
multi-segment roots.
🪄 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: 6441f242-8139-48c3-81be-b05e156ea55d

📥 Commits

Reviewing files that changed from the base of the PR and between 7df24eb and 4796a2b.

📒 Files selected for processing (11)
  • baml_language/crates/baml_compiler2_ast/src/lowering_diagnostic.rs
  • baml_language/crates/baml_compiler2_hir/src/builder.rs
  • baml_language/crates/baml_compiler2_mir/src/lower.rs
  • baml_language/crates/baml_compiler2_tir/src/builder.rs
  • baml_language/crates/baml_compiler2_tir/src/inference.rs
  • baml_language/crates/baml_compiler2_tir/src/throws_analysis.rs
  • baml_language/crates/baml_compiler_parser/src/parser.rs
  • baml_language/crates/baml_compiler_syntax/src/ast.rs
  • baml_language/crates/baml_fmt/src/ast/expressions.rs
  • baml_language/crates/baml_fmt/src/lib.rs
  • baml_language/crates/baml_lsp2_actions/src/check.rs
🚧 Files skipped from review as they are similar to previous changes (8)
  • baml_language/crates/baml_fmt/src/lib.rs
  • baml_language/crates/baml_lsp2_actions/src/check.rs
  • baml_language/crates/baml_compiler2_hir/src/builder.rs
  • baml_language/crates/baml_compiler2_ast/src/lowering_diagnostic.rs
  • baml_language/crates/baml_compiler2_tir/src/throws_analysis.rs
  • baml_language/crates/baml_fmt/src/ast/expressions.rs
  • baml_language/crates/baml_compiler_syntax/src/ast.rs
  • baml_language/crates/baml_compiler_parser/src/parser.rs

Comment thread baml_language/crates/baml_compiler2_tir/src/inference.rs
Two remaining CodeRabbit threads on PR #3577.

**Off-by-one in method sys-op omitted defaults (lower.rs).** When a
`$rust_io_function` sys-op is called as a method (e.g.
`ctx.output_format_with(...)`), the call plan's `param_index` is
receiver-relative — TIR strips `self` via `skip_self_param` for
method-convention calls — but `sysop_default_operand` fed it straight to
`function_parameter_defaults.param_default(param_index)`, which is indexed
self-inclusive (`self` at 0). Omitting an *earlier* optional arg while
providing a *later* one (e.g. `ctx.output_format_with(quote_class_fields = true)`,
omitting `prefix`) made the omitted `prefix` read `self`'s absent default →
`OmittedArg` → engine panic ("Cannot convert omitted argument sentinel").
Previously masked because the only reachable case (`output_format_with`) has
all-`null` defaults and the existing test passed `prefix` first.

Fix: shift omitted-default indices by one when the sys-op callee uses method
convention (new `callee_uses_method_convention`, mirroring TIR's
`callee_uses_method_call_convention`). Free-function sys-ops have no `self`,
so no shift. Adds a runtime regression test.

**Doc nit (inference.rs).** Moved `find_lambda_by_span`'s doc comment back
onto it — inserting `seed_template_body_params` had stranded it.
…olation

# Conflicts:
#	baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_describe_alias_string.snap
#	baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_describe_builtin_item_by_definition.snap
#	baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_describe_builtin_string.snap
#	baml_language/crates/baml_tests/snapshots/compiles/__baml_std__/baml_tests__compiles____baml_std____03_hir.snap
#	baml_language/crates/baml_tests/snapshots/compiles/__baml_std__/baml_tests__compiles____baml_std____04_5_mir.snap
#	baml_language/crates/baml_tests/snapshots/compiles/__baml_std__/baml_tests__compiles____baml_std____06_codegen.snap
#	baml_language/crates/baml_tests/src/compiler2_tir/snapshots/baml_tests__compiler2_tir__phase5__snapshot_baml_package_items.snap
#	baml_language/crates/baml_tests/tests/bytecode_format/snapshots/bytecode_format__bytecode_display_expanded.snap
#	baml_language/crates/baml_tests/tests/bytecode_format/snapshots/bytecode_format__bytecode_display_expanded_unoptimized.snap

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
baml_language/crates/baml_compiler2_mir/src/lower.rs (1)

6327-6383: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reuse the null-checked base operand for ?.[].

Line 6328 lowers base for the null guard, but Line 6383 lowers it again for the actual access. A side-effectful base can pass the guard on the first evaluation and then index a different value on the second. Materialize the checked base once and pass it into the access helper.

🐛 Proposed fix
-    fn lower_optional_index(&mut self, base: AstExprId, index: AstExprId, dest: Place) {
-        let base_op = self.lower_to_operand(base);
+    fn lower_optional_index(&mut self, base: AstExprId, index: AstExprId, dest: Place) {
+        let base_ty = self.expr_ty(base);
+        let base_op = self.lower_to_operand(base);
+        let base_local = self.operand_to_local(base_op, base_ty.clone());

         let is_null = Rvalue::BinaryOp {
             op: BinOp::Eq,
-            left: base_op,
+            left: Operand::Copy(Place::Local(base_local)),
             right: Operand::Constant(Constant::Null),
         };
@@
             self.builder.set_current_block(bb_access);
-            self.lower_optional_index_access(base, index, dest, bb_null);
+            self.lower_optional_index_access(
+                Operand::Copy(Place::Local(base_local)),
+                base_ty,
+                index,
+                dest,
+                bb_null,
+            );
         } else {
@@
             self.builder.set_current_block(bb_access);
-            self.lower_optional_index_access(base, index, dest.clone(), bb_null);
+            self.lower_optional_index_access(
+                Operand::Copy(Place::Local(base_local)),
+                base_ty,
+                index,
+                dest.clone(),
+                bb_null,
+            );
@@
     fn lower_optional_index_access(
         &mut self,
-        base: AstExprId,
+        base_op: Operand,
+        base_ty: RuntimeTy,
         index: AstExprId,
         dest: Place,
         bb_null: BlockId,
     ) {
-        let base_ty = self.expr_ty(base);
-        let base_op = self.lower_to_operand(base);
         let index_ty = self.expr_ty(index);
🤖 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_mir/src/lower.rs` around lines 6327 -
6383, The `lower_optional_index` function lowers the base expression once to
check for null (line 6328), but then passes the original AstExprId to
`lower_optional_index_access` which lowers it again (line 6383). This causes
side-effectful bases to be evaluated twice, potentially yielding different
values. Fix this by modifying the `lower_optional_index_access` method signature
to accept the already-lowered Operand instead of the AstExprId, then pass the
base_op operand from `lower_optional_index` and remove the duplicate lowering
inside `lower_optional_index_access`.
🤖 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_compiler2_tir/src/builder.rs`:
- Around line 4703-4755: The check_index_key_type method conditionally strips
null from index types when allow_null is true (lines 4722-4726), assuming
indices in optional chains are non-null in the narrowed context, but no
narrowing analysis is actually performed on index expressions. This allows null
indices to pass compile-time checks and fail at runtime. Either remove the
allow_null conditional logic entirely and always validate the index_ty directly
against the expected_key type without stripping null, or implement actual
narrowing analysis for indices in optional indexing chains (in
infer_optional_index_expr or similar) to guarantee non-null indices before
passing allow_null as true to check_index_key_type.

---

Outside diff comments:
In `@baml_language/crates/baml_compiler2_mir/src/lower.rs`:
- Around line 6327-6383: The `lower_optional_index` function lowers the base
expression once to check for null (line 6328), but then passes the original
AstExprId to `lower_optional_index_access` which lowers it again (line 6383).
This causes side-effectful bases to be evaluated twice, potentially yielding
different values. Fix this by modifying the `lower_optional_index_access` method
signature to accept the already-lowered Operand instead of the AstExprId, then
pass the base_op operand from `lower_optional_index` and remove the duplicate
lowering inside `lower_optional_index_access`.
🪄 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: a9e79bcf-2a9a-4e37-b1a8-4d0eb50ce794

📥 Commits

Reviewing files that changed from the base of the PR and between 4796a2b and b49b605.

⛔ Files ignored due to path filters (9)
  • baml_language/Cargo.lock is excluded by !**/*.lock
  • baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_builtin_package_listing.snap is excluded by !**/*.snap
  • baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_describe_alias_string.snap is excluded by !**/*.snap
  • baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_describe_builtin_item_by_definition.snap is excluded by !**/*.snap
  • baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_describe_builtin_string.snap is excluded by !**/*.snap
  • baml_language/crates/baml_lsp2_actions/src/snapshots/baml_lsp2_actions__describe_tests__describe_builtin_string_with_compiler2_visible_files.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/__baml_std__/baml_tests__compiles____baml_std____03_hir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/__baml_std__/baml_tests__compiles____baml_std____04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/__baml_std__/baml_tests__compiles____baml_std____06_codegen.snap is excluded by !**/*.snap
📒 Files selected for processing (4)
  • baml_language/crates/baml_builtins2/baml_std/baml/string.baml
  • baml_language/crates/baml_compiler2_mir/src/lower.rs
  • baml_language/crates/baml_compiler2_tir/src/builder.rs
  • baml_language/crates/baml_compiler2_tir/src/inference.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • baml_language/crates/baml_builtins2/baml_std/baml/string.baml
  • baml_language/crates/baml_compiler2_tir/src/inference.rs

@coderabbitai coderabbitai Bot left a comment

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.

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
baml_language/crates/baml_compiler2_mir/src/lower.rs (1)

6327-6383: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reuse the null-checked base operand for ?.[].

Line 6328 lowers base for the null guard, but Line 6383 lowers it again for the actual access. A side-effectful base can pass the guard on the first evaluation and then index a different value on the second. Materialize the checked base once and pass it into the access helper.

🐛 Proposed fix
-    fn lower_optional_index(&mut self, base: AstExprId, index: AstExprId, dest: Place) {
-        let base_op = self.lower_to_operand(base);
+    fn lower_optional_index(&mut self, base: AstExprId, index: AstExprId, dest: Place) {
+        let base_ty = self.expr_ty(base);
+        let base_op = self.lower_to_operand(base);
+        let base_local = self.operand_to_local(base_op, base_ty.clone());

         let is_null = Rvalue::BinaryOp {
             op: BinOp::Eq,
-            left: base_op,
+            left: Operand::Copy(Place::Local(base_local)),
             right: Operand::Constant(Constant::Null),
         };
@@
             self.builder.set_current_block(bb_access);
-            self.lower_optional_index_access(base, index, dest, bb_null);
+            self.lower_optional_index_access(
+                Operand::Copy(Place::Local(base_local)),
+                base_ty,
+                index,
+                dest,
+                bb_null,
+            );
         } else {
@@
             self.builder.set_current_block(bb_access);
-            self.lower_optional_index_access(base, index, dest.clone(), bb_null);
+            self.lower_optional_index_access(
+                Operand::Copy(Place::Local(base_local)),
+                base_ty,
+                index,
+                dest.clone(),
+                bb_null,
+            );
@@
     fn lower_optional_index_access(
         &mut self,
-        base: AstExprId,
+        base_op: Operand,
+        base_ty: RuntimeTy,
         index: AstExprId,
         dest: Place,
         bb_null: BlockId,
     ) {
-        let base_ty = self.expr_ty(base);
-        let base_op = self.lower_to_operand(base);
         let index_ty = self.expr_ty(index);
🤖 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_mir/src/lower.rs` around lines 6327 -
6383, The `lower_optional_index` function lowers the base expression once to
check for null (line 6328), but then passes the original AstExprId to
`lower_optional_index_access` which lowers it again (line 6383). This causes
side-effectful bases to be evaluated twice, potentially yielding different
values. Fix this by modifying the `lower_optional_index_access` method signature
to accept the already-lowered Operand instead of the AstExprId, then pass the
base_op operand from `lower_optional_index` and remove the duplicate lowering
inside `lower_optional_index_access`.
🤖 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_compiler2_tir/src/builder.rs`:
- Around line 4703-4755: The check_index_key_type method conditionally strips
null from index types when allow_null is true (lines 4722-4726), assuming
indices in optional chains are non-null in the narrowed context, but no
narrowing analysis is actually performed on index expressions. This allows null
indices to pass compile-time checks and fail at runtime. Either remove the
allow_null conditional logic entirely and always validate the index_ty directly
against the expected_key type without stripping null, or implement actual
narrowing analysis for indices in optional indexing chains (in
infer_optional_index_expr or similar) to guarantee non-null indices before
passing allow_null as true to check_index_key_type.

---

Outside diff comments:
In `@baml_language/crates/baml_compiler2_mir/src/lower.rs`:
- Around line 6327-6383: The `lower_optional_index` function lowers the base
expression once to check for null (line 6328), but then passes the original
AstExprId to `lower_optional_index_access` which lowers it again (line 6383).
This causes side-effectful bases to be evaluated twice, potentially yielding
different values. Fix this by modifying the `lower_optional_index_access` method
signature to accept the already-lowered Operand instead of the AstExprId, then
pass the base_op operand from `lower_optional_index` and remove the duplicate
lowering inside `lower_optional_index_access`.
🪄 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: a9e79bcf-2a9a-4e37-b1a8-4d0eb50ce794

📥 Commits

Reviewing files that changed from the base of the PR and between 4796a2b and b49b605.

⛔ Files ignored due to path filters (9)
  • baml_language/Cargo.lock is excluded by !**/*.lock
  • baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_builtin_package_listing.snap is excluded by !**/*.snap
  • baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_describe_alias_string.snap is excluded by !**/*.snap
  • baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_describe_builtin_item_by_definition.snap is excluded by !**/*.snap
  • baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_describe_builtin_string.snap is excluded by !**/*.snap
  • baml_language/crates/baml_lsp2_actions/src/snapshots/baml_lsp2_actions__describe_tests__describe_builtin_string_with_compiler2_visible_files.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/__baml_std__/baml_tests__compiles____baml_std____03_hir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/__baml_std__/baml_tests__compiles____baml_std____04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/__baml_std__/baml_tests__compiles____baml_std____06_codegen.snap is excluded by !**/*.snap
📒 Files selected for processing (4)
  • baml_language/crates/baml_builtins2/baml_std/baml/string.baml
  • baml_language/crates/baml_compiler2_mir/src/lower.rs
  • baml_language/crates/baml_compiler2_tir/src/builder.rs
  • baml_language/crates/baml_compiler2_tir/src/inference.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • baml_language/crates/baml_builtins2/baml_std/baml/string.baml
  • baml_language/crates/baml_compiler2_tir/src/inference.rs
🛑 Comments failed to post (1)
baml_language/crates/baml_compiler2_tir/src/builder.rs (1)

4703-4755: ⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Search for narrowing/flow-analysis that makes optional-indexing indices non-null.

# Check for narrowing logic that might guarantee non-null indices in optional contexts.
rg -nP -C5 'allow_null.*true|optional.*index|narrowing.*index' \
  --glob 'baml_compiler2_tir/**/*.rs' \
  --glob '!baml_compiler2_tir/**/builder.rs'

Repository: BoundaryML/baml

Length of output: 208


🏁 Script executed:

#!/bin/bash
# Explore the directory structure to locate baml_compiler2_tir
fd -t d 'baml_compiler2_tir' | head -5
ls -la baml_language/crates/ | head -20

Repository: BoundaryML/baml

Length of output: 1377


🏁 Script executed:

#!/bin/bash
# Locate the builder.rs file and check context around lines 4703-4789
find . -name 'builder.rs' -type f | grep -i tir

Repository: BoundaryML/baml

Length of output: 116


🏁 Script executed:

#!/bin/bash
# Look for narrowing-related modules
find . -name '*narrowing*' -type f 2>/dev/null | head -20

Repository: BoundaryML/baml

Length of output: 887


🏁 Script executed:

#!/bin/bash
# Check the actual call sites of check_index_key_type with allow_null parameter
rg 'check_index_key_type' -A2 -B2 2>/dev/null | head -50

Repository: BoundaryML/baml

Length of output: 3408


🏁 Script executed:

#!/bin/bash
# Get more context around the optional indexing call with allow_null=true
# Find the line number for the optional indexing call
rg -n 'check_index_key_type.*true' baml_language/crates/baml_compiler2_tir/src/builder.rs

Repository: BoundaryML/baml

Length of output: 145


🏁 Script executed:

#!/bin/bash
# Read the section around the optional indexing call to see the full context
sed -n '4770,4800p' baml_language/crates/baml_compiler2_tir/src/builder.rs

Repository: BoundaryML/baml

Length of output: 1337


🏁 Script executed:

#!/bin/bash
# Check what base_info contains and how index_ty is derived for optional indexing
sed -n '4760,4795p' baml_language/crates/baml_compiler2_tir/src/builder.rs

Repository: BoundaryML/baml

Length of output: 1547


🏁 Script executed:

#!/bin/bash
# Look at the narrowing module to understand what type narrowing is available
head -100 baml_language/crates/baml_compiler2_tir/src/narrowing.rs

Repository: BoundaryML/baml

Length of output: 3615


🏁 Script executed:

#!/bin/bash
# Check if narrowing is applied to index expressions anywhere
rg -n 'extract_narrowings|apply.*narrowing' baml_language/crates/baml_compiler2_tir/src/builder.rs | head -20

Repository: BoundaryML/baml

Length of output: 1259


🏁 Script executed:

#!/bin/bash
# Look at how optional indexing evaluates the index and whether narrowing is applied
sed -n '4760,4810p' baml_language/crates/baml_compiler2_tir/src/builder.rs

Repository: BoundaryML/baml

Length of output: 2047


🏁 Script executed:

#!/bin/bash
# Check the full method that contains the optional indexing logic
rg -n 'fn infer_expr_optional_index' baml_language/crates/baml_compiler2_tir/src/builder.rs

Repository: BoundaryML/baml

Length of output: 41


🏁 Script executed:

#!/antml:thinking bash
# Find the complete optional indexing method
sed -n '4760,4850p' baml_language/crates/baml_compiler2_tir/src/builder.rs

Repository: BoundaryML/baml

Length of output: 3552


🏁 Script executed:

#!/bin/bash
# Verify the narrowing module really doesn't apply to index expressions
# by checking what contexts narrowing is actually applied to
sed -n '1,150p' baml_language/crates/baml_compiler2_tir/src/narrowing.rs | grep -A 30 'extract_narrowings'

Repository: BoundaryML/baml

Length of output: 829


🏁 Script executed:

#!/bin/bash
# Check if there's any narrowing applied for index_ty in the optional indexing flow
rg -n 'index_ty|index.*narrowing' baml_language/crates/baml_compiler2_tir/src/builder.rs | grep -A2 -B2 '47[67][0-9]'

Repository: BoundaryML/baml

Length of output: 374


🏁 Script executed:

#!/bin/bash
# Look at remove_null to understand what it does
rg -n 'fn remove_null|pub fn remove_null' baml_language/crates/baml_compiler2_tir/src/

Repository: BoundaryML/baml

Length of output: 156


🏁 Script executed:

#!/bin/bash
# Check where remove_null is defined and what it does
sed -n '1,50p' baml_language/crates/baml_compiler2_tir/src/narrowing.rs | tail -30
rg -A5 'pub fn remove_null' baml_language/crates/baml_compiler2_tir/src/narrowing.rs

Repository: BoundaryML/baml

Length of output: 1138


🏁 Script executed:

#!/bin/bash
# Check if there's any special narrowing/flow analysis for chained optional expressions like node?.children?.[node?.value]
rg -B10 -A10 'optional_index|infer_expr_optional' baml_language/crates/baml_compiler2_tir/src/builder.rs | head -60

Repository: BoundaryML/baml

Length of output: 1693


🏁 Script executed:

#!/antml:thinking bash
# Look at the full remove_null implementation
sed -n '379,410p' baml_language/crates/baml_compiler2_tir/src/narrowing.rs

Repository: BoundaryML/baml

Length of output: 1148


🏁 Script executed:

#!/bash
# Search for any context where the index in optional indexing receives special treatment
rg -n 'a\?\.\[|optional.*index|chained.*narrowing' baml_language/crates/baml_compiler2_tir/src/builder.rs

Repository: BoundaryML/baml

Length of output: 348


🏁 Script executed:

#!/bash
# Check test files for optional indexing with nullable indices to understand intended behavior
find baml_language/crates -name '*.baml' -o -name '*.rs' | xargs rg -l '\?\.\[' 2>/dev/null | head -5

Repository: BoundaryML/baml

Length of output: 348


🏁 Script executed:

#!/bin/bash
# Look at test files to understand the intended semantics
cat baml_language/crates/baml_tests/src/compiler2_tir/phase3a.rs | grep -A20 'optional.*index\|OptionalIndex' | head -50

Repository: BoundaryML/baml

Length of output: 656


🏁 Script executed:

#!/bin/bash
# Search test files for examples with nullable indices in optional indexing
rg -B5 -A5 'children.*value|optional.*chain' baml_language/crates/baml_tests/ --glob '*.baml' | head -40

Repository: BoundaryML/baml

Length of output: 4330


🏁 Script executed:

#!/bin/bash
# Check if there are any tests that verify the behavior with nullable indices
rg -B3 -A3 'a\?\.\[.*\?' baml_language/crates/ --glob '*.rs' | head -50

Repository: BoundaryML/baml

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Get more context around line 4717 to understand the comment better
sed -n '4710,4730p' baml_language/crates/baml_compiler2_tir/src/builder.rs

Repository: BoundaryML/baml

Length of output: 928


🏁 Script executed:

#!/bin/bash
# Check if there are any error recovery tests or examples showing what should happen with nullable indices
rg -B5 -A5 'node\?\..*node\?' baml_language/crates/baml_tests/ --glob '*.baml'

Repository: BoundaryML/baml

Length of output: 4410


🏁 Script executed:

#!/bin/bash
# Look for the Expr::OptionalIndex definition to understand the semantics
rg -B3 -A3 'OptionalIndex' baml_language/crates/baml_compiler2_ast/src/ --glob '*.rs' | head -30

Repository: BoundaryML/baml

Length of output: 2128


Remove the null-stripping logic in optional indexing or enforce narrowing to guarantee non-null indices.

The code strips null from index types in optional indexing contexts (line 4723, when allow_null=true) with the comment claiming this avoids "false positives" because indices are "typically nullable-by-type yet non-null in the narrowed chain context" (e.g., node?.children?.[node?.value]).

However, no narrowing analysis is applied to index expressions. The narrowing module handles control flow (if/else conditions, truthiness, instanceof), not subscript indices. In infer_optional_index_expr, the index type is inferred directly without any type narrowing, then passed to check_index_key_type with allow_null=true, which unconditionally strips null.

Since the runtime aborts when indexing with a null subscript (as noted in the comment), this allows code that will fail at runtime to pass compile-time type checking. The comment's assumption that the index is "typically" non-null in chained contexts is not enforced by the compiler.

Either remove the allow_null logic to catch these errors at compile time, or implement narrowing analysis for indices in optional chains to guarantee non-null at the point of indexing.

🤖 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_tir/src/builder.rs` around lines 4703 -
4755, The check_index_key_type method conditionally strips null from index types
when allow_null is true (lines 4722-4726), assuming indices in optional chains
are non-null in the narrowed context, but no narrowing analysis is actually
performed on index expressions. This allows null indices to pass compile-time
checks and fail at runtime. Either remove the allow_null conditional logic
entirely and always validate the index_ty directly against the expected_key type
without stripping null, or implement actual narrowing analysis for indices in
optional indexing chains (in infer_optional_index_expr or similar) to guarantee
non-null indices before passing allow_null as true to check_index_key_type.

A correctness audit against the BEP turned up four real defects; all are fixed
with regression tests (full suite green, 0 snapshot drift).

(1) Cross-site `${ let … }` ICE (BEP §4 / the `{% set %}` equivalent). An
untagged template lowered its segments as a `+`-fold of independent
`Expr::Block`s, so a `let` in one `${…}` never reached a later `${…}` — the
reference resolved to `Ty::Unknown` and panicked at MIR runtime lowering with NO
user-facing diagnostic. Lower into a single shared concat scope
(`InterpPart::{Value,Stmts}`) so a side-effect-only `${ let … }` splices its
statements in and stays visible to later segments, mirroring the `${for}`
accumulator. The fast path (no such segment) keeps the `+` fold so constant
templates still infer `Ty::Literal` (§9). Plus `retain_user_name_diagnostics`:
a genuine unresolved name in `${…}` now reports cleanly instead of ICE-ing.

(2) Inline if-expression / primitive literal-union `to_string` (BEP §4
README:205). `${if (c) { "p" } else { "q" }}` failed to type-check
(`try_resolve_member_on_ty` had no `Ty::Union` arm); fixing that exposed a
pre-existing MIR bug — a primitive literal-union has no class tag, so
`.to_string()` fell through to a class-instance map read (VM `expected map, got
string`). Add the union probe arm, and in MIR dispatch an all-same-primitive
union as one direct call on the primitive's builtin class.

(3) Void/unit tail renders "" (BEP §4 README:194). Falls out of (1): a
void-typed `${ if (false) { … } }` now renders "" instead of erroring on a
missing `to_string`.

(4) Dedent tab/space (BEP §12 Rule 2). `preprocess_template` used a byte-count
minimum (tab tied with space), so a tab-indented and a space-indented line
wrongly shared an indent. Use the longest common leading-whitespace *prefix*
(char-by-char) — mixed tabs/spaces yield strip column zero.

@coderabbitai coderabbitai Bot left a comment

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.

🧹 Nitpick comments (1)
baml_language/crates/baml_base/src/dedent.rs (1)

38-55: 💤 Low value

consumed and split_at are redundant.

Both variables are incremented by exactly the same amount (c.len_utf8()) in every iteration and will always be equal. A single variable would suffice.

♻️ Suggested simplification
 fn strip_leading_indent(line: &str, target_bytes: usize) -> &str {
     if target_bytes == 0 {
         return line;
     }
-    let mut consumed = 0usize;
-    let mut split_at = 0usize;
+    let mut stripped = 0usize;
     for c in line.chars() {
         if !c.is_whitespace() {
             break;
         }
-        consumed += c.len_utf8();
-        split_at += c.len_utf8();
-        if consumed >= target_bytes {
+        stripped += c.len_utf8();
+        if stripped >= target_bytes {
             break;
         }
     }
-    &line[split_at..]
+    &line[stripped..]
 }
🤖 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_base/src/dedent.rs` around lines 38 - 55, In the
strip_leading_indent function, the variables consumed and split_at are redundant
as they are incremented identically in each loop iteration and maintain equal
values throughout. Consolidate these into a single variable that tracks the byte
offset. Remove one of the variable declarations and use only one variable for
both the comparison against target_bytes and for slicing the return value in the
expression &line[...].
🤖 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.

Nitpick comments:
In `@baml_language/crates/baml_base/src/dedent.rs`:
- Around line 38-55: In the strip_leading_indent function, the variables
consumed and split_at are redundant as they are incremented identically in each
loop iteration and maintain equal values throughout. Consolidate these into a
single variable that tracks the byte offset. Remove one of the variable
declarations and use only one variable for both the comparison against
target_bytes and for slicing the return value in the expression &line[...].

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 545943a1-f0d4-483b-8df3-8fd18c223498

📥 Commits

Reviewing files that changed from the base of the PR and between b49b605 and ecc3784.

📒 Files selected for processing (5)
  • baml_language/crates/baml_base/src/dedent.rs
  • baml_language/crates/baml_compiler2_ast/src/lower_expr_body.rs
  • baml_language/crates/baml_compiler2_mir/src/lower.rs
  • baml_language/crates/baml_compiler2_tir/src/builder.rs
  • baml_language/crates/baml_compiler2_tir/src/infer_context.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • baml_language/crates/baml_compiler2_ast/src/lower_expr_body.rs
  • baml_language/crates/baml_compiler2_tir/src/builder.rs
👮 Files not reviewed due to content moderation or server errors (1)
  • baml_language/crates/baml_compiler2_mir/src/lower.rs

…olation

Resolves 4 post-merge test failures (no source conflicts — all in generated
snapshots / test expectations):

- numeric_invariance lexer snapshot: regenerated — backtick now lexes as a
  `Backtick` token (this branch's feature) instead of `Error`.
- bytecode_display_{expanded,unoptimized}: regenerated — canary grew the
  builtin function table (comparison ops / interface fixes) so call indices
  shifted, plus this branch's auto-derived `to_string` methods now appear.
- phase3a {new_mode_failures_have_good_diagnostics, nested_lambda_diagnostic_
  has_real_span}: updated the asserted substring from `operator \`Add\`` to
  `operator \`+\`` to match canary's improved arithmetic diagnostic (#3788),
  which now prints the user-facing operator symbol.

Full suite green: 6263 passed, 0 snapshot drift.
…ation

String interpolation (auto-derived `to_string` per stdlib class) plus the
merged canary code (comparison ops, interface fixes) grew the macOS arm64
artifacts past their ceilings:

  baml-cli        15.59 MB  (was 15.58 MB ceiling; +12.7 KB)
  packed-program  12.01 MB  (was 11.85 MB ceiling; +159.6 KB, +4.4% vs baseline)

`packed-program` carries every stdlib class's new `to_string`, so the +508 KB
is expected. Record the new measured sizes as the macOS baseline (the prior
baml-cli baseline was stale-high at 18.18 MB, predating the size-down work) and
raise both ceilings ~3% above per the file's convention. Other platforms are
unaffected (linux/wasm comfortably under; both gates green there).
…olation

Canary #3792 replaced magic/implicit `to_string` with the opt-in `baml.ToString`
interface + a `string.from<T>(value)` universal renderer, and added an HIR rule
forbidding a direct `to_string` method on a class. This branch's string
interpolation was built on the now-removed magic to_string, so the merge adopts
canary's design:

- Untagged `${expr}` now elaborates to `string.from(expr)` (the universal
  renderer) instead of `expr.to_string()`. Removed the magic to_string
  auto-derive and the obsolete `to_string`-stringability TIR check (the
  null guard stays). `string.from` is total, so every value interpolates
  without the type needing a `to_string` method.
- Took canary's primitive companions (Int/Float/Bool/String no longer declare
  a direct `to_string`) and removed the now-orphaned Rust `fn to_string` impls.
- Removed the dead MIR primitive-union `to_string` dispatch (interpolation no
  longer emits `union.to_string()`); kept the union member-resolution arm,
  which is load-bearing for BEP-044 union field access.
- Updated tests for the new model: a user class customises rendering via
  `implements baml.ToString`; a class without it renders structurally
  (`Point { x: 1, y: 2 }`); the tagged-template counter uses `string.from(i)`.
- Regenerated snapshots (the per-class auto-derived `to_string` is gone from
  the rendered IR).

Full suite green: 6275 passed, 0 snapshot drift.
…ompt companions

The playground preview and "generate cURL" call a function's `$render_prompt` /
`$build_request` / `$build_request_stream` companions. For new-mode (backtick)
LLM functions these went through the Jinja-only `render_prompt`, which has no
template registered for them — so they rendered an empty prompt (blank preview /
cURL). Only the execute path (`call_llm_function`) carried the compiled prompt
closure; the static render path never did, and no test caught it (every prior
render test used a Jinja `#"..."#` prompt).

Fix, reusing the machinery the execute path already has:

- Stdlib: add `PrimitiveClient.render_specialized_prompt` — the single render
  source of truth (new-mode closure vs legacy Jinja, then specialize).
  `render_prompt` / `build_request` / `build_request_stream` take an optional
  `prompt_closure` and route through it. The orchestrator's
  `execute_once_oneshot` / `execute_stream` now call the same helper, so
  execution and the static preview/cURL can no longer diverge.

- Compiler: new-mode functions stash closure-carrying companion bodies in
  `LlmBodyDef::companion_bodies` (built in `lower_cst` while the CST backtick is
  in hand, like `stream_body`); `make_llm_companion` reads them, so the
  companions pass the prompt closure. Legacy Jinja functions are unchanged.

- Tests: `build_request.rs` now exercises the new-mode `$render_prompt` /
  `$build_request` companions and asserts they render the backtick prompt.

Snapshots regenerated for the new stdlib functions + signatures.

@coderabbitai coderabbitai Bot left a comment

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.

🧹 Nitpick comments (2)
baml_language/crates/baml_compiler2_ast/src/companions.rs (1)

168-177: ⚡ Quick win

Avoid silently downgrading new-mode companions to legacy synthesis.

On Line 168, a missing stashed body falls back to the legacy 3-arg call. For backtick/new-mode parents, that can hide an upstream lowering mismatch and change prompt-render behavior. Consider guarding this with an invariant check (or diagnostic) when companion_bodies is non-empty but target is missing.

Suggested hardening
-    let (body, source_map) = llm_companion_body(parent, target).unwrap_or_else(|| {
-        synthesize_llm_builtin_call(
-            target,
-            parent.name.as_str(),
-            &param_names,
-            client_arg_name,
-            Vec::new(),
-            parent.span,
-        )
-    });
+    let (body, source_map) = if let Some(pair) = llm_companion_body(parent, target) {
+        pair
+    } else {
+        if let Some(DeclarativeMeta::Llm(llm)) = &parent.declarative_meta {
+            debug_assert!(
+                llm.companion_bodies.is_empty(),
+                "missing stashed companion body for target `{target}` on `{}`",
+                parent.name
+            );
+        }
+        synthesize_llm_builtin_call(
+            target,
+            parent.name.as_str(),
+            &param_names,
+            client_arg_name,
+            Vec::new(),
+            parent.span,
+        )
+    };
🤖 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/companions.rs` around lines 168 -
177, The unwrap_or_else call on llm_companion_body at line 168 silently falls
back to synthesize_llm_builtin_call when a target is missing, which can hide
upstream lowering mismatches for new-mode companions. Add a guard before this
fallback to check if companion_bodies is non-empty but target is missing
(indicating an unexpected state), and emit an invariant check or diagnostic in
that case to prevent silently downgrading to legacy synthesis behavior rather
than proceeding with the fallback.
baml_language/crates/baml_builtins2/baml_std/baml/ns_llm/llm.baml (1)

91-94: ⚡ Quick win

Reuse prompt_template_for to avoid branch-logic drift.

The legacy/new-mode jinja_string match is duplicated in two places even though prompt_template_for now centralizes this rule. Reusing it keeps execution-path behavior aligned with preview/build helpers.

Suggested diff
 function call_llm_function<T>(client: Client, function_name: string, args: map<string, unknown>, prompt_closure: ((Context) -> PromptAst)? = null) -> T {
-  // New-mode (backtick) functions have no Jinja template registered; the
-  // closure renders the prompt, so only look one up for the legacy path.
-  let jinja_string = match (prompt_closure) {
-    null => get_jinja_template(function_name),
-    _ => "",
-  };
+  let jinja_string = prompt_template_for(function_name, prompt_closure);

   let context = ExecutionContext {
     jinja_string: jinja_string,
@@
 function stream_llm_function<TStream, TFinal>(client: Client, function_name: string, args: map<string, unknown>, prompt_closure: ((Context) -> PromptAst)? = null) -> Stream<TStream, TFinal> {
-    // New-mode (backtick) functions have no Jinja template registered; the
-    // closure renders the prompt, so only look one up for the legacy path.
-    let jinja_string = match (prompt_closure) {
-        null => get_jinja_template(function_name),
-        _ => "",
-    };
+    let jinja_string = prompt_template_for(function_name, prompt_closure);

     let context = ExecutionContext {
         jinja_string: jinja_string,

Also applies to: 115-118

🤖 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_builtins2/baml_std/baml/ns_llm/llm.baml` around
lines 91 - 94, The jinja_string variable initialization uses a match statement
on prompt_closure that is duplicated in two locations. Instead of maintaining
this duplicate match logic in both places, refactor both occurrences (the one at
lines 91-94 and the one at lines 115-118) to use the existing
prompt_template_for function which already centralizes this branch logic. This
will eliminate the duplication and ensure consistent behavior across all
execution paths.
🤖 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.

Nitpick comments:
In `@baml_language/crates/baml_builtins2/baml_std/baml/ns_llm/llm.baml`:
- Around line 91-94: The jinja_string variable initialization uses a match
statement on prompt_closure that is duplicated in two locations. Instead of
maintaining this duplicate match logic in both places, refactor both occurrences
(the one at lines 91-94 and the one at lines 115-118) to use the existing
prompt_template_for function which already centralizes this branch logic. This
will eliminate the duplication and ensure consistent behavior across all
execution paths.

In `@baml_language/crates/baml_compiler2_ast/src/companions.rs`:
- Around line 168-177: The unwrap_or_else call on llm_companion_body at line 168
silently falls back to synthesize_llm_builtin_call when a target is missing,
which can hide upstream lowering mismatches for new-mode companions. Add a guard
before this fallback to check if companion_bodies is non-empty but target is
missing (indicating an unexpected state), and emit an invariant check or
diagnostic in that case to prevent silently downgrading to legacy synthesis
behavior rather than proceeding with the fallback.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 709a6bec-cf01-41a5-ae01-10c37f1d16d2

📥 Commits

Reviewing files that changed from the base of the PR and between 2c45813 and 4bf504b.

⛔ Files ignored due to path filters (26)
  • baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_builtin_namespace_llm.snap is excluded by !**/*.snap
  • baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_builtin_package_listing.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/type_reflection.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/__baml_std__/baml_tests__compiles____baml_std____03_hir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/__baml_std__/baml_tests__compiles____baml_std____04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/__baml_std__/baml_tests__compiles____baml_std____04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/__baml_std__/baml_tests__compiles____baml_std____06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/comment_in_type/baml_tests__compiles__comment_in_type__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/comment_in_type/baml_tests__compiles__comment_in_type__06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/json_llm_return_type/baml_tests__compiles__json_llm_return_type__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/json_llm_return_type/baml_tests__compiles__json_llm_return_type__06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/llm_image_outputs/baml_tests__compiles__llm_image_outputs__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/llm_image_outputs/baml_tests__compiles__llm_image_outputs__06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/optional_function_parameters/baml_tests__compiles__optional_function_parameters__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/optional_function_parameters/baml_tests__compiles__optional_function_parameters__06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/stream_llm_inferred_typeargs/baml_tests__compiles__stream_llm_inferred_typeargs__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/stream_llm_inferred_typeargs/baml_tests__compiles__stream_llm_inferred_typeargs__06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/testset_vibes_nested/baml_tests__compiles__testset_vibes_nested__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/testset_vibes_nested/baml_tests__compiles__testset_vibes_nested__06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/type_builder_errors/baml_tests__compiles__type_builder_errors__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/type_builder_errors/baml_tests__compiles__type_builder_errors__06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/type_builder_test/baml_tests__compiles__type_builder_test__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/type_builder_test/baml_tests__compiles__type_builder_test__06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/src/compiler2_tir/snapshots/baml_tests__compiler2_tir__phase5__snapshot_baml_package_items.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/tests/bytecode_format/snapshots/bytecode_format__bytecode_display_expanded.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/tests/bytecode_format/snapshots/bytecode_format__bytecode_display_expanded_unoptimized.snap is excluded by !**/*.snap
📒 Files selected for processing (6)
  • baml_language/crates/baml_builtins2/baml_std/baml/ns_llm/llm.baml
  • baml_language/crates/baml_builtins2/baml_std/baml/ns_llm/llm_types.baml
  • baml_language/crates/baml_compiler2_ast/src/ast.rs
  • baml_language/crates/baml_compiler2_ast/src/companions.rs
  • baml_language/crates/baml_compiler2_ast/src/lower_cst.rs
  • baml_language/crates/bex_engine/tests/build_request.rs
🚧 Files skipped from review as they are similar to previous changes (3)
  • baml_language/crates/baml_compiler2_ast/src/lower_cst.rs
  • baml_language/crates/baml_compiler2_ast/src/ast.rs
  • baml_language/crates/baml_builtins2/baml_std/baml/ns_llm/llm_types.baml

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