- Zig 86.2%
- Scheme 10.2%
- Python 1.7%
- Shell 1.3%
- Tree-sitter Query 0.6%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .forgejo/workflows | ||
| bench | ||
| docs | ||
| examples | ||
| scripts | ||
| src | ||
| test | ||
| .devkit.conf | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| build_native.zig | ||
| build_vm.zig | ||
| CHANGELOG.md | ||
| CLAUDE.md | ||
| context.md | ||
| lamp.svg | ||
| LICENSE | ||
| README.md | ||
| TODO.md | ||
Lamp Scheme
A Scheme implementation in Zig, built for embedding.
Two execution modes share one frontend: a register VM with an aarch64 JIT, and a native backend that compiles the VM's code ahead of time to arm64. Both pass the same suites, and a differential corpus asserts they agree byte-for-byte.
- R5RS — VM 316/316, native 314/314 (the suite's two
evaltests run only where there is an evaluator) - R7RS-small — VM 1210/1210, native 1205/1205 (a compiled program has
no
evalor environments); no known failures - Speed — on the R7RS benchmark suite the VM is faster than Guile 3.0 (it takes 0.54x Guile's time, geometric mean) and native code is level with Chez 10.4 (1.00x Chez's time); see docs/BENCHMARKS.md
Other things worth knowing: the full real numeric tower — fixnums that promote
to bignums on overflow and exact rationals, so (/ 1 3) is 1/3 and
(exact 0.75) is 3/4 — hygienic syntax-rules macros, call/cc and
dynamic-wind, proper tail calls, full Unicode (tables generated from the Unicode 16.0 database),
a generational mark-sweep GC over typed arenas, a source-level debugger built
for DAP integration, and native value types: host structs as Scheme values.
Build
Requires Zig 0.17.0 on the PATH (ZIG overrides it in the scripts).
scripts/setup-tools.sh installs the developer tools (devkit, simgrep,
zigmap). The REPL's line editor, caret, is a pinned dependency in
build.zig.zon that zig build fetches.
zig build # debug
zig build -Doptimize=fast # release
scripts/gate # the full test gate
Use
./zig-out/bin/lamp # REPL
./zig-out/bin/lamp script.scm [args] # script; args land in *args*
./zig-out/bin/lamp < script.scm # a program on stdin
./zig-out/bin/lamp --repl # the REPL even when stdin is a pipe
./zig-out/bin/lamp --dap # Debug Adapter Protocol on stdin/stdout
./zig-out/bin/lamp --no-jit ... # interpret only
./zig-out/bin/lamp --disasm file.scm # list the compiled code (--disasm=NAME: one function)
lamp runs the register VM with an aarch64 JIT (src/vm/). A script's
status is its exit (0 if it has none); an error prints error: ... to stderr
and exits 1.
> (define (fib n) (if (< n 2) n (+ (fib (- n 1)) (fib (- n 2)))))
> (map fib '(1 2 3 4 5 6 7 8 9 10))
(1 1 2 3 5 8 13 21 34 55)
To compile ahead of time, the native backend (arm64; src/native/):
zig build native -Doptimize=ReleaseFast # zig-out/bin/lamp-native, zig-out/lib/liblamp-rt.a
./zig-out/bin/lamp-native compile app.scm app.o
zig cc app.o zig-out/lib/liblamp-rt.a -o app
A compiled program has no evaluator: eval, environments and runtime load
are absent ((scheme eval) reads as missing in cond-expand; use lamp for
those). Everything else compiles, re-entrant call/cc included.
Libraries can live in files. (import (my utils)) looks for my/utils.sld
then my/utils.scm under each search root:
lamp -I lib app.scm # repeatable, highest precedence
LAMP_LIBRARY_PATH=lib:vendor lamp app.scm
An embedder adds roots with .library_paths (Session.init's options). With
no roots configured nothing is loaded from disk, so import sees only the
built-in libraries and what the source defines.
Performance
Apple M1 Pro, ecraven's R7RS benchmark suite (56 programs), identical sources, inputs and iteration counts for every implementation. Each ratio is Lamp's time divided by the peer's, so below 1.00 means Lamp is faster (0.50x: Lamp takes half the time):
| geometric mean | median | Lamp faster on | |
|---|---|---|---|
| native vs Chez 10.4 | 0.90x | 1.01x | 27 of 56 |
| native vs Guile 3.0 | 0.33x | 0.43x | 51 of 55 |
| VM vs Guile 3.0 | 0.52x | 0.70x | 41 of 55 |
| VM vs Chez 10.4 | 1.40x | 1.68x | 17 of 56 |
Native code beats Chez outright on unboxed float loops (mbrot 0.12x,
sumfp 0.21x), call/cc used as a return (ctak 0.13x), strings (string
0.23x) and buffered I/O, and loses on allocation-heavy and higher-order code
(graphs 2.9x, sboyer 2.8x, gcbench 2.6x, scheme 2.2x) and re-entrant
continuations (fibc 2.9x). The full tables and the method are in
docs/BENCHMARKS.md; single rows carry several percent of
noise.
Embedding
A host program runs Scheme through a lamp.Session: one growing program,
each evalText/evalSource a unit compiled into it (the VM, JIT on). The
whole guide is docs/EMBEDDING.md; the surface is src/embed.zig.
const lamp = @import("lamp");
const s = try lamp.Session.init(gpa, io, .{
// Sandbox: only these libraries (besides (scheme base)) can be imported;
// the rest stay hidden. Omit for all of them.
.libraries = &.{"(scheme write)"},
});
defer s.deinit();
_ = try s.evalSource("game.scm", text); // errors report game.scm:line
const tick = s.global("on-tick") orelse return; // a callback, by name
_ = try s.call(tick, &.{lamp.embed.Value.fixnum(1)});
Zig functions become procedures: plain functions with typed parameters, in
a library a session adds with its context (lamp.native):
fn hostLog(host: *Host, msg: []const u8) void {
host.out.print("[host] {s}\n", .{msg}) catch {};
}
const host_lib = lamp.native.Library(.{
.name = "(my host)",
.Context = Host,
.natives = .{.{ "host-log", hostLog }},
});
try s.addLibrary(host_lib, &host); // scripts: (import (my host))
Lamp reads each argument into its type, refusing a wrong one with a message naming the procedure and the argument, and boxes the result.
An error is error.Faulted (or ExpandFailed for a unit that does not
compile); s.errorMessage(err, &buf) says what and where, and the session
goes on. A script's (exit n) is s.exitStatus().
Native value types
A host type -- a Vec3, a colour, a handle -- is a first-class Scheme value
carrying its bytes inline, with overloadable + - * /, its own printed form
and equal?:
(define v (+ (make-vec3 1.0 2.0 3.0) (make-vec3 0.5 0.5 0.5)))
(vec3-x v) ; 1.5
It is a native.Type in the same library declaration.
examples/native-types/ shows it
(zig build native-types-interp);
examples/callback/ is a host and Scheme calling each
other (zig build callback); examples/embed/ the
sandbox. Embedding natively compiled code is not built yet; the compiled
halves of the first two examples (main.zig) wait for it.
Libraries
| Library | Provides |
|---|---|
(scheme base) |
Core language — arithmetic, lists, strings, control flow |
(scheme write) |
display, write, newline |
(scheme read) |
read |
(scheme char) |
Unicode predicates and case conversion |
(scheme inexact) |
sqrt, sin, cos, exp, log, … |
(scheme file) |
open-input-file, open-output-file, … |
(scheme time) |
current-jiffy, jiffies-per-second |
(scheme cxr) |
caar, cadr, caddr, … |
(scheme eval) |
eval, environment |
(scheme load) |
load |
(scheme lazy) |
delay, force, make-promise |
(scheme process-context) |
command-line, exit |
(scheme case-lambda) |
case-lambda |
(scheme repl) |
interaction-environment |
(scheme r5rs) |
the whole R5RS set in one import |
(lamp debug) |
disassemble, debug-break |
(lamp meta) |
library-list, library-exports, symbol-doc, symbol-signature |
Scheme source embeds as proper R7RS libraries via @embedFile and
define-library.
Docstrings
#doc attaches documentation to a definition. It is stored in the library
table at compile time, not compiled into code, and queried through (lamp meta):
(define (square x)
#doc "Return the square of x."
(* x x))
(symbol-doc '(example math) 'square) ; "Return the square of x."
Not implemented
Complex numbers ((scheme complex)) and syntax-case. A compiled program has
no eval, environments or runtime load (the interpreter has all three).
Dev tools
devkit (the release mechanism — see
.devkit.conf), simgrep
(duplicate-code search) and zigmap
(codemaps) live in .tools/. None is a dependency:
scripts/setup-tools.sh # devkit, simgrep + zigmap (Zig 0.17.0 comes from your PATH)
scripts/setup-tools.sh -u # pull first
Work happens on dev; main only advances via .tools/devkit/devkit promote,
which merges, runs scripts/gate, and rolls back on any failure.
License
MIT. See LICENSE.