Schooner.Env (schooner v1.0.0)

Copy Markdown View Source

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

frame()

@type frame() :: %{optional(binary()) => Schooner.Value.t()} | {:rec, reference()}

lookup_result()

@type lookup_result() ::
  {:ok, Schooner.Value.t()} | :error | {:uninitialised, binary()}

t()

@type t() :: %Schooner.Env{globals: reference(), lex: [frame()]}

Functions

define(env, name, value)

@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.

extend(env, bindings)

@spec extend(t(), [{binary(), Schooner.Value.t()}]) :: t()

Push a new lexical frame on top of env.

extend_rec(env, names)

@spec extend_rec(t(), [binary()]) :: t()

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.

lookup(env, name)

@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.

new()

@spec new() :: t()

pop(env)

@spec pop(t()) :: t()

Pop the topmost lexical frame on env.

rec_set(env, name, value)

@spec rec_set(t(), binary(), Schooner.Value.t()) :: t()

Set a binding in the topmost recursive frame on env.

release_rec(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.