Skip to content

Implement lexical scoping and shadowing for let bindings#3425

Merged
rossirpaulo merged 18 commits intocanaryfrom
paulo/binding-and-shadowing
Apr 28, 2026
Merged

Implement lexical scoping and shadowing for let bindings#3425
rossirpaulo merged 18 commits intocanaryfrom
paulo/binding-and-shadowing

Conversation

@rossirpaulo
Copy link
Copy Markdown
Contributor

@rossirpaulo rossirpaulo commented Apr 28, 2026

Implements lexical block scoping and local shadowing for BAML let, watch let, and for bindings across HIR, TIR, MIR, generated snapshots, and runtime regressions.

Summary

  • Adds lexical scoping fixtures for valid shadowing and invalid block-leak cases.
  • Refactors HIR body walking so nested blocks, for/while loop bodies, lambdas, match arms, and catch arms own the right scopes. Stmt::While now pushes a Block scope around the body and the C-style after step, mirroring Stmt::For.
  • Allows local shadowing, repeated _, and parameter shadowing while preserving top-level/member/parameter duplicate diagnostics.
  • Adds visible-binding lookup and capture identity via binding IDs so same-name captures resolve to the correct declaration.
  • Restores TIR active local state across blocks, for loops, while loops, match arms, and catch arms. PatId-keyed assignment tracking distinguishes inner-shadow assignments (drop on scope exit, rule 3) from outer-binding assignments (propagate, rule 2).
  • Routes match-arm and catch-arm pattern bindings — including the catch-clause stack-trace binding — through declare_scoped_local, fixing a leak where the stack-trace binding was raw-inserted with no paired restore.
  • Lowers lambda captures by binding identity and marks captured MIR locals by binding identity.
  • Restores MIR watched-local cleanup on every exit path through a single emitter (emit_unwatch_to_depth): block fallthrough, for/while body fallthrough, match-arm and catch-arm body fallthrough, throw, break/continue/return.
  • Type-checks the C-style for after step (e.g. i += 1), which was previously skipped. This surfaces two correct missing-name diagnostics that update the loops_c_for and header_requires_let_negative LSP fixtures.
  • Adds runtime regressions and unignores now-supported branch-local and lambda-shadowing tests.
  • Accepts final HIR/TIR/MIR/codegen/diagnostics snapshots.

Scoping and Evolving Container Rules

  • let, watch let, and for declarations are lexical: bindings introduced inside a block, loop body, lambda, match arm, or catch arm do not leak after that scope exits.
  • Assignments and type-establishing mutations to an already-visible outer binding still apply to that outer binding, even when they occur inside a nested block or loop body.
  • Shadowing is resolved by binding identity, not by name alone: mutating a shadowed inner binding must not mutate or retype the outer binding.
  • Empty evolving containers use the existing BAML heuristic: the first type-establishing usage wins. This includes push, index assignment, and similar mutations inside control-flow or loop bodies; the typechecker does not treat a loop body as non-establishing just because the loop might run zero times.
  • Example: let xs = []; for (let n in []) { xs.push("s") }; xs.push(1) establishes xs as string[] at the loop-body push, so the later integer push is a type error.

Validation

  • cargo test -p baml_lsp2_actions_tests --lib
  • cargo test -p baml_tests --lib
  • cargo test -p bex_engine --test concurrent test_closures_in_loop_vars
  • cargo test -p baml_tests --test lexical_scoping
  • cargo test -p baml_tests --test watch
  • mise run fmt
  • mise run clippy
  • prek run --all-files

CI status

Summary by CodeRabbit

  • Bug Fixes

    • Fixes for duplicate-binding diagnostics and correct restoration of outer variables after blocks, loops, and while bodies.
    • Improved lambda capture and shadowing behavior so closures reference the intended variables.
  • New Features

    • Stronger lexical-scoping guarantees across if/else, match, catch, loops, and nested blocks.
  • Editor / LSP

    • More accurate completions and hover/type info for let/for bindings; improved reference-finding respecting shadowing.
  • Tests / Chores

    • Numerous new/updated regression tests and updated snapshot expectations; .gitignore updated.

rossirpaulo and others added 12 commits April 28, 2026 03:25
Remove `_`-as-value test cases and regenerate snapshots. PR #3417
("implement new patterns backend without parser support") canonicalizes
`_` to a wildcard pattern at AST construction, so `let _ = expr; _` is
no longer valid — `_` binds nothing and cannot be referenced.

- Drop `repeated_underscore` and `for_underscore_binding` from the
  lexical_scoping project and the inlined regression strings.
- Rename `declared_type_and_for_underscore_restore_outer_binding` to
  `declared_type_restored_across_scope` and keep only the half that
  exercises declared-type restoration across an inner scope (a real
  Phase 1 invariant); drop the underscore half.
- Regenerate the 7 lexical_scoping snapshots; sizes shrink consistently
  with removing only the two functions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 28, 2026

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

Project Deployment Actions Updated (UTC)
beps Ready Ready Preview, Comment Apr 28, 2026 1:34pm
promptfiddle Ready Ready Preview, Comment Apr 28, 2026 1:34pm

Request Review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 28, 2026

📝 Walkthrough

Walkthrough

