Schooner is a sandboxed Scheme interpreter you embed in an Elixir application. The host hands a script source to one of the entry points, gets back an Elixir term, and resource-bounds the work with the standard process tools. This guide covers a first script, the choice of entry point, and the value model that results come back in.

Hello, Schooner

{:ok, value} = Schooner.eval("(import (scheme base)) (+ 1 2)", Schooner.Env.new())
value
# => 3

The pieces:

  • "(import (scheme base)) (+ 1 2)" — the Scheme source. The (import ...) declaration brings + into scope; without it even the basics are unbound.
  • Schooner.Env.new() — a fresh environment. Top-level defines the script makes will land in this env's globals slot and persist if you re-use the env for another eval.
  • Schooner.eval/2 — read, expand, evaluate. Returns {:ok, value} on success, {:error, exception} for any script-level failure.

For the raising variant, use the bang form:

3 = Schooner.eval!("(import (scheme base)) (+ 1 2)", Schooner.Env.new())

Choosing an entry point

Schooner's eval entry points come in two flavours, each with a bang and a tagged-tuple form:

FamilyAuto-importsTrust postureUse for
Schooner.run/1 and Schooner.run!/1injects every shipped standard library when the script declares noneNot sandbox-safe. Every shipped primitive is in scope.tests, REPL-style use, your own scripts
Schooner.eval/2,3 and Schooner.eval!/2,3none — bindings come exclusively from the env and the script's own (import ...)Sandbox-safe. The embedder controls the surface.embedding scripts you do not control

Running untrusted input through the run family gives it every shipped primitive. Use eval/2 whenever the script source isn't yours.

The Schooner.Environment shortcut

Schooner.Env.new() is the low-level path. For typical embedding work — registering host libraries, controlling which standard libraries are reachable — use Schooner.Environment.new/1:

env =
  Schooner.Environment.new(
    standard_libraries: [:base, :char],   # only these are importable
    libraries: [my_log_lib],              # custom host library
    pre_imports: [["scheme", "base"], ["myapp", "log"]]  # imported without a script-side import
  )

{:ok, value} = Schooner.eval("(info \"hello\") (+ 1 2)", env)

See Host Functions for the host-library authoring side. The %Schooner.Environment{} struct is opaque — treat it as a handle, not a record to pattern-match against.

The value model

Scheme values cross the boundary as tagged Elixir terms. Schooner.eval/2 returns whatever the script's last form produced, in this representation:

Scheme valueElixir representation
#t / #ftrue / false
() (empty list)[]
(a . b) (pair)[a | b]
42 (exact integer)42
1.5 (inexact real)1.5
1/2 (exact rational){:rational, 1, 2}
+inf.0 / -inf.0 / +nan.0{:float_special, :pos_inf | :neg_inf | :nan}
1+2i (complex){:complex, 1, 2}
#\a (character){:char, ?a}
"hello" (string)"hello"
'foo (symbol){:sym, "foo"}
#(1 2 3) (vector){:vector, {1, 2, 3}}
#u8(0 1 255) (bytevector){:bytevector, <<0, 1, 255>>}
Procedure / closure{:closure, ...} or {:primitive, name, arity, fun}
Record instance{:record, type_id, fields_tuple}
Promise{:promise, :lazy, thunk} or {:promise, :forced, value}
Parameter object{:parameter, id, init, converter}
Error object{:error_obj, kind, message, irritants}
EOF:eof
Unspecified:unspecified
Foreign (host opaque){:foreign, term}

Integers, floats, strings, lists, and booleans use Elixir's native representation. Tags appear where Scheme distinguishes types that the BEAM does not: symbols from strings (both binaries), characters from integers, and vectors from records and closures (all tuples).

Don't pattern-match Schooner.Value shapes directly in host code. Use the conversion helpers in Schooner.Host — see Host Functions — so that a future change to the representation does not break your host code.

Calling Scheme procedures from Elixir

A script can return a procedure (a closure or a primitive); the host stashes it and invokes it later via Schooner.apply/2:

env = Schooner.Environment.new()
{:ok, double} = Schooner.eval("(import (scheme base)) (lambda (x) (* x 2))", env)
{:ok, 42} = Schooner.apply(double, [21])

The same call invokes callbacks that a script passes to a host function; see Host Functions.

Compile once, run many times

For scripts you'll evaluate repeatedly, Schooner.compile/2 does the reading, macro expansion, and analysis once:

env = Schooner.Environment.new()
{:ok, compiled} = Schooner.compile("(import (scheme base)) (+ 1 2)", env)

{:ok, 3} = Schooner.run_compiled(compiled, env)
{:ok, 3} = Schooner.run_compiled(compiled, env)

The compiled artifact is opaque (%Schooner.Compiled{}) — its internals belong to the evaluator. You can run it against any %Schooner.Environment{} whose macro environment is compatible with the one passed to compile. Variable bindings from the script's (import ...) declarations are resolved at compile time and stored in the artifact, so they are in scope at run time whatever the runtime environment's registry contains.

Error handling

The bare-name entry points return {:ok, _} | {:error, exception}. The exception families that surface as {:error, _}:

ArgumentError raised by malformed options is not caught — that's an embedder bug, not a script-level failure, and silencing it would hide configuration mistakes.

The bang forms raise the same exceptions directly.

Next steps