Skip to content

lowering: decide capture and nested-let renames by liveness - #98

Merged
simontreanor merged 1 commit into
mainfrom
fix/capture-renaming-liveness
Aug 30, 2026
Merged

lowering: decide capture and nested-let renames by liveness#98
simontreanor merged 1 commit into
mainfrom
fix/capture-renaming-liveness

Conversation

@simontreanor

Copy link
Copy Markdown
Owner

Tightens the #92/#96 renamer to a liveness rule, so a capture name reused across sequential matches keeps its plain spelling.

The rule

A capture (or a nested-block let) n is renamed to _n only when some reference to n outside it resolves, under Pyfun scoping, to a binding that is live across it: a parameter, a root-level let, a global or builtin, a binding of an enclosing frame, or an arm/nested-block binder whose extent encloses the one being decided. A reference bound by a disjoint arm or block (a sibling arm, an arm of a different match, another block's let) reads its own binding and does not count. A reference inside a nested closure always counts, since the closure can outlive its arm. A binder's own references never count for it.

Mechanism

src/lowering/captures.rs is a one-pass census that resolves every Var reference and <- target to what it reads at that point (the frame root, a registered arm/nested-block binder, or a nested scope's own slot, which is no occurrence at all) and records whether it sits in a closure. Binders are identified by the address of their AST node (binder_key), which is what the lowering holds when it decides them, and each registered binder records the binders enclosing it, so Frame::must_rename(name, key) can tell an enclosing binder from a disjoint one. A binder the census never saw gets the conservative answer. The old frame - arm - siblings subtraction, arm_occurrences and block_occurrences are gone; enter_block now just reports whether the block is nested.

Effect on emitted Python

def step(a, b):
    if isinstance(a, Ok):
        v = a._0
        ...
    else:
        why = a._0
        ...
    if isinstance(b, Ok):
        v = b._0
        ...
    else:
        why = b._0

where main emits _v, _why, _v2, _why2. Every #95 rename that protects a real binding survives (the #92 and #96 programs, nested matches reusing a name, an escaping closure, an outer binding read after both matches). No existing snapshot moved; all #95 tests pass unchanged.

Tests

e2e_a_capture_reused_across_sequential_matches_keeps_its_name, a_let_reused_across_sequential_branches_keeps_its_name, e2e_an_outer_binding_read_after_both_matches_renames_both_captures, e2e_a_nested_let_reading_the_parameter_it_shadows_is_renamed, e2e_three_sequential_error_arms_emit_the_plain_name, e2e_a_closure_escaping_an_earlier_arm_forces_the_later_capture_to_rename.

Docs: DESIGN.md §5 paragraph and the INTERNALS.md section restated for the liveness rule; ROADMAP item 10 notes the tightening.

Closes #97

…ntial reuse keeps plain names

A capture or nested-block let is renamed only when a reference outside it
resolves to a binding that is live across it: the frame root, an enclosing
frame, or an arm/block that encloses it. A reference bound by a disjoint arm
or block reads its own binding and does not count; one inside a nested
closure always does. The census resolves every reference in one walk and
identifies binders by AST node address, so case Error why: in three
sequential matches keeps its name while every #92/#96 rename survives.
@simontreanor
simontreanor merged commit 6848e84 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.

Capture renaming fires on a name reused across sequential matches, where the plain name is safe

1 participant