Skip to content

Implement enclosing snapshots#1147

Open
lionel- wants to merge 1 commit intofeature/use-def-mapsfrom
feature/enclosing
Open

Implement enclosing snapshots#1147
lionel- wants to merge 1 commit intofeature/use-def-mapsfrom
feature/enclosing

Conversation

@lionel-
Copy link
Copy Markdown
Contributor

@lionel- lionel- commented Apr 13, 2026

Branched from #1144
Part of #1141

From the module-level doc in use-def-maps.rs:

// ## Enclosing snapshots
//
// Use-def maps are per-scope, so a free variable in a nested function
// gets `{ definitions: [], may_be_unbound: true }` locally. To resolve
// it, we need the enclosing scope's bindings. Enclosing snapshots
// bridge this gap.
//
// A snapshot is registered when `add_use` detects `may_be_unbound` in
// the nested scope. The builder walks up the scope chain to find the
// ancestor where the symbol is bound and records a snapshot in that
// ancestor's `UseDefMapBuilder`. The snapshot captures what's live at
// the nested scope's definition point, then a watcher accumulates
// subsequent definitions. We take the union of all subsequent definitions
// because we can't tractably know exactly when the function is called. In the
// following example, `x`'s use sees both B and C even though from this
// particular snippet it can only see B.
//
// Providing more accurate answers in the general case would require whole
// program analysis (e.g. `f` may be passed down to other functions). For now we
// accept the over-approximation of taking the union of subsequent definitions,
// although we could improve on that for simple cases in the future (tracking
// simple calls, and falling back to over-approximation in the other cases).
//
// ```r
// x <- 0              # def A
// x <- 1              # def B (shadows A)
// f <- function() x   # snapshot initialized: {B}
// f()
// x <- 2              # watcher fires → snapshot: {B, C}
// ```
//
// Note that the snapshot is {B, C}, not {A, B, C}, because def A was already
// shadowed when `f` was first defined. The rule: what was live at the
// definition point (prior shadowing applied) plus every definition added after.
//
// When `may_be_unbound` is true due to a conditional local definition, the
// snapshot is also consulted (unlike Python, R has no name-binding rule, so
// conditional local definitions don't prevent fallthrough):
//
// ```r
// x <- 1
// f <- function(cond) {
//     if (cond) x <- 2
//     x                  # local: {x <- 2, may_be_unbound: true}
// }                      # enclosing snapshot: {x <- 1}
// ```
//
// The consumer combines both: the local bindings and the enclosing
// snapshot give the full picture of what `x` could be.
//
// For eager NSE scopes (e.g. `local()`), the snapshot will be even more
// precise: since the body executes at the call site, the snapshot is a
// point-in-time capture with no watcher, reflecting exactly the linear state.
// No union over-approximation needed.

To support this:

  • We now do a pre-scan that allows us to see the full list of defined names before recursing into nested scopes. This way we're able to see that x is bound in:

    f <- function() x
    x <- 1
  • When a use may be unbound, we register an "enclosing snapshot" in the ancestor scope that captures what's live at the definition point and accumulates subsequent definitions via watchers.

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