Replaces name-based local/capture tracking with semantic identity (BindingId) across HIR, semantic index, MIR, and TIR. Expression traversal now records per-reference root bindings and visibility; lambda capture, lowering, inference, and scope-restore logic use binding identity. Many tests and LSP features updated to use the new semantics.

Changes

Cohort / File(s) Summary
HIR semantic index & builder
baml_language/crates/baml_compiler2_hir/src/semantic_index.rs, baml_language/crates/baml_compiler2_hir/src/builder.rs
Adds BindingId and LocalBinding (with visible_from), records PathRootReference during expression walk, registers locals on declaration, and replaces name-based lambda capture collection with binding-identity-based capture analysis.
MIR lowering
baml_language/crates/baml_compiler2_mir/src/lower.rs
Maps captures to BindingId, introduces binding_locals, refactors capture-indexing and name resolution to use binding_id_for_name_at, centralizes watcher cleanup (emit_unwatch_to_depth), and rewrites catch/match arm lowering to restore per-arm locals and watcher depth.
TIR builder & scoped locals
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/resolve.rs
Implements snapshot/restore scoped-local rollback, routes declarations/assignments through declare_scoped_local/assign_local, seeds lambda captures by BindingId, updates inference to use owning-scope logic and BindingId for retyping, and centralizes visibility checks via index.binding_visible_at.
LSP features: completions / describe / type info / usages
baml_language/crates/baml_lsp2_actions/src/completions.rs, .../completions_tests.rs, .../describe.rs, .../describe_tests.rs, .../type_info.rs, .../type_info_tests.rs, .../usages.rs, .../usages_at_tests.rs
Switches LSP logic to use semantic visibility (binding_visible_at / BindingId), extracts PatId from for bindings, deduplicates completions, resolves binding owners for describe/type queries, and refactors usages/resolution to compare BindingId. Tests added/updated for shadowing, hover and usages.
TIR tests & compiler tests
baml_language/crates/baml_tests/src/compiler2_hir.rs, .../compiler2_tir/*, baml_language/crates/baml_tests/tests/*
Adds many regressions and re-enables tests covering lexical scoping, shadowing, capture correctness, container/type establishment, watch/unwatch semantics across control-flow, and scope restoration. Adjusts render_tir skip logic to use ScopeInference.
BAML test fixtures
baml_language/crates/baml_tests/projects/lexical_scoping/..., baml_language/crates/baml_tests/projects/lexical_scoping_errors/..., baml_language/crates/baml_lsp2_actions_tests/test_files/syntax/*
Adds lexical-scoping fixtures and updates expected diagnostics in multiple test fixtures to reflect new visibility/duplicate-binding behavior (removes previous E0012 duplicates, adds some E0003 unresolved-name locations).
Tools & infra
baml_language/crates/tools_onionskin/src/compiler.rs, .gitignore
Adjusts scope-tree rendering to use binding.name_range and ignores *.pending-snap files.
Misc tests
various newly added/modified test files across crates
Extensive test additions and snapshot updates validating the new binding-identity semantics and scope-restore behavior across many scenarios.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 I hop through scopes with nimble feet,
IDs for names make each binding neat.
Shadows bow, restores take place,
From HIR to TIR I trace the space,
A rabbit cheers: no leaks, just grace!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Implement lexical scoping and shadowing for let bindings' accurately describes the main objective of this comprehensive PR that refactors HIR/TIR/MIR layers to implement proper lexical scoping and variable shadowing.
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 paulo/binding-and-shadowing

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.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 28, 2026

Binary size checks passed

7 passed

Artifact Platform Gzip Baseline Delta Status
bridge_cffi Linux 6.0 MB 5.7 MB +372.2 KB (+6.6%) OK
bridge_cffi-stripped Linux 6.0 MB 5.7 MB +341.8 KB (+6.0%) OK
bridge_cffi macOS 5.0 MB 4.6 MB +354.8 KB (+7.7%) OK
bridge_cffi-stripped macOS 5.0 MB 4.7 MB +284.7 KB (+6.1%) OK
bridge_cffi Windows 5.0 MB 4.6 MB +358.1 KB (+7.8%) OK
bridge_cffi-stripped Windows 5.0 MB 4.7 MB +296.4 KB (+6.4%) OK
bridge_wasm WASM 3.3 MB 3.2 MB +48.5 KB (+1.5%) OK

Generated by cargo size-gate · workflow run

Brings TIR/MIR scope handling in line with the HIR scope tree so
let-bindings and watch declarations stop leaking past their lexical
scope on every exit path (fallthrough, throw, break/continue/return,
match/catch arm fallthrough, while-body fallthrough).

- HIR: `Stmt::While` now pushes a Block scope around the body and the
  C-style `after` step, mirroring `Stmt::For` so downstream consumers
  (LSP find-refs, capture analysis, MIR `binding_locals`) anchor on
  the while-statement boundary.
- TIR: snapshot/restore scoped locals around `Stmt::While`; route
  match-arm and catch-arm bindings (including the catch-clause
  stack-trace binding) through `declare_scoped_local` so their
  PatId-keyed lifetime is tracked. Also type-check the C-style for
  `after` step (previously skipped, surfaced in two LSP fixtures).
- MIR: consolidate every unwatch emission through
  `emit_unwatch_to_depth` (renamed from `emit_unwatch_since`) and add
  the call before `Stmt::Throw`, on match-arm and catch-arm body
  fallthrough, so `watch let` declared inside an arm or before a throw
  no longer leaks its watcher.
- Replace the name-keyed `scoped_local_assignments` tracker with
  `Vec<ScopedAssignment { name, pattern: Option<PatId> }>` so
  scope-restore can distinguish inner-shadow assignments (drop, rule
  3) from outer-binding assignments (propagate, rule 2). Drops the
  dead `preserve_assignments=false` branch that fell out as a result.
- Funnel every `self.locals.insert` through a named helper
  (`add_local`, `narrow_local`, `seed_capture_unknown`) so the
  locals-population paths are uniform; route lambda-param seeding
  through `add_local`.
- Polish: DRY four inline `LocalBinding` pushes through
  `register_local_pattern`; replace `WalkContext` enum with
  `push_block_scope: bool`; replace inline lambda-scope linear search
  with `index.lambda_scope_for(span)`; align
  `type_info.rs::find_binding_ty_in_scopes` with the ancestor-scoped
  pattern; collapse `local_binding_name` /
  `match_or_catch_binding_name` into one helper; drop redundant Name
  tiebreakers from capture resolution.

Tests: 6 new lexical-scoping runtime regressions (rule 1 / 2 / 3),
9 new watch-teardown tests across throw / arm fallthrough /
break / continue / return, 2 new HIR unit tests pinning the scope
tree shape and the while-body block scope. 2 LSP fixtures updated to
record now-correct unresolved-name diagnostics on the C-style for
`after` step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 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_compiler2_mir/src/lower.rs (1)

3715-3724: ⚠️ Potential issue | 🟠 Major

while after-step bindings outlive the iteration.

HIR/TIR now treat the C-style after step as its own lexical block, but MIR lowers it directly in bb_after with no locals/watch snapshot. Any let/watch let declared there remains in self.locals / watched_locals_stack for the next iteration and after loop exit, which breaks the new shadowing rules and leaks watchers.

Suggested fix
-                if after.is_some() {
-                    self.builder.set_current_block(bb_after);
-                }
-                if let Some(after_stmt) = after {
-                    self.lower_stmt(after_stmt);
-                }
+                if let Some(after_stmt) = after {
+                    self.builder.set_current_block(bb_after);
+                    let saved_locals = self.locals.clone();
+                    let watched_depth = self.watched_locals_stack.len();
+                    self.lower_stmt(after_stmt);
+                    if !self.builder.is_current_terminated() {
+                        self.emit_unwatch_to_depth(watched_depth);
+                    }
+                    self.restore_locals_after_scope(saved_locals, watched_depth);
+                }
 
                 if !self.builder.is_current_terminated() {
                     self.builder.goto(bb_cond);
                 }

Please add a focused Rust unit regression that declares a shadowing let or watch let in the after step. As per coding guidelines, **/*.rs: Prefer writing Rust unit tests over integration tests where possible; Always run cargo test --lib if you changed any Rust code.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@baml_language/crates/baml_compiler2_mir/src/lower.rs` around lines 3715 -
3724, The after-step is lowered into bb_after without entering/exiting a fresh
lexical scope, so locals and watched_locals_stack entries created by let/watch
in the after-step persist across iterations; modify the lowering around bb_after
to push a new locals scope and snapshot watched_locals_stack (e.g., save length
or push marker), then call self.builder.set_current_block(bb_after); lower the
after_stmt via self.lower_stmt(after_stmt); and finally restore/pop the locals
and watched_locals_stack snapshot before jumping back to bb_cond. Also add a
focused Rust unit regression (in the crate tests, not integration) that declares
a shadowing let and a watch let inside the while after-step to ensure those
bindings do not outlive the iteration and run cargo test --lib.
🧹 Nitpick comments (5)
baml_language/crates/baml_lsp2_actions/src/type_info_tests.rs (1)

83-83: Prefer semantic assertions over exact markdown equality here.

This assertion is brittle to formatting-only hover changes. Asserting fenced code + x: int content keeps intent while reducing churn.

Suggested assertion update
-    assert_eq!(markdown, "```baml\nx: int\n```");
+    assert!(
+        markdown.contains("```baml"),
+        "expected fenced BAML hover markdown, got:\n{markdown}"
+    );
+    assert!(
+        markdown.contains("x: int"),
+        "expected for-binding declaration hover type, got:\n{markdown}"
+    );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@baml_language/crates/baml_lsp2_actions/src/type_info_tests.rs` at line 83,
The existing exact equality assert assert_eq!(markdown, "```baml\nx: int\n```")
is brittle; replace it with semantic assertions that verify the hover contains a
BAML fenced code block and the expected content. Locate the test assertion line
(the assert_eq!(markdown, ...) in type_info_tests.rs), and change to two
assertions using assert!(markdown.contains("```baml"), ...) and
assert!(markdown.contains("x: int"), ...) with helpful failure messages to
reduce churn from formatting-only changes.
baml_language/crates/baml_lsp2_actions/src/completions_tests.rs (1)

