Skip to content

Lower lambdas in surrounding function - #4282

Merged
2kai2kai2 merged 4 commits into
canaryfrom
kai/one-arena-per-fn
Jul 30, 2026
Merged

Lower lambdas in surrounding function#4282
2kai2kai2 merged 4 commits into
canaryfrom
kai/one-arena-per-fn

Conversation

@2kai2kai2

@2kai2kai2 2kai2kai2 commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Lambdas use their parent function's TypeExpr/TypeRef arenas, now have a separate LambdaDef AST type and generally lower in their surrounding function rather than as a separate system. This improves the internal representation and also is part of the groundwork for a rust-like inference system.

Summary by CodeRabbit

  • Improvements

    • Improved handling of lambda expressions and nested test bodies for more accurate type inference, diagnostics, and control-flow analysis.
    • Ensured exceptions raised inside unused lambdas do not incorrectly affect the enclosing function.
    • Improved code navigation, local-variable resolution, inlay hints, completions, and usage tracking within lambdas.
    • Added structural expression traversal to improve analysis accuracy and consistency.
  • Documentation

    • Updated compiler architecture documentation to explain lambda-body representation and incremental inference behavior.
  • Tests

    • Added coverage for traversal completeness and lambda exception handling.

@vercel

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
beps Error Error Jul 30, 2026 6:10pm
promptfiddle2 Ready Ready Preview Jul 30, 2026 6:10pm

Request Review

@github-actions

Copy link
Copy Markdown

⏭️ Performance benchmarks were skipped

Perf benchmarks (CodSpeed) are opt-in on pull requests — they no longer run on every push. They always run automatically after merge to canary/main.

To run them on this PR, do any of the following, then push a commit (or re-run CI):

  • Add RUN_CODSPEED=1 to the PR description, or
  • Include run-perf or /perf in the PR title or any commit message.

@2kai2kai2
2kai2kai2 enabled auto-merge July 30, 2026 18:06
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Lambda bodies now use LambdaDef values with ExprId roots stored in enclosing ExprBody arenas. Lowering, traversal, HIR/MIR/TIR inference, throws analysis, LSP features, control-flow construction, documentation, and tests were updated to use the shared-arena model.

Changes

Lambda arena migration

Layer / File(s) Summary
Lambda AST and arena lowering
baml_language/crates/baml_compiler2_ast/...
Introduces LambdaDef/LambdaKind, lowers lambda and test bodies into enclosing arenas, and adds root-based traversal that excludes lambda bodies.
HIR, MIR, and TIR lambda inference
baml_language/crates/baml_compiler2_hir/..., baml_language/crates/baml_compiler2_mir/..., baml_language/crates/baml_compiler2_tir/...
Updates lambda walking, lowering, scope ownership, type inference, throws inference, and explicit root handling.
Root-aware tooling and validation
baml_language/ARCHITECTURE.md, baml_language/crates/baml_lsp2_actions/..., baml_language/crates/baml_project/..., baml_language/crates/baml_tests/..., baml_language/crates/baml_compiler2_visualization/...
Adapts documentation, LSP resolution, control-flow graphs, test rendering, and exception coverage to shared arenas and lambda-excluding traversal.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Source
  participant ASTLowering
  participant ExprBody
  participant TIRInference
  participant LSP
  Source->>ASTLowering: lower lambda expression
  ASTLowering->>ExprBody: allocate lambda body nodes
  ASTLowering->>TIRInference: provide LambdaDef and body root
  TIRInference->>ExprBody: infer reachable nodes
  LSP->>ExprBody: index reachable non-lambda nodes
Loading

Possibly related PRs

Suggested reviewers: codeshaunted, hellovai, aaronvg

Poem

A bunny hops through arenas wide,
Lambdas keep their roots inside.
Hops skip bodies, scopes stay clear,
Throws find only what is near.
Tests and tools now follow too—
Fresh paths bloom in compiler dew.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly reflects the main change: lambdas are now lowered within their surrounding function.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Fix failing CI checks
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch kai/one-arena-per-fn

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.

@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 July 30, 2026 18:10 Inactive
@2kai2kai2
2kai2kai2 added this pull request to the merge queue Jul 30, 2026
@github-actions

Copy link
Copy Markdown

Binary size checks passed

7 passed

Artifact Platform File Gzip Gated on Baseline Delta Status
baml-cli Linux 🔒 27.4 MB 11.7 MB file 27.4 MB +43.2 KB (+0.2%) OK
packed-program Linux 🔒 17.7 MB 7.3 MB file 17.7 MB +13.2 KB (+0.1%) OK
baml-cli macOS 🔒 21.3 MB 10.2 MB file 21.3 MB +14.7 KB (+0.1%) OK
packed-program macOS 🔒 13.9 MB 6.4 MB file 13.8 MB +11.9 KB (+0.1%) OK
baml-cli Windows 🔒 23.0 MB 10.4 MB file 23.0 MB +13.7 KB (+0.1%) OK
packed-program Windows 🔒 14.8 MB 6.5 MB file 14.8 MB -10.3 KB (-0.1%) OK
bridge_wasm WASM 16.8 MB 🔒 4.6 MB gzip 4.6 MB -40.9 KB (-0.9%) OK

