Schooner targets r7rs-small but ships a deliberately smaller surface. This guide documents every intentional gap, with runnable examples showing the deviation and the workaround.
No mutation
Schooner has no destructive operations. All values are immutable; recursion is achieved by sharing environment-frame identity, not by post-construction mutation. The following r7rs operations are not defined:
set!set-car!,set-cdr!string-set!,string-fill!,string-copy!vector-set!,vector-fill!,vector-copy!bytevector-u8-set!,bytevector-copy!list-set!- Record-type field mutators
{:error, %Schooner.Eval.Error{}} =
Schooner.eval("(import (scheme base)) (define x 1) (set! x 2)", Schooner.Env.new())Workaround. Express state as the return value of a function,
or pass it through letrec / parameter objects:
(import (scheme base))
;; Instead of: (set! x (+ x 1))
;; Pass the new value through:
(define (step x) (+ x 1))
(step 1) ;; => 2(import (scheme base))
;; Mutable counter via parameter object
(define counter (make-parameter 0))
(define (with-incremented-counter thunk)
(parameterize ((counter (+ (counter) 1)))
(thunk)))
(with-incremented-counter (lambda () (counter))) ;; => 1The "no mutation" constraint is structural — pervasive in the implementation, not a flag that can be toggled.
Inexact reals are double-precision only
Schooner's inexact numeric tower is IEEE-754 doubles. There is
no extended-precision float, no flonum mode, no
(scheme inexact) extension beyond what r7rs requires.
(import (scheme base) (scheme inexact))
(* 0.1 0.1) ;; => 0.010000000000000002 (the IEEE-754 double answer)If you need higher precision, expose a host function backed by
Decimal or :erlang.float_to_binary/2 with explicit precision.
Special-form names cannot be lexically rebound
if, let, cond, =>, etc. dispatch on the literal symbol
before the lexical environment is consulted. A Scheme that
uses one of these names as a variable produces unexpected
results — Schooner rejects the rebinding rather than silently
shadowing.
{:error, _} =
Schooner.eval("(import (scheme base)) (define if 42) if", Schooner.Env.new())Workaround. Use a different name. Lower-priority alternative:
import the form under a renamed binding via (import (rename ...)).
Macro hygiene gaps
syntax-rules works for the standard r7rs cases, with two
documented gaps:
- Custom-ellipsis identifier:
(syntax-rules <id> () ...)is not supported. Schooner's ellipsis is always.... define-syntaxintroduced by another macro template: a macro that expands to(define-syntax foo ...)won't registerfooas a macro at the outer site. Top-leveldefine-syntaxonly.
The standard idioms — cond, case, let, when, unless,
and, or, do, letrec*, parameterize, delay,
delay-force, case-lambda — are all defined as
syntax-rules macros and behave per spec.
define-syntax is top-level only
{:error, _} =
Schooner.eval("""
(import (scheme base))
(let ()
(define-syntax twice
(syntax-rules ()
((_ e) (begin e e))))
(twice 'hi))
""", Schooner.Env.new())define-syntax works at the top level. Inside a (let () ...)
body it's rejected. Workaround: hoist the macro definition
to the top level, or use let-syntax / letrec-syntax for
locally-scoped macros.
call/cc is escape-only
Continuations captured by call-with-current-continuation /
call/cc are single-shot upward only. Invoking a captured
continuation:
- Inside its original dynamic extent → escapes to the
call/ccsite, returning the argument as the continuation's value. This is the supported case. - Outside its original dynamic extent → raises
Schooner.Eval.Error. This includes the case where a callback captures a continuation and the host invokes the callback after the originalcall/cchas unwound.
(import (scheme base))
;; Escape pattern — supported.
(call/cc (lambda (k) (+ 1 (k 42)))) ;; => 42Multi-shot continuations (amb, generators-via-call/cc,
yin-yang puzzle) and dynamic-wind re-entry semantics are
deferred to v2.0. The v1 documented-error case is
forward-compatible: lifting the restriction in v2 is
non-breaking, so v1 scripts that respect the rule will continue
to work unchanged.
Workaround for long-lived non-local exit: use (raise ...)
and with-exception-handler / guard. Exceptions cross
arbitrary frames — including host boundaries — without the
escape-only restriction.
(import (scheme base))
(guard (e ((string? e) e))
(let loop ((n 1000000))
(if (= n 0)
(raise "exit-from-deep-loop")
(loop (- n 1)))))
;; => "exit-from-deep-loop"Parameters cannot be assigned by procedural call
In r7rs, (make-parameter init) returns a procedure that, when
called with arguments, has implementation-defined behaviour for
"setting the parameter's current value". Schooner has no
mutation, so this reading is inapplicable; calling a parameter
with arguments is rejected.
(import (scheme base))
(define p (make-parameter 1))
(p) ;; => 1 — zero-arg call returns current value
;; (p 2) ;; raises — Schooner has no mutation, no parameter assignment.Workaround: use parameterize to install a new value
within a dynamic extent.
(import (scheme base))
(define p (make-parameter 1))
(parameterize ((p 99))
(p)) ;; => 99Primitive errors are not Scheme-catchable
Schooner distinguishes two error classes:
- Script-level errors — raised by
(raise ...)or(error ...). These enter the Scheme exception machinery and can be caught bywith-exception-handler/guard. - Primitive errors — raised by built-in primitives for
type / arity / domain failures. These surface to the host
as
Schooner.Primitive.Errorand are not catchable from Scheme.
(import (scheme base))
;; (car 5) is a primitive type error — not catchable.
(guard (e (#t "caught"))
(car 5))
;; => raises Schooner.Primitive.Error to the hostWhy: a sandboxed script must not be able to paper over its own type errors. Treating primitive failures as Scheme exceptions would let a malicious script silently swallow them and continue executing in an inconsistent state.
Workaround for legitimate "ask forgiveness" patterns: check the type explicitly before applying the operation, or wrap the operation in a host primitive that converts host-side failures to Scheme errors:
(import (scheme base))
(if (pair? x)
(car x)
'no-car-on-non-pair)Libraries shipped vs omitted
| Shipped (default registry) | Shipped (opt-in host library) | Omitted |
|---|---|---|
(scheme base) | (scheme time) via Schooner.Time | (scheme file) |
(scheme cxr) | (scheme load) | |
(scheme char) | (scheme repl) | |
(scheme inexact) | (scheme process-context) | |
(scheme complex) | (scheme eval) | |
(scheme case-lambda) | (scheme r5rs) | |
(scheme lazy) | ||
(scheme write) | ||
(scheme read) |
The omitted set is intentionally absent: file / process / eval are host concerns, exposed by the embedder via host functions if and only if they are appropriate for the trust context.
(scheme time) is shipped, but as a host library (Schooner.Time)
that the embedder must opt in to by passing
Schooner.Time.library() to Schooner.Environment.new/1. Wall-clock
access is the first side-effecting and non-deterministic primitive,
so keeping it out of the default registry preserves the "default
sandbox is pure" property. The same module also doubles as a worked
example of the embeddable-library pattern — see
Host Functions.
I/O is string-port-only
Schooner has no file ports, no console ports, no read-line.
The output primitives display, write, newline,
write-string are present in their string-port flavour:
they return the rendered text instead of writing to a port.
(import (scheme base) (scheme write))
(write "hello") ;; => "\"hello\"" — the rendered text, not side-effecting
(display 42) ;; => "42"For scripts that need to side-effect, the embedder exposes a
host function (info, log, print) that does the actual
writing.
What this means for migration
Code written for full r7rs implementations may need adjustments in three places:
- Anywhere using mutation — restructure to pure
transformation, parameters, or
letrec-style state. - Anywhere relying on multi-shot
call/cc— restructure to exceptions, or wait for v2. - Anywhere expecting
(scheme file)/(scheme load)— route through host functions provided by the embedder.
Everything else — the macro layer, records, exceptions, the numeric tower up through complex, the standard library procedures — runs as you'd expect.