Wile's extension system lets Go code add Scheme primitives, macros, and global
values to the interpreter. Extensions are modular, composable, and — when the
R7RS library system is enabled — automatically importable from Scheme via
(import ...).
┌─────────────────────────────────────────────────────────────────┐
│ Go embedder │
│ │
│ engine, _ := wile.NewEngine(ctx, │
│ wile.WithExtension(math.Extension), │
│ wile.WithExtension(myExt), │
│ wile.WithLibraryPaths(), ← enables (import ...) │
│ ) │
└──────────────────────────────┬──────────────────────────────────┘
│
┌───────────────▼───────────────┐
│ Registry │
│ │
│ core primitives (~80) │
│ + math extension (+30) │
│ + myExt (+N) │
│ + bindings, macros, globals │
└───────────────┬───────────────┘
│ Apply()
┌───────────────▼───────────────┐
│ EnvironmentFrame │
│ │
│ Phase 0 (runtime) ← procs │
│ Phase 1 (expand) ← macros │
│ Phase 2 (compile) ← syntax │
└───────────────────────────────┘
- Registration — Extensions call
Registry.AddPrimitives,AddBindings,AddMacroSource,AddGlobalValueduringNewEngine. - Application —
Registry.Applymaterializes everything into a live environment, following a strict phase order. - Library creation — If
WithLibraryPathswas called, each extension's runtime primitives are wrapped in a syntheticCompiledLibraryand registered in theLibraryRegistry. - Evaluation — Scheme code can use extension primitives directly (they're
in the top-level environment) or import them selectively via
(import ...).
package myext
import (
"github.com/aalpar/wile/machine"
"github.com/aalpar/wile/registry"
"github.com/aalpar/wile/values"
"github.com/aalpar/wile/werr"
)
// Extension is the package's entry point.
var Extension = registry.NewExtension("myext", AddToRegistry)
// Builder composes registration functions.
var Builder = registry.NewRegistryBuilder(addPrimitives)
// AddToRegistry is the combined registration function.
var AddToRegistry = Builder.AddToRegistry
func addPrimitives(r *registry.Registry) error {
r.AddPrimitives([]registry.PrimitiveSpec{
{
Name: "double",
ParamCount: 1,
Impl: primDouble,
Doc: "Returns twice the argument.",
ParamNames: []string{"n"},
Category: "myext",
},
}, registry.PhaseRuntime)
return nil
}
func primDouble(mc *machine.MachineContext) error {
n, ok := mc.Arg(0).(values.Number)
if !ok {
return werr.WrapForeignErrorf(werr.ErrNotANumber, "double: expected number")
}
mc.SetValue(n.Add(n))
return nil
}ctx := context.Background()
engine, err := wile.NewEngine(ctx,
wile.WithExtension(myext.Extension),
)
// double is now available in the top-level environment
result, _ := engine.Eval(ctx, engine.MustParse(ctx, "(double 21)")) // 42engine, err := wile.NewEngine(ctx,
wile.WithExtensions(
math.Extension,
process.Extension,
system.Extension,
myext.Extension,
),
)Every extension implements the registry.Extension interface:
type Extension interface {
Name() string // "math", "system", etc.
AddToRegistry(r *Registry) error // Registers primitives with the registry
}For simple extensions, registry.NewExtension wraps a function:
var Extension = registry.NewExtension("myext", func(r *registry.Registry) error {
r.AddPrimitive(spec, registry.PhaseRuntime)
return nil
})Extensions can implement additional interfaces for extra behavior:
| Interface | Method | Purpose |
|---|---|---|
LibraryNamer |
LibraryName() []string |
Custom R7RS library name (default: (wile <name>)) |
Closeable |
Close() error |
Resource cleanup when Engine.Close() is called |
// Custom library name: (myorg utils) instead of (wile custom)
type myExtension struct{}
func (e *myExtension) Name() string {
return "custom"
}
func (e *myExtension) AddToRegistry(r *registry.Registry) error {
// ... register primitives ...
return nil
}
func (e *myExtension) LibraryName() []string {
return []string{"myorg", "utils"}
}
func (e *myExtension) Close() error {
// cleanup
return nil
}The registry.Registry is the central store for all registration data.
Extensions interact with it during the registration phase.
// Single primitive
r.AddPrimitive(registry.PrimitiveSpec{
Name: "sqrt",
ParamCount: 1,
Impl: primSqrt,
Doc: "Returns the square root of z.",
ParamNames: []string{"z"},
Category: "math",
}, registry.PhaseRuntime)
// Batch registration
r.AddPrimitives([]registry.PrimitiveSpec{
{Name: "sin", ParamCount: 1, Impl: primSin},
{Name: "cos", ParamCount: 1, Impl: primCos},
}, registry.PhaseRuntime)| Field | Type | Required | Description |
|---|---|---|---|
Name |
string |
yes | Scheme-visible name ("sqrt", "string->number") |
ParamCount |
int |
yes | Minimum (or fixed) parameter count |
IsVariadic |
bool |
no | Accepts variable arguments beyond ParamCount |
Impl |
machine.ForeignFunction |
yes | Go implementation function |
Doc |
string |
no | One-line description |
ParamNames |
[]string |
no | Parameter names for documentation |
Category |
string |
no | Grouping category |
// Compile-time bindings (no runtime value — for auxiliary syntax like else, =>)
r.AddBinding("my-special-form")
r.AddBindings([]string{"else", "=>", "_"})
// Bootstrap macro source (Scheme code evaluated at engine startup)
r.AddMacroSource(`
(define-syntax my-when
(syntax-rules ()
((my-when test body ...)
(if test (begin body ...)))))
`)
// Global values (parameters, promises, or any Value)
r.AddGlobalValue("current-input-port", portParam)
// Initialization callbacks (run after Apply, environment is fully populated)
r.AddInitFunc(func() error {
// post-registration setup
return nil
})Registry.Apply() processes registrations in this fixed order:
1. Compile-time bindings (AddBindings)
2. Compile-only primitives (PhaseCompile without PhaseRuntime)
3. Runtime primitives (PhaseRuntime → ForeignClosure at phase 0)
4. Expand-time primitives (PhaseExpand → ForeignClosure at phase 1)
5. Global values (AddGlobalValue)
6. Init functions (AddInitFunc)
Macro sources are loaded separately by the engine after Apply().
Phases control when a primitive is available. They are bit flags that compose
with |.
| Phase | Bit | Environment | Purpose |
|---|---|---|---|
PhaseRuntime |
1 |
Top-level (phase 0) | Normal runtime evaluation |
PhaseExpand |
2 |
Expand (phase 1) | Available during macro expansion |
PhaseCompile |
4 |
Compile (phase 2) | Binding-only, no runtime value |
Most extension primitives use PhaseRuntime only. Primitives needed during
syntax-rules expansion use PhaseRuntime | PhaseExpand. Compile-time
bindings (auxiliary syntax keywords) use PhaseCompile or AddBinding.
// Available at runtime only
r.AddPrimitive(spec, registry.PhaseRuntime)
// Available at both runtime and during macro expansion
r.AddPrimitive(spec, registry.PhaseRuntime|registry.PhaseExpand)
// Compile-time binding only (e.g., auxiliary syntax)
r.AddBinding("else")Large extensions can split registration into logical groups using
RegistryBuilder:
var Builder = registry.NewRegistryBuilder(
addTranscendental, // exp, log, sin, cos, ...
addRounding, // floor, ceiling, truncate, round
addDivision, // floor/, truncate/, ...
addComplex, // make-rectangular, real-part, ...
)
var AddToRegistry = Builder.AddToRegistry
var Extension = registry.NewExtension("math", AddToRegistry)Each function receives the same *Registry and can independently register its
primitives. The builder runs them in order, stopping on the first error.
A primitive implementation has the type machine.ForeignFunction:
// machine.ForeignFunction
func(mc *machine.MachineContext) errorfunc primAdd(mc *machine.MachineContext) error {
a, ok := mc.Arg(0).(values.Number)
if !ok {
return werr.WrapForeignErrorf(werr.ErrNotANumber, "add: first argument")
}
b, ok := mc.Arg(1).(values.Number)
if !ok {
return werr.WrapForeignErrorf(werr.ErrNotANumber, "add: second argument")
}
// Number.Add panics on unknown types; the VM recovers panics
mc.SetValue(a.Add(b))
return nil
}For variadic primitives (IsVariadic: true), the last argument index holds
a proper list of the excess arguments:
// (my-sum x ...) — ParamCount: 1, IsVariadic: true
func primMySum(mc *machine.MachineContext) error {
rest := mc.Arg(0) // proper list of all arguments
// Walk the list...
}- Single value:
mc.SetValue(result) - Void:
mc.SetValue(values.Void) - Multiple values: Use
mc.SetValues()or return values through continuation-based protocols
Use project error types, not bare errors.New or fmt.Errorf:
// Type check errors
return werr.WrapForeignErrorf(werr.ErrTypeConversion,
"sqrt: expected number, got %s", v.SchemeString())
// Wrong argument count
return werr.WrapForeignErrorf(werr.ErrWrongNumberOfArguments,
"my-fn: expected 2 arguments, got %d", n)For simpler Go↔Scheme bridging, Engine.RegisterFunc uses reflection to
automatically marshal arguments and return values:
engine.RegisterFunc("double", func(x int64) int64 {
return x * 2
})
engine.RegisterFunc("greet", func(name string) string {
return "Hello, " + name
})| Go type | Scheme type |
|---|---|
int64, int |
integer |
float64 |
inexact real |
string |
string |
bool |
#t / #f |
[]byte |
bytevector |
[]T |
proper list (elements converted recursively) |
map[K]V |
hashtable (K must be string, int64, int, or bool) |
struct |
alist ((FieldName . value) ...) |
func(...) |
callback (invokes Scheme lambda via VM sub-context) |
wile.Value |
any Scheme value (pass-through) |
context.Context |
auto-forwarded (first param only, invisible to Scheme) |
error |
last return only — returned as Go error |
engine.RegisterFuncs(map[string]any{
"double": func(x int64) int64 { return x * 2 },
"greet": func(name string) string { return "Hello, " + name },
"is-even": func(x int64) bool { return x%2 == 0 },
})Note: Go map iteration order is unspecified. If multiple functions are invalid, which one fails first is non-deterministic. Functions registered before the failure remain registered.
Go functions can accept Scheme procedures as callback parameters:
engine.RegisterFunc("apply-twice", func(f func(int64) int64, x int64) int64 {
return f(f(x))
})(apply-twice (lambda (x) (* x 2)) 5) ; → 20Callbacks must be invoked synchronously during the registered function's execution. Storing a callback or calling it from another goroutine is unsafe.
If the first Go parameter is context.Context, the VM's context is forwarded
automatically. It does not count toward the Scheme parameter count:
engine.RegisterFunc("fetch", func(ctx context.Context, url string) (string, error) {
// ctx carries the VM's context (deadline, cancellation)
return httpGet(ctx, url)
})(fetch "https://example.com") ; 1 Scheme argument, not 2These are importable by external Go code:
| Package | Library Name | Primitives |
|---|---|---|
extensions/math |
(wile math) |
sqrt, exp, sin, cos, tan, asin, acos, atan, log, expt, square, floor, ceiling, truncate, round, floor/, floor-quotient, floor-remainder, truncate/, truncate-quotient, truncate-remainder, finite?, infinite?, nan?, numerator, denominator, rationalize, exact-integer-sqrt, make-rectangular, make-polar, real-part, imag-part, magnitude, angle, number->string, string->number |
extensions/system |
(wile system) |
command-line, exit, emergency-exit, get-environment-variable, get-environment-variables, current-second, current-jiffy, jiffies-per-second, features |
extensions/files |
(wile files) |
open-input-file, open-output-file, open-binary-input-file, open-binary-output-file, file-exists?, delete-file, call-with-input-file, call-with-output-file, with-input-from-file, with-output-to-file |
extensions/process |
(wile process) |
system, process-spawn, process-stdout, process-stderr, process-stdin, process-wait, process-kill, process? |
extensions/threads |
(wile threads) |
SRFI-18 threading: make-thread, thread-start!, thread-join!, thread-yield!, make-mutex, mutex-lock!, make-condition-variable, condition-variable-signal!, etc. |
extensions/gointerop |
(wile gointerop) |
Go concurrency primitives: make-channel, channel-send!, channel-receive, make-wait-group, make-rw-mutex, make-once, make-atomic, atomic-compare-and-swap!, etc. |
extensions/introspection |
(wile introspection) |
environment?, interaction-environment, environment-bound-names, environment-ref, environment-bound? |
Not importable by external code:
| Package | Purpose |
|---|---|
internal/extensions/io |
R7RS I/O: read, write, display, port operations |
internal/extensions/eval |
eval, load, library loading (couples to full compiler pipeline) |
internal/extensions/namespace |
Namespace introspection and management |
internal/extensions/all |
Records, promises, exceptions, strings, characters, and other R7RS primitives |
| Option | Description |
|---|---|
WithExtension(ext) |
Add a single extension |
WithExtensions(ext...) |
Add multiple extensions |
WithAllExtensions() |
Add all available extensions (matches CLI set) |
WithSafeExtensions() |
Add the safe extension set (io, math, introspection, records, promises, strings, characters) |
WithoutCore() |
Skip core primitives — creates a bare engine with only explicitly added extensions |
WithLibraryPaths(paths...) |
Enable R7RS library system with optional search paths |
WithRegistry(reg) |
Use a custom registry (skips core primitives) |
WithMaxCallDepth(n) |
Set maximum VM recursion depth |
WithAuthorizer(auth) |
Set fine-grained runtime authorization policy (see SANDBOXING.md) |
SafeExtensions() is also available as a function returning []EngineOption, useful when composing with other options via append.
Registryis thread-safe for concurrent registration (usessync.RWMutex).Engineis not safe for concurrent use. Each goroutine needs its own engine, or external synchronization.- SRFI-18 threads within a single engine are safe — the VM handles coordination internally.
| Convention | Example | Meaning |
|---|---|---|
Trailing ? |
pair?, finite? |
Predicate (returns boolean) |
Trailing ! |
set-car!, string-fill! |
Mutator (side effect) |
-> |
number->string |
Type conversion |
% prefix |
%make-lazy-promise |
Internal/private |
Extensions should depend only on public packages:
extensions/myext
├── github.com/aalpar/wile/registry ← Extension, Registry, PrimitiveSpec
├── github.com/aalpar/wile/machine ← MachineContext, ForeignFunction
├── github.com/aalpar/wile/values ← Value types, error types
└── github.com/aalpar/wile/registry/helpers ← Type conversion helpers
No circular dependencies between extensions. Each is independently importable.