An embeddable, sandboxed Scheme interpreter for the BEAM, targeting the r7rs-small language minus its mutable operations.
The pipeline is Lexer → Reader → Expander → Eval. The
Schooner.Value module provides the tagged-term value model and
Schooner.Env the immutable-with-mutable-globals runtime
environment. Macro bindings live in a separate expansion-time
syntax env threaded through expansion only.
Choosing an entry point
Schooner's entry points come in pairs following the Elixir
convention: a tagged-tuple form (run/1, eval/2,3) and a bang
form (run!/1, eval!/2,3) that raises on failure. Picking
between run* and eval* is a trust-posture decision — the
naming is the opposite of what reflex suggests.
| Family | Auto-imports | Trust posture | Use for |
|---|---|---|---|
run/1/run!/1 | injects (import ...) of every shipped standard library when the script declares none | Not sandbox-safe. Every shipped primitive is in scope by default. | tests, REPL-style use, your own scripts where you control the source |
eval/2,3/eval!/2,3 | none — bindings come exclusively from the env argument and the script's own (import ...) declarations | Sandbox-safe. The embedder controls the surface. | embedding untrusted or semi-trusted scripts |
The implicit-import behaviour of run/run! is opt-in via the
implicit_imports: :all option on the 3-arity eval/3 and
eval!/3. Embedders who want the convenience without the rename
can call eval!/3 (or eval/3) with the option themselves.
Within each family, the bang form raises one of:
Schooner.Error— uncaught Scheme(raise ...)in the scriptSchooner.Eval.Error— runtime evaluation failureSchooner.Primitive.Error— primitive type / arity / domain errorSchooner.Library.NotFoundError—(import ...)of a missing librarySchooner.Lexer.Error,Schooner.Reader.Error,Schooner.Expander.Error— source-level failures
The non-bang form returns {:ok, value} on success or
{:error, exception} for any of the above. ArgumentError
raised by malformed options is not caught — that is an
embedder bug, not a script-level failure.
Embedding untrusted code
Use eval/2 (or eval!/2) and require the script to declare
exactly which libraries it needs. A script that omits
(import ...) cannot reach any primitive — even + is unbound:
iex> Schooner.eval!("(import (only (scheme base) +)) (+ 1 2)", Schooner.Env.new())
3Pair this with BEAM-level resource limits — run eval!/2 inside
a spawned process with :max_heap_size and a Task.shutdown/2
timeout so a runaway script cannot exhaust the host.
For richer sandbox composition (registering host libraries,
pre-imports, etc.), construct a Schooner.Environment via
Schooner.Environment.new/1 and pass it to eval/2.
Summary
Functions
Invoke a Scheme procedure value from Elixir. Returns
{:ok, value} on success, {:error, exception} for any
script-level failure. Use apply!/2 for the raising variant.
Read, expand, and pre-resolve source against env_struct's
registry. Returns {:ok, %Schooner.Compiled{}} on success,
{:error, exception} on any source-level failure. Use
compile!/1,2 for the raising variant.
Bang form of compile/2 — raises on source-level failure.
Read and evaluate source against env, threading top-level
definitions and any explicit (import ...) bindings into it.
Read and evaluate source against env with opts. Returns
{:ok, value} on success, {:error, exception} for any
script-level failure. Use eval!/3 for the raising variant.
Bang form of eval/2 — raises on script-level failure.
Bang form of eval/3 — raises on script-level failure.
Read and evaluate source in a fresh empty env, with every shipped
standard library implicitly imported when the script declares no
imports of its own.
Bang form of run/1 — raises on script-level failure.
Evaluate a %Schooner.Compiled{} against env_struct. Returns
{:ok, value} on success, {:error, exception} for any
script-level failure. Use run_compiled!/2 for the raising
variant.
Bang form of run_compiled/2 — raises on script-level failure.
Functions
@spec apply(Schooner.Value.t(), [Schooner.Value.t()]) :: {:ok, Schooner.Value.t()} | {:error, Exception.t()}
Invoke a Scheme procedure value from Elixir. Returns
{:ok, value} on success, {:error, exception} for any
script-level failure. Use apply!/2 for the raising variant.
proc may be any procedure value — a closure (returned by
evaluating a lambda or a define form), a primitive, or a
parameter. args is a list of Schooner.Value.t/0 arguments.
This is the host-side hook for callback patterns: pass a Scheme
procedure into a host function, capture it, and invoke it later
via apply/2.
@spec apply!(Schooner.Value.t(), [Schooner.Value.t()]) :: Schooner.Value.t()
Bang form of apply/2 — raises on script-level failure. See
apply/2 for arguments.
@spec compile(binary()) :: {:ok, Schooner.Compiled.t()} | {:error, Exception.t()}
@spec compile(binary(), Schooner.Environment.t()) :: {:ok, Schooner.Compiled.t()} | {:error, Exception.t()}
Read, expand, and pre-resolve source against env_struct's
registry. Returns {:ok, %Schooner.Compiled{}} on success,
{:error, exception} on any source-level failure. Use
compile!/1,2 for the raising variant.
The compiled artifact can be passed to run_compiled/2
repeatedly against any compatible environment. Macros are
expanded at compile time; variable bindings from (import ...)
declarations are pre-resolved and baked into the artifact.
When called without an environment, defaults to a fresh
Schooner.Environment.new/0 (every shipped standard library
available).
@spec compile!(binary()) :: Schooner.Compiled.t()
@spec compile!(binary(), Schooner.Environment.t()) :: Schooner.Compiled.t()
Bang form of compile/2 — raises on source-level failure.
@spec eval(binary(), Schooner.Env.t() | Schooner.Environment.t()) :: {:ok, Schooner.Value.t()} | {:error, Exception.t()}
Read and evaluate source against env, threading top-level
definitions and any explicit (import ...) bindings into it.
Returns {:ok, value} on success, {:error, exception} for
any script-level failure. Use eval!/2 for the raising variant.
env may be either a Schooner.Env (low-level) or a
Schooner.Environment (the embedding-friendly bundle of env +
registry + syntax env produced by Schooner.Environment.new/1).
When given an Env, no implicit imports are added — bindings
come exclusively from env and the script's own (import ...)
declarations.
This is the strict path: it is the right entry point for embedding scripts whose source the host does not control. See "Choosing an entry point" in the moduledoc.
@spec eval(binary(), Schooner.Env.t(), keyword()) :: {:ok, Schooner.Value.t()} | {:error, Exception.t()}
Read and evaluate source against env with opts. Returns
{:ok, value} on success, {:error, exception} for any
script-level failure. Use eval!/3 for the raising variant.
Options match eval!/3. The Environment overload of eval/2
does not accept options — pre-imports and library composition
are baked into the Environment at construction time.
@spec eval!(binary(), Schooner.Env.t() | Schooner.Environment.t()) :: Schooner.Value.t()
Bang form of eval/2 — raises on script-level failure.
@spec eval!(binary(), Schooner.Env.t(), keyword()) :: Schooner.Value.t()
Bang form of eval/3 — raises on script-level failure.
Options:
:implicit_imports— controls implicit imports prepended to a script that declares none of its own.:none(default) — no implicit imports. Bindings come exclusively fromenvand the script's own(import ...)declarations. Use this for untrusted input.:all— implicitly import every shipped standard library. Skipped if the script already declares any(import ...), on the principle that an explicit import means the user has opted in to a tighter surface.
@spec run(binary()) :: {:ok, Schooner.Value.t()} | {:error, Exception.t()}
Read and evaluate source in a fresh empty env, with every shipped
standard library implicitly imported when the script declares no
imports of its own.
Returns {:ok, value} on success, {:error, exception} for
any script-level failure. Use run!/1 for the raising variant.
Not for untrusted input. A script with no (import ...)
form reaches every primitive Schooner ships with. Use eval/2
for embedding scripts you do not control — see "Choosing an
entry point" in the moduledoc.
@spec run!(binary()) :: Schooner.Value.t()
Bang form of run/1 — raises on script-level failure.
@spec run_compiled(Schooner.Compiled.t(), Schooner.Environment.t()) :: {:ok, Schooner.Value.t()} | {:error, Exception.t()}
Evaluate a %Schooner.Compiled{} against env_struct. Returns
{:ok, value} on success, {:error, exception} for any
script-level failure. Use run_compiled!/2 for the raising
variant.
The compiled program's pre-resolved variable bindings are
re-applied to env_struct.env on every call, so the
(import ...) surface the program was compiled against is
always in scope regardless of env_struct's registry. Macros
are not re-expanded — the program's macro shape is frozen at
compile time.
@spec run_compiled!(Schooner.Compiled.t(), Schooner.Environment.t()) :: Schooner.Value.t()
Bang form of run_compiled/2 — raises on script-level failure.