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. Choosing
between run* and eval* is a trust decision, and the names
can mislead: eval* is the sandbox-safe family, run* the
permissive one.
| 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 |
run/run! evaluate against a fresh Schooner.Env with the
implicit_imports: :all option of eval/3 and eval!/3. To get
the same implicit imports against your own Schooner.Env, call
eval/3 or eval!/3 with that option directly.
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. Build the env
inside that process: an env can only be used by the process that
created it. See the Running Untrusted Scheme guide.
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.
Compile source against a fresh Schooner.Environment.new/0, in
which every shipped standard library is available to import. See
compile/2.
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/1 — raises on source-level failure.
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()}
Compile source against a fresh Schooner.Environment.new/0, in
which every shipped standard library is available to import. See
compile/2.
@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.
@spec compile!(binary()) :: Schooner.Compiled.t()
Bang form of compile/1 — raises on source-level failure.
@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 and the right entry point for 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 declares any(import ...)of its own: an explicit import means the script has chosen a narrower 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.