Deviations from r7rs-small

Copy Markdown View Source

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)))    ;; => 1

The "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:

  1. Custom-ellipsis identifier: (syntax-rules <id> () ...) is not supported. Schooner's ellipsis is always ....
  2. define-syntax introduced by another macro template: a macro that expands to (define-syntax foo ...) won't register foo as a macro at the outer site. Top-level define-syntax only.

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/cc site, 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 original call/cc has unwound.
(import (scheme base))

;; Escape pattern — supported.
(call/cc (lambda (k) (+ 1 (k 42))))    ;; => 42

Multi-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))          ;; => 99

Primitive errors are not Scheme-catchable

Schooner distinguishes two error classes:

  1. Script-level errors — raised by (raise ...) or (error ...). These enter the Scheme exception machinery and can be caught by with-exception-handler / guard.
  2. Primitive errors — raised by built-in primitives for type / arity / domain failures. These surface to the host as Schooner.Primitive.Error and 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 host

Why: 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:

  1. Anywhere using mutation — restructure to pure transformation, parameters, or letrec-style state.
  2. Anywhere relying on multi-shot call/cc — restructure to exceptions, or wait for v2.
  3. 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.