Immutable-ish environment for the Scheme evaluator.
An environment is a chain of lexical frames innermost-first plus a
globals slot identified by a process-dictionary key (a fresh
reference). Lexical frames are immutable maps; new frames are
pushed by extend/2. The globals slot is mutable in the strict
sense — define/3 writes to it — so closures that captured an env
see top-level definitions added after they were created. This is
the standard letrec-style knot-tying applied to the top-level
frame: closures reference the frame by identity, not by
value-at-bind-time.
Globals are stored as a Map on the process heap, not in :ets, so
consecutive lookups of the same name return the same heap term.
This preserves erts_debug.same/2 identity for aggregates bound
at top level — (eq? x x) answers #t for vectors, pairs,
parameters, and other aggregates, matching the lexical case.
The globals slot is owned by the process that calls new/0 and is
reclaimed when that process exits, so per-execution sandboxing
falls out of the BEAM's process model.
Recursive lexical frames
letrec, letrec*, named let, and the letrec* produced by
internal-define splicing each push a recursive frame. A recursive
frame is a map of name → value backed by a process-dictionary slot
keyed by make_ref/0, so closures captured during init evaluation
see later bindings via frame identity.
Each such frame is released by release_rec/1 when the body
finishes — unless the body's return value carries a closure whose
env still names the frame. In that case the evaluator detects the
escape and leaves the slot alive holding the now-finalised
snapshot, so the escaped closure (and any closures it reaches via
rec lookups, including mutually-recursive bindings) can resolve
rec names through the slot for the rest of the process lifetime.
Slot leakage is therefore bounded to actual closure escapes — a
letrec form whose body returns a non-closure value (the dominant
case in typical Scheme code, including all named-let recursion
whose body is (tag val ...)) releases its slot as usual.
Summary
Functions
Add or replace a top-level binding. Visible to every closure that captured this env, regardless of when it was created.
Push a new lexical frame on top of env.
Push a fresh letrec-style frame whose bindings start uninitialised
and can be assigned later via rec_set/3. Closures created against
the returned env see the bindings by frame identity, so forward
references resolve once the frame has been populated. Backed by a
process-dictionary slot keyed by a fresh reference; the slot must
be released with release_rec/1 once the binding form's body has
finished evaluating.
Look up name. Walks lexical frames innermost-first, then globals.
Pop the topmost lexical frame on env.
Set a binding in the topmost recursive frame on env.
Release the topmost recursive frame on env, deleting the
process-dictionary slot that backs it. Must be paired with
extend_rec/2. Idempotent — releasing a frame whose slot was
already deleted is a no-op.
Types
@type frame() :: %{optional(binary()) => Schooner.Value.t()} | {:rec, reference()}
@type lookup_result() :: {:ok, Schooner.Value.t()} | :error | {:uninitialised, binary()}
Functions
@spec define(t(), binary(), Schooner.Value.t()) :: t()
Add or replace a top-level binding. Visible to every closure that captured this env, regardless of when it was created.
@spec extend(t(), [{binary(), Schooner.Value.t()}]) :: t()
Push a new lexical frame on top of env.
Push a fresh letrec-style frame whose bindings start uninitialised
and can be assigned later via rec_set/3. Closures created against
the returned env see the bindings by frame identity, so forward
references resolve once the frame has been populated. Backed by a
process-dictionary slot keyed by a fresh reference; the slot must
be released with release_rec/1 once the binding form's body has
finished evaluating.
@spec lookup(t(), binary()) :: lookup_result()
Look up name. Walks lexical frames innermost-first, then globals.
Returns {:uninitialised, name} when name is bound by an enclosing
recursive frame whose init expression has not yet been evaluated;
callers should treat this as a forward-reference runtime error.
@spec new() :: t()
Pop the topmost lexical frame on env.
@spec rec_set(t(), binary(), Schooner.Value.t()) :: t()
Set a binding in the topmost recursive frame on env.
@spec release_rec(t()) :: :ok
Release the topmost recursive frame on env, deleting the
process-dictionary slot that backs it. Must be paired with
extend_rec/2. Idempotent — releasing a frame whose slot was
already deleted is a no-op.