Schooner (schooner v1.0.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. Picking between run* and eval* is a trust-posture decision — the naming is the opposite of what reflex suggests.

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

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:

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.

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.

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

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

When called without an environment, defaults to a fresh Schooner.Environment.new/0 (every shipped standard library available).

compile!(source)

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

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: it is the right entry point for embedding 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 already declares any (import ...), on the principle that an explicit import means the user has opted in to a tighter 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.