🔒 = the size this artifact is GATED on (ceiling + delta). Binaries gate on file size (installed binary); WASM gates on gzip (download size). The other size is shown for information only.


Generated by cargo size-gate · workflow run

Merged via the queue into canary with commit e850d92 Jul 30, 2026
79 of 81 checks passed
@2kai2kai2
2kai2kai2 deleted the kai/one-arena-per-fn branch July 30, 2026 18:28

@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_project/src/db.rs (1)

775-775: 🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

call_sites_by_source_expr now scans the whole shared arena instead of the current test/lambda's own subtree.

Since lambda bodies (and, for tests, every sibling test { ... } registration in a file) now share one ExprBody arena, call_sites_by_source_expr's flat body.exprs.iter() scan is no longer scoped to the callable being visualized. At Line 775, test_body is the entire $init_test_* registration body for the file (every test's lambda body lives in it), so building the CFG for one test now re-scans every other test's body too. At Line 861 the same helper additionally now walks any lambdas nested inside the function being graphed (previously excluded, since lambda bodies were separate arenas).

This doesn't produce wrong output — expand_user_function_calls_in_graph's subsequent graph.nodes lookup still filters out call sites that aren't part of the bounded CFG — but it turns "visualize one test" into O(all tests' combined body size) instead of O(that test's body size), which is quadratic across a file with many tests in the playground.

Bound the scan the same way tokens/index.rs/usages.rs already do for this migration, using ExprBody::reachable_excluding_lambdas from an explicit root:

⚡ Proposed fix: bound the scan by root
-fn call_sites_by_source_expr(body: &baml_compiler2_ast::ExprBody) -> Vec<(u32, String)> {
-    use baml_compiler2_ast::Expr;
-
-    let mut calls = Vec::new();
-    for (expr_id, expr) in body.exprs.iter() {
-        let (Expr::Call { callee, .. } | Expr::OptionalCall { callee, .. }) = expr else {
-            continue;
-        };
-
-        let Expr::Path(segments) = &body.exprs[*callee] else {
-            continue;
-        };
-
-        let callee_name = segments
-            .iter()
-            .map(AsRef::<str>::as_ref)
-            .collect::<Vec<_>>()
-            .join(".");
-        calls.push((expr_id.into_raw().into_u32(), callee_name));
-    }
-    calls
-}
+fn call_sites_by_source_expr(
+    body: &baml_compiler2_ast::ExprBody,
+    root: Option<baml_compiler2_ast::ExprId>,
+) -> Vec<(u32, String)> {
+    use baml_compiler2_ast::{BodyNode, Expr};
+
+    let mut calls = Vec::new();
+    let Some(root) = root else {
+        return calls;
+    };
+    for node in body.reachable_excluding_lambdas(root) {
+        let BodyNode::Expr(expr_id) = node else {
+            continue;
+        };
+        let (Expr::Call { callee, .. } | Expr::OptionalCall { callee, .. }) = &body.exprs[expr_id]
+        else {
+            continue;
+        };
+
+        let Expr::Path(segments) = &body.exprs[*callee] else {
+            continue;
+        };
+
+        let callee_name = segments
+            .iter()
+            .map(AsRef::<str>::as_ref)
+            .collect::<Vec<_>>()
+            .join(".");
+        calls.push((expr_id.into_raw().into_u32(), callee_name));
+    }
+    calls
+}

Then thread a root: Option<ExprId> through expand_user_function_calls_in_graph and pass expr_body.root_expr at Line 861 and test_lambda.body at Line 775.

Also applies to: 861-861, 879-963, 998-1019

🤖 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_project/src/db.rs` at line 775, Bound call-site
scanning in expand_user_function_calls_in_graph to the callable’s subtree by
adding a root: Option<ExprId> parameter and using
ExprBody::reachable_excluding_lambdas from that root instead of scanning the
full arena. Pass test_lambda.body when processing test bodies and
expr_body.root_expr when processing functions, and thread the root through all
affected callers and helper logic.
🧹 Nitpick comments (2)
baml_language/crates/baml_compiler2_ast/src/lower_expr_body.rs (1)

686-688: 📐 Maintainability & Code Quality | 🔵 Trivial

Tracked BUG: — doubly-nested test is dropped without a diagnostic.

testset "A" { test "B" { test "C" {} } } compiles clean while C never runs. The new a_test_inside_a_test_does_not_register test pins the current behavior, so this is a known gap rather than a regression.

Want me to open an issue to add a NestedTestNotRegistered lowering diagnostic on this arm?

🤖 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
686 - 688, Track this known gap by opening an issue for a
NestedTestNotRegistered lowering diagnostic when processing a test nested inside
another test in the relevant lowering arm. Preserve the current behavior and the
a_test_inside_a_test_does_not_register test; do not change registration or add
unrelated diagnostics.
baml_language/crates/baml_compiler2_tir/src/throw_inference.rs (1)

419-443: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Nit: body_nodes is walked twice per call here.

collect_catch_arm_bodies(body) (line 410) already builds the full node list, and this loop rebuilds it. Computing it once and passing the slice to both would halve the traversal + allocation on every function.

🤖 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/throw_inference.rs` around lines
419 - 443, Reuse the node list produced by collect_catch_arm_bodies in the
throw-inference flow instead of calling body_nodes(body) again. Update
collect_catch_arm_bodies and its caller as needed to pass the shared slice to
both catch-arm analysis and the subsequent throw scan, preserving the existing
filtering and fact insertion behavior.
🤖 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_ast/src/ast.rs`:
- Around line 903-906: Update the stale lambda documentation at
baml_language/crates/baml_compiler2_ast/src/ast.rs:903-906 by removing
references to FunctionDef and FunctionBodyDef::Expr, and describe Expr::Lambda
as referencing LambdaDef with a body stored as ExprId in the shared arena. Also
revise lower_spawn_expr documentation at
baml_language/crates/baml_compiler2_ast/src/lower_expr_body.rs:854-869 to
describe lowering a synthesized LambdaDef into the current arena and replace the
Expr::Lambda(func_def) wording with the LambdaDef payload terminology.

In `@baml_language/crates/baml_lsp2_actions/src/annotations.rs`:
- Around line 246-249: Update the comment above the synthesized test/testset
registration skip in the annotation traversal to remove the reference to the
nonexistent Expr::Lambda arm. Describe that lambda arguments are still
recursively visited because their bodies reside in the same arena and are
processed by the surrounding loop.

---

Outside diff comments:
In `@baml_language/crates/baml_project/src/db.rs`:
- Line 775: Bound call-site scanning in expand_user_function_calls_in_graph to
the callable’s subtree by adding a root: Option<ExprId> parameter and using
ExprBody::reachable_excluding_lambdas from that root instead of scanning the
full arena. Pass test_lambda.body when processing test bodies and
expr_body.root_expr when processing functions, and thread the root through all
affected callers and helper logic.

---

Nitpick comments:
In `@baml_language/crates/baml_compiler2_ast/src/lower_expr_body.rs`:
- Around line 686-688: Track this known gap by opening an issue for a
NestedTestNotRegistered lowering diagnostic when processing a test nested inside
another test in the relevant lowering arm. Preserve the current behavior and the
a_test_inside_a_test_does_not_register test; do not change registration or add
unrelated diagnostics.

In `@baml_language/crates/baml_compiler2_tir/src/throw_inference.rs`:
- Around line 419-443: Reuse the node list produced by collect_catch_arm_bodies
in the throw-inference flow instead of calling body_nodes(body) again. Update
collect_catch_arm_bodies and its caller as needed to pass the shared slice to
both catch-arm analysis and the subsequent throw scan, preserving the existing
filtering and fact insertion behavior.
🪄 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 Plus

Run ID: 00b1ed2d-a2f8-43f8-8344-4ec151201ae5

📥 Commits

Reviewing files that changed from the base of the PR and between 61148c0 and 33cb9ef.

⛔ Files ignored due to path filters (1)
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/void_return_type/baml_tests__diagnostic_errors__void_return_type__05_diagnostics.snap is excluded by !**/*.snap
📒 Files selected for processing (24)
  • baml_language/ARCHITECTURE.md
  • baml_language/crates/baml_compiler2_ast/src/ast.rs
  • baml_language/crates/baml_compiler2_ast/src/disambiguate.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/traverse.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/throw_inference.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_compiler2_visualization/src/control_flow/mod.rs
  • baml_language/crates/baml_lsp2_actions/src/annotations.rs
  • baml_language/crates/baml_lsp2_actions/src/completions.rs
  • baml_language/crates/baml_lsp2_actions/src/describe.rs
  • baml_language/crates/baml_lsp2_actions/src/tokens/index.rs
  • baml_language/crates/baml_lsp2_actions/src/usages.rs
  • baml_language/crates/baml_project/src/db.rs
  • baml_language/crates/baml_tests/src/compiler2_tir/inference.rs
  • baml_language/crates/baml_tests/src/compiler2_tir/mod.rs
  • baml_language/crates/baml_tests/src/compiler2_tir/phase8_exceptions.rs

Comment on lines 903 to +906
/// Lambda expression: anonymous function in expression position.
/// Reuses `FunctionDef` with synthetic name `"<anonymous function>"`.
/// The lambda's body gets its own `ExprBody` via `FunctionBodyDef::Expr`.
Lambda(Box<FunctionDef>),
Lambda(Box<LambdaDef>),

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Doc comments left behind by the shared-arena migration. Both sites still describe lambdas as reusing FunctionDef and owning their own ExprBody/source map — precisely the model this PR replaces. The code is correct; only the prose is stale, and these are the two places a reader looks first to learn the lambda representation.

  • baml_language/crates/baml_compiler2_ast/src/ast.rs#L903-L906: drop the FunctionDef / FunctionBodyDef::Expr sentences on Expr::Lambda and point at [LambdaDef], noting the body is an ExprId in this arena.
  • baml_language/crates/baml_compiler2_ast/src/lower_expr_body.rs#L854-L869: reword lower_spawn_expr's doc so the synthesized body is a LambdaDef lowered into the current arena, not "a fresh 0-arg lambda (its own ExprBody + source map)"; also update "Expr::Lambda(func_def)" to reflect the LambdaDef payload.
📍 Affects 2 files
  • baml_language/crates/baml_compiler2_ast/src/ast.rs#L903-L906 (this comment)
  • baml_language/crates/baml_compiler2_ast/src/lower_expr_body.rs#L854-L869
🤖 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/ast.rs` around lines 903 - 906,
Update the stale lambda documentation at
baml_language/crates/baml_compiler2_ast/src/ast.rs:903-906 by removing
references to FunctionDef and FunctionBodyDef::Expr, and describe Expr::Lambda
as referencing LambdaDef with a body stored as ExprId in the shared arena. Also
revise lower_spawn_expr documentation at
baml_language/crates/baml_compiler2_ast/src/lower_expr_body.rs:854-869 to
describe lowering a synthesized LambdaDef into the current arena and replace the
Expr::Lambda(func_def) wording with the LambdaDef payload terminology.

Comment on lines +246 to +249
// Skip synthesized test/testset registration calls — their
// `name` / `body` / `collector` / `runner` arguments are codegen,
// not user-facing. We still recurse into their lambda arguments
// (the actual test bodies) via the `Expr::Lambda` arm below.

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Stale comment: the Expr::Lambda arm it references no longer exists.

Lambda arguments are now reached because their bodies live in this same arena and are visited by this loop — not "via the Expr::Lambda arm below".

📝 Proposed comment fix
             // Skip synthesized test/testset registration calls — their
             // `name` / `body` / `collector` / `runner` arguments are codegen,
-            // not user-facing. We still recurse into their lambda arguments
-            // (the actual test bodies) via the `Expr::Lambda` arm below.
+            // not user-facing. Their lambda arguments (the actual test bodies)
+            // are still covered: those expressions live in this same arena and
+            // are visited by this loop.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Skip synthesized test/testset registration calls — their
// `name` / `body` / `collector` / `runner` arguments are codegen,
// not user-facing. We still recurse into their lambda arguments
// (the actual test bodies) via the `Expr::Lambda` arm below.
// Skip synthesized test/testset registration calls — their
// `name` / `body` / `collector` / `runner` arguments are codegen,
// not user-facing. Their lambda arguments (the actual test bodies)
// are still covered: those expressions live in this same arena and
// are visited by this loop.
🤖 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_lsp2_actions/src/annotations.rs` around lines 246 -
249, Update the comment above the synthesized test/testset registration skip in
the annotation traversal to remove the reference to the nonexistent Expr::Lambda
arm. Describe that lambda arguments are still recursively visited because their
bodies reside in the same arena and are processed by the surrounding loop.

codeshaunted added a commit that referenced this pull request Jul 31, 2026
…n TIR

Probed the ticket's bad_pop repro against current TIR: pop() on a
generic receiver types T | null (both engines agree), but TIR still
accepts the single-arm match { let x: T => x } over T | null with no
diagnostic - the soundness hole lives in exhaustiveness, and its fix
PR #3904 was parked for this rework. The bad_pop fixture lands at S10
(patterns) as tir: fails. B-1010 was fixed in TIR by the base commit's
lambda rework (#4282); the ticket is stale against canary.
codeshaunted added a commit that referenced this pull request Aug 1, 2026
…n TIR

Probed the ticket's bad_pop repro against current TIR: pop() on a
generic receiver types T | null (both engines agree), but TIR still
accepts the single-arm match { let x: T => x } over T | null with no
diagnostic - the soundness hole lives in exhaustiveness, and its fix
PR #3904 was parked for this rework. The bad_pop fixture lands at S10
(patterns) as tir: fails. B-1010 was fixed in TIR by the base commit's
lambda rework (#4282); the ticket is stale against canary.
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