470-496: Strengthen these shadowing assertions to avoid label-dedup false positives.

Both tests currently pass if there is only one "x" item, even when it resolves to the wrong binding. Consider asserting the selected item's detail as well, with distinct shadowed types.

Suggested test hardening
 fn test_value_completion_hides_shadowed_same_scope_local() {
     let test = CursorTest::new(
         r#"
 function Test() -> int {
-    let x = 1
+    let x = "outer"
     let x = 2
     x<[CURSOR]
 }
 "#,
     );

     let completions = completions_at(&test.db, test.cursor.file, test.cursor.offset);
-    let x_count = completions.iter().filter(|c| c.label == "x").count();
+    let x_items: Vec<_> = completions.iter().filter(|c| c.label == "x").collect();

-    assert_eq!(
-        x_count, 1,
+    assert_eq!(
+        x_items.len(), 1,
         "Should only complete the innermost visible 'x', got: {completions:?}"
     );
+    let detail = x_items[0].detail.as_deref().unwrap_or("");
+    assert!(
+        detail.contains("int"),
+        "Expected innermost local `x: int`, got detail: {detail}"
+    );
 }

 #[test]
 fn test_value_completion_hides_shadowed_parameter() {
     let test = CursorTest::new(
         r#"
-function Test(x: int) -> int {
+function Test(x: string) -> int {
     let x = 2
     x<[CURSOR]
 }
 "#,
     );

     let completions = completions_at(&test.db, test.cursor.file, test.cursor.offset);
-    let x_count = completions.iter().filter(|c| c.label == "x").count();
+    let x_items: Vec<_> = completions.iter().filter(|c| c.label == "x").collect();

-    assert_eq!(
-        x_count, 1,
+    assert_eq!(
+        x_items.len(), 1,
         "Should only complete the local that shadows parameter 'x', got: {completions:?}"
     );
+    let detail = x_items[0].detail.as_deref().unwrap_or("");
+    assert!(
+        detail.contains("int"),
+        "Expected shadowing local `x: int`, got detail: {detail}"
+    );
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@baml_language/crates/baml_lsp2_actions/src/completions_tests.rs` around lines
470 - 496, The current tests only assert there is one completion with label "x"
which can hide incorrect binding matches; update the tests that call
completions_at(...) and compute x_count to also locate the matching completion
item (e.g., completions.iter().find(|c| c.label == "x")) and assert its detail
(or another distinguishing field) equals the expected signature/type for the
innermost local vs the parameter (for the
test_value_completion_hides_shadowed_parameter and the adjacent shadowing test),
so you verify both uniqueness and that the resolved binding is the correct one.
baml_language/crates/baml_lsp2_actions/src/usages_at_tests.rs (1)

242-250: Strengthen this shadowing test with exact-location assertions.

assert_eq!(usages.len(), 2) is a good guard, but it can still pass with the wrong two references. Consider asserting the exact formatted locations (or names + spans) to lock in “outer x only” behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@baml_language/crates/baml_lsp2_actions/src/usages_at_tests.rs` around lines
242 - 250, The test currently only checks usages.len() == 2 which can pass even
if the wrong locations are returned; update the assertion to compare the exact
expected formatted locations (or names and spans) using
test.format_location_with_name(usages[i]) to lock the behavior to the outer `x`
only. Replace or add after the length check an equality assertion like
assert_eq!(usages.iter().map(|l|
test.format_location_with_name(l)).collect::<Vec<_>>(), vec![expected_loc1,
expected_loc2]) (or assert the pair of names+spans) using the same helpers so
the test fails if any other references are returned. Ensure you reference the
usages variable and test.format_location_with_name to generate the expected
strings.
baml_language/crates/baml_tests/tests/arrays.rs (1)

233-260: Strengthen this regression by shadowing with the same identifier.

This currently uses catch (e), so it doesn't directly guard same-name misresolution. Consider catch (x) to pin the exact shadowing case.

♻️ Suggested tweak
-            }) catch (e) {
+            }) catch (x) {
                 _ => [0]
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@baml_language/crates/baml_tests/tests/arrays.rs` around lines 233 - 260,
Update the test array_map_callback_in_catch_base_keeps_parameter_scope to use a
catch clause that shadows the map callback parameter by changing the catch
pattern from (e) to (x) so the test exercises same-name shadowing of the
callback parameter; keep the rest of the test (function main, items.map
callback, and the assert on output.result expecting [1,12,3]) unchanged so the
regression still verifies correct resolution when a later catch introduces an
identically named binding.
baml_language/crates/baml_lsp2_actions/src/describe.rs (1)

450-469: Code duplication with inference_owner_scope in inference.rs.

This body_owner_scope helper is nearly identical to inference_owner_scope in baml_compiler2_tir/src/inference.rs (lines 37-54). Consider extracting this logic to a shared location (e.g., a method on FileSemanticIndex or a utility in semantic_index.rs) to avoid divergence during future maintenance.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@baml_language/crates/baml_lsp2_actions/src/describe.rs` around lines 450 -
469, Duplicate scope-walking logic exists in body_owner_scope and
inference_owner_scope; extract into a single shared helper on FileSemanticIndex
(or a new utility in semantic_index.rs). Create a method like
FileSemanticIndex::nearest_body_owner(&self, start: FileScopeId) -> FileScopeId
implementing the loop that checks scope.kind for ScopeKind::Function |
ScopeKind::Let | ScopeKind::Lambda and walks parent links, then replace
body_owner_scope and inference_owner_scope calls with that new method; ensure
signature uses FileScopeId and references the same ScopeKind variants so
behavior remains identical.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@baml_language/crates/baml_compiler2_mir/src/lower.rs`:
- Around line 4629-4637: The switch-lowering arm path currently only
snapshots/restores self.locals and therefore fails to unwind "watch let"
watchers when an arm falls through to the join; update the arm-lowering in
try_lower_as_switch (the block that does self.bind_pattern(...) /
self.lower_expr(...) / self.restore_active_locals(...)) to also snapshot the
active watcher depth/state before binding the pattern and after lowering the
body either restore that watcher state by calling
emit_unwatch_to_depth(saved_depth) (or equivalent) when the arm falls through
(i.e., when !self.builder.is_current_terminated()), and ensure the full restore
happens alongside restore_active_locals(saved_locals); also add a focused Rust
unit test that creates a match/catch arm containing a watch let and asserts the
watcher is unwound when the arm is switch-lowered (run cargo test --lib).

In `@baml_language/crates/baml_compiler2_tir/src/builder.rs`:
- Around line 6089-6100: The current seeding in infer_lambda_body uses
self.add_local(name.clone(), ty.clone()) which preserves an outer declared_types
entry via entry().or_insert() and therefore fails when a lambda param shadows an
annotated outer local; change the seeding to directly overwrite the relevant
maps instead of calling add_local: for each (name, ty) in param_tys when
Some(name) assign/insert into self.declared_types and self.locals (replacing any
existing entry) and remove from self.let_binding_patterns, so the lambda
parameter's declared type and local binding replace any outer declaration and
shadowing works correctly.

---

Outside diff comments:
In `@baml_language/crates/baml_compiler2_mir/src/lower.rs`:
- Around line 3715-3724: The after-step is lowered into bb_after without
entering/exiting a fresh lexical scope, so locals and watched_locals_stack
entries created by let/watch in the after-step persist across iterations; modify
the lowering around bb_after to push a new locals scope and snapshot
watched_locals_stack (e.g., save length or push marker), then call
self.builder.set_current_block(bb_after); lower the after_stmt via
self.lower_stmt(after_stmt); and finally restore/pop the locals and
watched_locals_stack snapshot before jumping back to bb_cond. Also add a focused
Rust unit regression (in the crate tests, not integration) that declares a
shadowing let and a watch let inside the while after-step to ensure those
bindings do not outlive the iteration and run cargo test --lib.

---

Nitpick comments:
In `@baml_language/crates/baml_lsp2_actions/src/completions_tests.rs`:
- Around line 470-496: The current tests only assert there is one completion
with label "x" which can hide incorrect binding matches; update the tests that
call completions_at(...) and compute x_count to also locate the matching
completion item (e.g., completions.iter().find(|c| c.label == "x")) and assert
its detail (or another distinguishing field) equals the expected signature/type
for the innermost local vs the parameter (for the
test_value_completion_hides_shadowed_parameter and the adjacent shadowing test),
so you verify both uniqueness and that the resolved binding is the correct one.

In `@baml_language/crates/baml_lsp2_actions/src/describe.rs`:
- Around line 450-469: Duplicate scope-walking logic exists in body_owner_scope
and inference_owner_scope; extract into a single shared helper on
FileSemanticIndex (or a new utility in semantic_index.rs). Create a method like
FileSemanticIndex::nearest_body_owner(&self, start: FileScopeId) -> FileScopeId
implementing the loop that checks scope.kind for ScopeKind::Function |
ScopeKind::Let | ScopeKind::Lambda and walks parent links, then replace
body_owner_scope and inference_owner_scope calls with that new method; ensure
signature uses FileScopeId and references the same ScopeKind variants so
behavior remains identical.

In `@baml_language/crates/baml_lsp2_actions/src/type_info_tests.rs`:
- Line 83: The existing exact equality assert assert_eq!(markdown, "```baml\nx:
int\n```") is brittle; replace it with semantic assertions that verify the hover
contains a BAML fenced code block and the expected content. Locate the test
assertion line (the assert_eq!(markdown, ...) in type_info_tests.rs), and change
to two assertions using assert!(markdown.contains("```baml"), ...) and
assert!(markdown.contains("x: int"), ...) with helpful failure messages to
reduce churn from formatting-only changes.

In `@baml_language/crates/baml_lsp2_actions/src/usages_at_tests.rs`:
- Around line 242-250: The test currently only checks usages.len() == 2 which
can pass even if the wrong locations are returned; update the assertion to
compare the exact expected formatted locations (or names and spans) using
test.format_location_with_name(usages[i]) to lock the behavior to the outer `x`
only. Replace or add after the length check an equality assertion like
assert_eq!(usages.iter().map(|l|
test.format_location_with_name(l)).collect::<Vec<_>>(), vec![expected_loc1,
expected_loc2]) (or assert the pair of names+spans) using the same helpers so
the test fails if any other references are returned. Ensure you reference the
usages variable and test.format_location_with_name to generate the expected
strings.

In `@baml_language/crates/baml_tests/tests/arrays.rs`:
- Around line 233-260: Update the test
array_map_callback_in_catch_base_keeps_parameter_scope to use a catch clause
that shadows the map callback parameter by changing the catch pattern from (e)
to (x) so the test exercises same-name shadowing of the callback parameter; keep
the rest of the test (function main, items.map callback, and the assert on
output.result expecting [1,12,3]) unchanged so the regression still verifies
correct resolution when a later catch introduces an identically named binding.
🪄 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: f689c349-7b41-4c86-a77f-75140c928e25

📥 Commits

Reviewing files that changed from the base of the PR and between 36ac995 and 1a0a1f8.

⛔ Files ignored due to path filters (35)
  • baml_language/crates/baml_tests/snapshots/__baml_std__/baml_tests____baml_std____04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/__baml_std__/baml_tests____baml_std____06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/__testing_std__/baml_tests____testing_std____04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/catch_all_keyword/baml_tests__catch_all_keyword__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/catch_all_keyword/baml_tests__catch_all_keyword__06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/catch_throw/baml_tests__catch_throw__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/catch_throw/baml_tests__catch_throw__06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/catch_throw_regressions/baml_tests__catch_throw_regressions__06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/closure_loop_variable/baml_tests__closure_loop_variable__03_hir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/closure_loop_variable/baml_tests__closure_loop_variable__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/closure_loop_variable/baml_tests__closure_loop_variable__04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/format_checks/baml_tests__format_checks__05_diagnostics.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/format_checks/baml_tests__format_checks__06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/generic_field_chain/baml_tests__generic_field_chain__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/lambda_field_access/baml_tests__lambda_field_access__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/lexical_scoping/baml_tests__lexical_scoping__01_lexer__lexical_scoping.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/lexical_scoping/baml_tests__lexical_scoping__02_parser__lexical_scoping.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/lexical_scoping/baml_tests__lexical_scoping__03_hir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/lexical_scoping/baml_tests__lexical_scoping__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/lexical_scoping/baml_tests__lexical_scoping__04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/lexical_scoping/baml_tests__lexical_scoping__05_diagnostics.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/lexical_scoping/baml_tests__lexical_scoping__06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/lexical_scoping/baml_tests__lexical_scoping__10_formatter__lexical_scoping.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/lexical_scoping_errors/baml_tests__lexical_scoping_errors__01_lexer__lexical_scoping_errors.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/lexical_scoping_errors/baml_tests__lexical_scoping_errors__02_parser__lexical_scoping_errors.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/lexical_scoping_errors/baml_tests__lexical_scoping_errors__03_hir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/lexical_scoping_errors/baml_tests__lexical_scoping_errors__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/lexical_scoping_errors/baml_tests__lexical_scoping_errors__04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/lexical_scoping_errors/baml_tests__lexical_scoping_errors__05_diagnostics.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/lexical_scoping_errors/baml_tests__lexical_scoping_errors__06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/lexical_scoping_errors/baml_tests__lexical_scoping_errors__10_formatter__lexical_scoping_errors.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/parser_statements/baml_tests__parser_statements__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/testset_dynamic/baml_tests__testset_dynamic__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/testset_vibes_nested/baml_tests__testset_vibes_nested__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/testset_with_setup/baml_tests__testset_with_setup__04_5_mir.snap is excluded by !**/*.snap
📒 Files selected for processing (36)
  • baml_language/crates/baml_compiler2_hir/src/builder.rs
  • baml_language/crates/baml_compiler2_hir/src/semantic_index.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/resolve.rs
  • baml_language/crates/baml_lsp2_actions/src/completions.rs
  • baml_language/crates/baml_lsp2_actions/src/completions_tests.rs
  • baml_language/crates/baml_lsp2_actions/src/describe.rs
  • baml_language/crates/baml_lsp2_actions/src/describe_tests.rs
  • baml_language/crates/baml_lsp2_actions/src/type_info.rs
  • baml_language/crates/baml_lsp2_actions/src/type_info_tests.rs
  • baml_language/crates/baml_lsp2_actions/src/usages.rs
  • baml_language/crates/baml_lsp2_actions/src/usages_at_tests.rs
  • baml_language/crates/baml_lsp2_actions_tests/test_files/syntax/expr/extra_dot.baml
  • baml_language/crates/baml_lsp2_actions_tests/test_files/syntax/headers/complex_headers_test.baml
  • baml_language/crates/baml_lsp2_actions_tests/test_files/syntax/headers/nested_if_statements.baml
  • baml_language/crates/baml_lsp2_actions_tests/test_files/syntax/loops/c_for.baml
  • baml_language/crates/baml_lsp2_actions_tests/test_files/syntax/loops/header_requires_let_negative.baml
  • baml_language/crates/baml_lsp2_actions_tests/test_files/syntax/misc/return.baml
  • baml_language/crates/baml_lsp2_actions_tests/test_files/syntax/parens.baml
  • baml_language/crates/baml_tests/projects/closure_loop_variable/demo.baml
  • baml_language/crates/baml_tests/projects/lexical_scoping/lexical_scoping.baml
  • baml_language/crates/baml_tests/projects/lexical_scoping_errors/lexical_scoping_errors.baml
  • baml_language/crates/baml_tests/src/compiler2_hir.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/phase6.rs
  • baml_language/crates/baml_tests/src/compiler2_tir/phase7.rs
  • baml_language/crates/baml_tests/tests/arrays.rs
  • baml_language/crates/baml_tests/tests/functions.rs
  • baml_language/crates/baml_tests/tests/if_else.rs
  • baml_language/crates/baml_tests/tests/lambdas.rs
  • baml_language/crates/baml_tests/tests/lexical_scoping.rs
  • baml_language/crates/baml_tests/tests/watch.rs
  • baml_language/crates/tools_onionskin/src/compiler.rs
💤 Files with no reviewable changes (4)
  • baml_language/crates/baml_lsp2_actions_tests/test_files/syntax/misc/return.baml
  • baml_language/crates/baml_lsp2_actions_tests/test_files/syntax/expr/extra_dot.baml
  • baml_language/crates/baml_lsp2_actions_tests/test_files/syntax/headers/nested_if_statements.baml
  • baml_language/crates/baml_lsp2_actions_tests/test_files/syntax/headers/complex_headers_test.baml

Comment thread baml_language/crates/baml_compiler2_mir/src/lower.rs Outdated
Comment thread baml_language/crates/baml_compiler2_tir/src/builder.rs
These are per-run scratch files insta writes when an inline snapshot
mismatches; they're meant to be resolved via `cargo insta review` and
deleted, not committed. The committed file had accumulated null
bookkeeping records from every subsequent test run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Caution

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

⚠️ Outside diff range comments (2)
baml_language/crates/baml_compiler2_tir/src/builder.rs (1)

2529-2590: ⚠️ Potential issue | 🟠 Major

Analyze sibling match/catch arms from a shared pre-arm state.

restore_scoped_locals(snapshot) keeps outer-targeting assignments immediately, so the next arm/clause starts from the previous arm’s mutated self.locals. That makes mutually exclusive alternatives order-dependent. For example, one arm establishing xs as int[] will cause a later sibling arm with xs.push("s") to be checked against the wrong baseline, and catch also loses the untouched no-throw path the same way. These outward mutations need to be joined after all alternatives, not fed arm-by-arm into subsequent siblings.

Also applies to: 2647-2796

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@baml_language/crates/baml_compiler2_tir/src/builder.rs` around lines 2529 -
2590, The loop over arms leaks outward mutations because
restore_scoped_locals(snapshot) is applied at the end of each arm, letting the
next arm observe prior arm assignments; change the flow so each arm starts from
the same pre-match snapshot: after taking snapshot = snapshot_scoped_locals()
and before processing each arm body (i.e., after
narrow_local/declare_scoped_local and after computing arm_ty), immediately
restore_scoped_locals(snapshot) to revert any outer-targeting assignments so the
next arm is unaffected, and instead accumulate per-arm mutations (a map of
PatId/Name -> Ty) and, after the entire arms loop, perform a join/merge of those
per-arm types into self.locals (implement a helper like join_scoped_locals or
merge_scoped_local_changes that computes the least-upper-bound across all arms
and applies only the common widened types). Ensure to update uses of
declare_scoped_local, narrow_local, and restore_scoped_locals to work with this
per-arm snapshot/merge approach.
baml_language/crates/baml_compiler2_mir/src/lower.rs (1)

3690-3724: ⚠️ Potential issue | 🟠 Major

while still leaks after-scope shadows and watchers.

The loop now tracks watched_depth for break/continue, but the normal after path never restores self.locals or unwinds watches before jumping back to bb_cond. A let x = ... / watch let x = ... inside the C-style after clause will stay visible on the next iteration and after loop exit, so later x lookups can resolve to the stale inner local instead of the outer binding.

♻️ Suggested fix
             let prev_loop = self.loop_context.take();
+            let saved_locals = self.locals.clone();
             let watched_depth = self.watched_locals_stack.len();
             self.loop_context = Some(LoopContext {
                 break_target: bb_exit,
                 continue_target: bb_after,
                 watched_locals_depth: watched_depth,
@@
                 if after.is_some() {
                     self.builder.set_current_block(bb_after);
                 }
                 if let Some(after_stmt) = after {
                     self.lower_stmt(after_stmt);
                 }

                 if !self.builder.is_current_terminated() {
+                    self.emit_unwatch_to_depth(watched_depth);
                     self.builder.goto(bb_cond);
                 }
+                self.restore_locals_after_scope(saved_locals, watched_depth);

                 self.loop_context = prev_loop;
                 self.builder.set_current_block(bb_exit);

Please add a focused regression for a while (... ) after ... shadowing case (and ideally watch let in after) so this path stays covered. As per coding guidelines, **/*.rs: Prefer writing Rust unit tests over integration tests where possible; Always run cargo test --lib if you changed any Rust code.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@baml_language/crates/baml_compiler2_mir/src/lower.rs` around lines 3690 -
3724, The after-clause path in lower_while (where loop_context is set using
LoopContext and watched_depth is captured) fails to restore self.locals and
unwinds watched_locals_stack before jumping back to bb_cond, causing after-scope
shadows and watches to leak; fix by saving prev_loop and
prev_locals/watched_depth, and after lowering the after-stmt (before any goto
bb_cond or on normal fallthrough) restore self.locals and pop/unwind
watched_locals_stack to the saved depth and restore self.loop_context =
prev_loop so inner let/watch bindings are removed; also add a focused unit test
exercising while ... after ... shadowing (including watch let) to prevent
regression.
🧹 Nitpick comments (1)
baml_language/crates/baml_tests/tests/watch.rs (1)

354-363: Consider adding one shadowing teardown regression (watch let with same name).

Given this PR’s lexical shadowing focus, a focused test with nested watch let x (inner shadows outer) would help ensure inner teardown never detaches the outer watcher and validate BindingId-based behavior end-to-end.

Suggested test shape
+#[tokio::test]
+async fn watch_shadowed_binding_teardown_is_scope_correct() {
+    let output = baml_test!(
+        r#"
+        function main() -> int {
+            watch let x = 0;
+            {
+                watch let x = 1;
+                x = 2;
+            }
+            x = 3;
+            x
+        }
+    "#
+    );
+
+    insta::assert_snapshot!(output.bytecode, `@r`#"
+    // Expect inner `unwatch x` at block exit and outer `unwatch x` at fn exit.
+    "#);
+    assert_eq!(output.result, Ok(BexExternalValue::Int(3)));
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@baml_language/crates/baml_tests/tests/watch.rs` around lines 354 - 363, Add a
regression test in baml_tests/tests/watch.rs near the "Watch teardown across
abnormal exits" group that exercises lexical shadowing: create nested watch
bindings like `watch let x` where the inner `watch let x` shadows the outer,
then trigger abnormal exits (break/continue/return) and assert the inner
teardown does not detach the outer watcher; ensure the test uses the same
binding name to validate BindingId-based behavior end-to-end and mirrors the
existing tests' style/assertion helpers so it integrates with the current test
harness.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@baml_language/crates/baml_compiler2_mir/src/lower.rs`:
- Around line 3690-3724: The after-clause path in lower_while (where
loop_context is set using LoopContext and watched_depth is captured) fails to
restore self.locals and unwinds watched_locals_stack before jumping back to
bb_cond, causing after-scope shadows and watches to leak; fix by saving
prev_loop and prev_locals/watched_depth, and after lowering the after-stmt
(before any goto bb_cond or on normal fallthrough) restore self.locals and
pop/unwind watched_locals_stack to the saved depth and restore self.loop_context
= prev_loop so inner let/watch bindings are removed; also add a focused unit
test exercising while ... after ... shadowing (including watch let) to prevent
regression.

In `@baml_language/crates/baml_compiler2_tir/src/builder.rs`:
- Around line 2529-2590: The loop over arms leaks outward mutations because
restore_scoped_locals(snapshot) is applied at the end of each arm, letting the
next arm observe prior arm assignments; change the flow so each arm starts from
the same pre-match snapshot: after taking snapshot = snapshot_scoped_locals()
and before processing each arm body (i.e., after
narrow_local/declare_scoped_local and after computing arm_ty), immediately
restore_scoped_locals(snapshot) to revert any outer-targeting assignments so the
next arm is unaffected, and instead accumulate per-arm mutations (a map of
PatId/Name -> Ty) and, after the entire arms loop, perform a join/merge of those
per-arm types into self.locals (implement a helper like join_scoped_locals or
merge_scoped_local_changes that computes the least-upper-bound across all arms
and applies only the common widened types). Ensure to update uses of
declare_scoped_local, narrow_local, and restore_scoped_locals to work with this
per-arm snapshot/merge approach.

---

Nitpick comments:
In `@baml_language/crates/baml_tests/tests/watch.rs`:
- Around line 354-363: Add a regression test in baml_tests/tests/watch.rs near
the "Watch teardown across abnormal exits" group that exercises lexical
shadowing: create nested watch bindings like `watch let x` where the inner
`watch let x` shadows the outer, then trigger abnormal exits
(break/continue/return) and assert the inner teardown does not detach the outer
watcher; ensure the test uses the same binding name to validate BindingId-based
behavior end-to-end and mirrors the existing tests' style/assertion helpers so
it integrates with the current test harness.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 3423d2b9-551f-45c3-8278-704c80eba26f

📥 Commits

Reviewing files that changed from the base of the PR and between 1a0a1f8 and e2d65c8.

📒 Files selected for processing (7)
  • baml_language/.gitignore
  • baml_language/crates/baml_compiler2_mir/src/lower.rs
  • baml_language/crates/baml_compiler2_tir/src/builder.rs
  • baml_language/crates/baml_lsp2_actions/src/type_info.rs
  • baml_language/crates/baml_tests/tests/lambdas.rs
  • baml_language/crates/baml_tests/tests/lexical_scoping.rs
  • baml_language/crates/baml_tests/tests/watch.rs
✅ Files skipped from review due to trivial changes (1)
  • baml_language/.gitignore
🚧 Files skipped from review as they are similar to previous changes (2)
  • baml_language/crates/baml_tests/tests/lambdas.rs
  • baml_language/crates/baml_lsp2_actions/src/type_info.rs

@rossirpaulo rossirpaulo added this pull request to the merge queue Apr 28, 2026
Merged via the queue into canary with commit 96b6edb Apr 28, 2026
64 of 67 checks passed
@rossirpaulo rossirpaulo deleted the paulo/binding-and-shadowing branch April 28, 2026 13:49
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