Schooner (schooner v1.1.0)

Copy Markdown View Source

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.

FamilyAuto-importsTrust postureUse for
run/1/run!/1injects (import ...) of every shipped standard library when the script declares noneNot 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,3none — bindings come exclusively from the env argument and the script's own (import ...) declarationsSandbox-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:

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())
3

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

Bang form of apply/2 — raises on script-level failure. See apply/2 for arguments.

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

apply(proc, args)

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

apply!(proc, args)

Bang form of apply/2 — raises on script-level failure. See apply/2 for arguments.

compile(source)

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

compile(source, env_struct)

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

compile!(source)

@spec compile!(binary()) :: Schooner.Compiled.t()

Bang form of compile/1 — raises on source-level failure.

compile!(source, env_struct)

Bang form of compile/2 — raises on source-level failure.

eval(source, env_or_environment)

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

eval(source, env, opts)

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

eval!(source, env)

Bang form of eval/2 — raises on script-level failure.

eval!(source, env, opts)

@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 from env and 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.

run(source)

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

run!(source)

@spec run!(binary()) :: Schooner.Value.t()

Bang form of run/1 — raises on script-level failure.

run_compiled(compiled, env_struct)

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

run_compiled!(compiled, env_struct)

Bang form of run_compiled/2 — raises on script-level failure.