Skip to content

lowering: rename a root-level let that shadows an enclosing-scope name read earlier in the function - #100

Merged
simontreanor merged 2 commits into
mainfrom
fix/root-let-shadowing
Aug 30, 2026
Merged

lowering: rename a root-level let that shadows an enclosing-scope name read earlier in the function#100
simontreanor merged 2 commits into
mainfrom
fix/root-let-shadowing

Conversation

@simontreanor

@simontreanor simontreanor commented Aug 30, 2026

Copy link
Copy Markdown
Owner

A root-level let in a function that reuses a name the function had already read from an enclosing scope made that name local to the whole Python def, so the earlier read raised UnboundLocalError. pyfun check passed; the program died at runtime.

let f x =
  let g o =
    let y = x + 1
    let x = 2
    x + y + o
  g 10

now emits

def f(x):
    def g(o):
        y = x + 1
        _x = 2
        return _x + y + o
    return g(10)

and prints 18.

The rule

A root-level let n is emitted as _n iff some occurrence of n in the same frame reads a binding outside the frame: an enclosing function's binding, a module-level binding, a builtin. Such a read can only sit before the let, in the let's own value, or in a closure made before it, and Python's function-wide locals rule turns the later assignment into a declaration that covers it. A read after the let resolves to the let itself; a read of a parameter or an earlier root-level let resolves to the frame's own root; neither counts, so rebinding in sequence stays exactly as Python does it. Top-level lets are globals and never rename.

Mechanism

src/lowering/captures.rs: an occurrence now records what it reads as Target::Outside, Target::Root(key) (a parameter or root-level let of this frame) or Target::Binder(key) (an arm or nested-block binder). Frame::of_body takes the parameters and binds them as roots; a root-level let, a CE binder and a top-level let bind as roots too. Frame::must_rename counts Outside and Root alike (unchanged behaviour for arm captures and nested-block lets); the new Frame::must_rename_root asks only for an Outside occurrence, and returns false in the module frame. lower_block_let now decides root-level names too, with the same ordering, targets plumbing, let mut/nonlocal mapping and destructuring hook as for nested ones.

Tests

Nine new tests in tests/compile.rs (e2e where the output proves it): the issue program, a module binding read then shadowed, let n = n + 1 over a module binding, a nested def made before the let that reads the enclosing binding, a root-level let mut with <- from a closure (nonlocal _x), a destructuring let (a, b) renaming only a; and the negatives: rebinding a parameter, a let referenced only after it, a top-level let. No existing test changed. Full suite, clippy -D warnings, fmt --check and docs/verify_lessons.py are clean.

Computation-expression binders (second commit)

The same shape one level down: a let/let! in an option/result/seq/async body is assigned in the CE's own def, so

let n = 5
let f u =
  option {
    let m = n + 1
    let! n = Some 2
    return n + m
  }

raised UnboundLocalError on m = n + 1. A CE binder is a root-level binder of the CE's frame and now takes exactly the root-level rule. bind_ce_target splits into enter_ce_binder (evict the local-function registries, decide the rename with must_rename_root, install it for the rest of the body) and bind_ce_target_as (emit the assignment under the chosen spelling, through unpack_binding's targets for a destructuring target). The let! of result/option lowers its continuation before building its ladder, so lower_short_circuit_items registers the binder first and hands the spelling to short_circuit_bind; the failure branch still returns the subject untouched. seq and async bind in order, so bind_ce_target does both steps in one. Emitted: _n = _pf_t0._0, prints Some(8).

Seven more tests: the repro, the result version with both branches (Ok(8) / Error('no')), the async version run through asyncio.run (8), a seq let ([8]), a destructuring let! (n, k) renaming only n; and the negatives: a let! read only after it, and a let! rebinding an earlier binder of the same CE.

Docs: DESIGN.md §5 paragraph and the INTERNALS.md section state the root-level rule for function bodies and CE bodies; ROADMAP.md item 10 notes #99.

Closes #99

…e read earlier in the function

A root-level let in a nested function that reuses a name the function had
already read from an enclosing scope (a parameter of the outer function, a
module-level binding, a builtin) made that name local to the whole Python
def, so the earlier read raised UnboundLocalError. The capture census now
tells a reference to a binding outside the frame from one to the frame's
own root (a parameter or an earlier root-level let), and a root-level let
renames to _name exactly when some read of the name in the frame is outside.
Rebinding a parameter or an earlier let in sequence stays as Python does it,
and top-level lets are globals and never rename.
…ts own def

A let/let! target in an option/result/seq/async body is assigned in the
CE's own Python def, so one that reuses a name the body had already read
from outside the CE made that name local to the whole def and the earlier
read raised UnboundLocalError. bind_ce_target now splits into registering
the binder (evict, decide the rename under the root-level rule, install it
for the rest of the body) and emitting the assignment, so the let! of
result/option can register before its continuation lowers; the failure
branch still returns the subject untouched.
@simontreanor
simontreanor merged commit 6ae7c5f into main Aug 30, 2026
16 checks passed
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.

A root-level let that shadows an enclosing-scope name read earlier in the same function raises UnboundLocalError

1 participant