GitHub - kaappi/kaappi: Kaappi: A Scheme Programming Language Implementation Written in Zig

16 min read Original article ↗

Kaappi

A complete R7RS-small Scheme implementation, written in Zig.

CI Latest release Coverage MIT license Buy Me A Coffee

Website · Playground · Tour · Guide · Download


Kaappi implements every identifier from R7RS Appendix A — 692 built-in procedures, 32 syntax forms, and all 16 standard libraries — plus 178 SRFIs, a C FFI, OS threads and fibers, an LLVM native-code backend, a package manager, and a stepping debugger. The runtime is a register-based bytecode VM with generational garbage collection and stack-copying first-class continuations.

The name is Malayalam and Tamil for coffee — see the FAQ for the story.

Note: Kaappi was built with the assistance of AI (Claude by Anthropic).

Try it

No install needed — run Scheme in your browser at the playground, or take the guided 12-lesson tour.

Installation

Install script (macOS, Linux, FreeBSD, OpenBSD, NetBSD)

curl -fsSL https://kaappi-lang.org/install.sh | bash

This installs kaappi and thottam (the package manager) to ~/.local/bin/ and the standard libraries to ~/.kaappi/lib/, verifying SHA256 checksums along the way. On the BSDs the script works from the base system alone — when neither curl nor wget is installed it falls back to the base fetch (FreeBSD) or ftp (OpenBSD, NetBSD) for downloads and sha256 for verification.

Prebuilt binaries for every platform are on the releases page. macOS binaries are Developer ID signed and notarized; all releases ship SHA256SUMS with a GPG signature (SHA256SUMS.asc, key at keybase.io/baijum). See the download page for manual install and verification steps.

Build from source

Requires Zig 0.16+ and a C toolchain (for the vendored isocline library):

git clone https://github.com/kaappi/kaappi.git
cd kaappi
zig build                            # → zig-out/bin/kaappi
zig build run                        # launch the REPL
zig build run -- program.scm         # run a Scheme file
zig build test                       # run the unit tests

Supported platforms

OS Architecture Build Tests Native compilation
macOS aarch64 (Apple Silicon) yes yes LLVM backend
Linux x86_64 yes yes LLVM backend
Linux aarch64 yes yes LLVM backend
Linux riscv64 yes yes interpreter only
Linux s390x (big-endian) yes yes interpreter only
Linux ppc64le yes yes interpreter only
Windows aarch64 (ARM64), x86_64 yes yes LLVM backend (needs a C toolchain)
FreeBSD x86_64, aarch64 yes yes LLVM backend (base cc suffices)
OpenBSD x86_64, aarch64 yes yes LLVM backend (base cc suffices)
NetBSD x86_64, aarch64 yes yes LLVM backend (needs pkgsrc clang; base cc is GCC)
WebAssembly wasm32-wasi yes interpreter only

The WASM build (zig build wasm) runs in browsers and WASI runtimes — it powers the playground.

Every non-macOS target cross-compiles with zig build -Dtarget=<arch>-<os>. Per-platform notes, each linking to the full port document:

  • Windows — the complete interpreter: REPL (plain line editing, no history or completion), fibers, channels, OS threads, FFI (LoadLibrary), and the kaappi test runner. thottam installs packages too (with Git for Windows on PATH); only manifests with a build: command are refused, since the C-FFI packages' Makefiles target POSIX. fd readiness covers sockets (event-driven, WSAEventSelect) and pipes (polled) — file ports keep blocking reads, while timers and cross-thread wakeups always work. The POSIX-only slice of SRFI-170 (uid/gid, symlinks, chmod/umask, user/group info) raises a catchable file error. Windows builds expose the windows cond-expand feature identifier instead of posix.
  • FreeBSD — full POSIX with no degradations: kqueue-backed fiber I/O, OS threads, complete SRFI-170, the full isocline REPL, and thottam with build: support. kaappi compile links native binaries with the base system's cc — no extra toolchain needed.
  • OpenBSD — the same full-POSIX kqueue platform, with two automatic accommodations for OpenBSD's hardening: each binary is marked PT_OPENBSD_NOBTCFI at build time to opt out of BTCFI enforcement (Zig 0.16 emits no BTI landing pads), and the interpreter raises its own stack limit at startup to clear OpenBSD's tight 4 MiB default.
  • NetBSD — the same feature set, verified on NetBSD 10.1. The runtime binds NetBSD's versioned libc symbols explicitly (__kevent50, __opendir30, __getpwnam50 — the plain names are old-ABI compat symbols that silently misparse modern structs) and resets the aarch64 FPCR at startup, which NetBSD boots in flush-to-zero mode that would break IEEE gradual underflow. kaappi compile needs clang from pkgsrc; NetBSD's base cc is GCC, which can't consume LLVM IR.

A taste of Kaappi

$ kaappi
kaappi> (define (fib n)
  ...     (if (< n 2) n
  ...         (+ (fib (- n 1)) (fib (- n 2)))))
kaappi> (fib 20)
6765
kaappi> (map (lambda (x) (* x x)) '(1 2 3 4 5))
(1 4 9 16 25)
kaappi> `(the answer is ,(* 6 7))
(the answer is 42)
kaappi> (string-length "héllo")
5
kaappi> (char-alphabetic? #\λ)
#t

The REPL has syntax highlighting, line editing, persistent history (~/.kaappi/history), tab completion for all built-in and user-defined symbols, and multi-line input with automatic paren balancing.

Hygienic macros

(define-syntax my-when
  (syntax-rules ()
    ((my-when test body ...)
     (if test (begin body ...)))))

(my-when #t
  (display "hello world")
  (newline))

Libraries

(define-library (mylib math)
  (export square cube)
  (import (scheme base))
  (begin
    (define (square x) (* x x))
    (define (cube x) (* x x x))))

(import (mylib math))
(cube 5) ;=> 125

First-class continuations

(define saved #f)

(+ 1 (call/cc (lambda (k)
                (set! saved k)
                10)))
;=> 11

(saved 42)
;=> 43

Features

Complete R7RS-small

  • Proper tail calls(define (loop n) (loop (+ n 1))) runs forever without growing the stack
  • First-class continuations — multi-shot call/cc via stack copying, dynamic-wind for cleanup
  • Exception handlingguard, raise, with-exception-handler, typed error objects (file-error?, read-error?)
  • Hygienic macrossyntax-rules with scope-based renaming; pattern variables, ellipsis, literals, underscore wildcards
  • Library systemdefine-library, import with only/except/rename/prefix, .sld file loading, cond-expand
  • Numeric tower — fixnum, bignum (arbitrary precision), exact rational, flonum (IEEE 754 f64), complex; automatic promotion on overflow
  • Full Unicode — UTF-8 strings indexed by codepoint, Unicode character classification and case mapping
  • Records, ports, lazy evaluation, multiple values, parameters — the whole standard, with no known functional gaps

Beyond the standard

  • 178 SRFIs — 12 built-in, 162 as portable .sld libraries, plus SRFI 261 portable library references ((srfi srfi-1), (srfi lists-1)) resolved in the importer and SRFI 226/160/211 as sub-libraries only (full list in CONFORMANCE.md)
  • Native binarieskaappi compile program.scm -o program compiles Scheme to a native executable via LLVM, with self-tail-calls compiled as loops (details)
  • Standalone bundleszig build -Dbundle-src=program.scm embeds bytecode + libraries in a single executable
  • C FFI — call shared libraries from Scheme via (kaappi ffi); 18 marshalled types, callbacks for passing Scheme procedures to C
  • Concurrency — green threads with channels via (kaappi fibers), plus real OS threads via SRFI-18
  • Stepping debugger — breakpoints (with conditions), watch expressions, step/next/step-out, frame navigation, locals — all from the REPL
  • Profilerkaappi --profile or ,profile expr: per-function self/total time, call counts, allocation bytes
  • Sandbox modekaappi --sandbox blocks FFI, file I/O, eval, load, and environment access
  • Bytecode caching — compiled .sbc files are reused when the source is unchanged
  • Machine-legible diagnostics — every error carries a stable KP code (error[KP3001]), with --diagnostics=json (LSP shape), kaappi explain <code>, and a Scheme accessor (error-object-code e) in (kaappi diagnostics) for dispatching on codes (details)
  • Capability discoverykaappi features [--json] reports this build's version, target, compiled-in subsystems, SRFIs, and limits from one source of truth (details)
  • Editor support — a bundled LSP server (kaappi-lsp) and a VS Code extension

Ecosystem

Kaappi ships thottam, a package manager for its growing library ecosystem:

# Install the web framework (auto-installs kaappi-http, kaappi-json, kaappi-net)
thottam install kaappi-web

# Now it just works — no --lib-path flags needed
kaappi app.scm
Package Description
kaappi-net TCP/TLS networking
kaappi-http HTTP/HTTPS client + server (pre-fork, threaded)
kaappi-web Web framework — routing, middleware, JSON helpers
kaappi-json JSON parser and serializer
kaappi-pg PostgreSQL client with cursors and type conversion
kaappi-redis Redis client — lists, hashes, pub/sub, pipelining
kaappi-examples REST API, task queue, CRUD app, file server

More libraries (CSV, TOML, YAML, logging, templates, testing, crypto, SQLite, email, CLI parsing) are listed in the ecosystem docs.

thottam install <pkg> resolves dependencies, supports version constraints (thottam install kaappi-net@">=0.2.0"), and installs to ~/.kaappi/lib/ where libraries are discovered automatically.

A REST API in a few lines

(import (kaappi web) (kaappi pg) (kaappi json))

(define db (pg-connect "dbname=myapp"))

(define app
  (routes
    (GET "/users/:id"
      (lambda (req params)
        (let ((rows (pg-query db "SELECT * FROM users WHERE id = $1"
                      (param/number params "id"))))
          (json-response (if (null? rows) '(("error" . "not found"))
                             (car rows))))))
    (POST "/users"
      (lambda (req params)
        (let ((body (request-json req)))
          (pg-exec db "INSERT INTO users (name) VALUES ($1)"
            (cdr (assoc "name" body)))
          (json-response '(("created" . #t)) 201))))))

(serve (wrap app wrap-json-body wrap-logging wrap-errors) 8080)

Concurrency

Green threads (fibers) for cooperative multitasking within one OS thread:

(import (kaappi fibers))

(define ch (make-channel))

(spawn (lambda ()
  (channel-send ch "hello from fiber")))

(display (channel-receive ch))  ;=> hello from fiber

Scheduling is cooperative: spawned fibers run when the main program blocks (channel-receive on an empty channel, fiber-join) or calls (yield). A fiber that blocks on an empty channel is parked and woken by the next channel-send on that channel. When the main program ends, fibers that are still parked (e.g. workers that never received a stop sentinel) are simply discarded and the process exits — like goroutines in Go. If the main program blocks on a channel that no runnable or parked-and-wakeable fiber can ever send to, channel-receive raises a deadlock error (an error object, catchable with guard); the same applies to fiber-join on a fiber that can never complete.

Real OS threads via SRFI-18 — each thread gets its own VM and GC, enabling true parallel I/O (e.g., thread-per-connection servers):

(import (srfi 18))

(define t (thread-start!
  (make-thread
    (lambda ()
      (display "running on OS thread")
      (newline)))))

(thread-join! t)

Architecture

Source → Reader → Expander → IR → Bytecode emission → VM
         (UTF-8    (syntax-   (analysis +   (register-    (generational GC,
          lexer)    rules)     optimization   based)        stack-copied
                               passes)                      continuations)
Component Role
Reader Tokenizer + recursive descent parser for the full R7RS lexical syntax, including Unicode identifiers and #\λ character literals.
Expander syntax-rules pattern matching and hygienic template instantiation.
IR Tree-structured intermediate representation (18 node types) with a tail-position analysis pass and 5 optimization passes (constant folding, dead-branch elimination, and more).
Compiler IR → register-based bytecode.
VM Bytecode interpreter with growable register file and frame stack, exception handler and dynamic-wind stacks, stack-copying continuations, and a stepping debugger.
GC Generational collector (young/old) with write barrier for old→young references.

Values are NaN-boxed 64-bit words — flonums, fixnums, booleans, characters, and nil all fit in a single u64 with zero heap allocation:

Flonum:    any f64 that is not a NaN     ← stored directly
Pointer:   0xFFFC | 48-bit pointer       ← heap object
Fixnum:    0xFFFD | 48-bit signed int    ← up to ±2^47, auto-promotes to bignum
Immediate: 0xFFFE | payload              ← nil, bool, void, eof, char

The full component map, file layout, and design notes are in docs/dev/architecture.md.

Testing

zig build test                     # Zig unit tests
bash tests/scheme/run-all.sh       # all Scheme-level suites

The Scheme suites include a 1,395-test R7RS conformance suite (via (chibi test)), plus targeted suites for compliance, continuations, macro hygiene, SRFIs, and the FFI. CI runs on every platform in the support matrix, and per-commit performance trends are tracked on the benchmark dashboard.

Documentation

Document Description
User Guide Installation, REPL, language tutorial, CLI reference
Procedure Reference Every built-in procedure, organized by domain
Cookbook Task-oriented recipes: REST APIs, JSON, CSV, SQLite, testing
Ecosystem thottam and all kaappi-* libraries
R7RS Conformance Design choices and per-SRFI coverage details
Architecture Pipeline, value representation, GC, file organization
Adding Features Step-by-step guides for extending the implementation
Testing Guide Unit tests, Scheme tests, benchmarks, CI
Developer Docs Index All contributor docs: guides, design decisions, postmortems

Known limitations

Continuations

call/cc captures continuations by copying the full VM state (registers, call frames, exception handlers, dynamic-wind stack). Cost is O(stack depth) per capture — negligible for most programs, but noticeable if continuations are captured in tight inner loops. Continuations captured in one top-level REPL expression cannot re-enter subsequent top-level expressions (standard behavior shared by Guile, Chibi, Chicken, Chez, and Racket).

SRFI 248's delimited continuations (with-unwind-handler, and the extended guard) are built on this call/cc via a sticky exception handler, with two observable caveats:

  • Single-shot — each captured delimited continuation may be resumed at most once. Every SRFI 248 idiom (coroutine generators, for-each->fold, effect handlers) resumes each k once, so this does not affect them; resuming the same k twice fails because it re-enters a native frame that has already returned.
  • Handler timing — the handler runs at the raise point rather than after unwinding to with-unwind-handler, so a handler side effect (and, since guard is built on it, a guard clause) runs before a dynamic-wind after-thunk of the guarded body, where R7RS-small runs it after. All the effects still happen; only their order differs. See CONFORMANCE.md for details.

Both are limited to SRFI 248; plain call/cc, dynamic-wind, and the built-in guard are unaffected unless you import (srfi 248).

Exceptions

A handler runs after the stack has unwound to the with-exception-handler (or guard) that installed it, rather than at the raise point, so a parameterize or dynamic-wind extent entered between the two is already gone by the time the handler is called. R7RS-small calls the handler in the dynamic environment of the raise. raise-continuable is unaffected — its handler does run in place, as specified.

guard clauses are evaluated in the guard's own dynamic environment, as R7RS 4.2.7 requires. One consequence of the above shows in the implicit re-raise: when no clause matches, raise-continuable is invoked in the guard's dynamic environment rather than the original raise's, so an outer with-exception-handler observes the guard's parameterization. Restoring the raise point's dynamic environment needs a continuation captured under the native raise frame, which cannot be resumed once that frame has returned.

This applies to the built-in guard. (srfi 248) replaces guard with its own, whose separate timing caveat is above.

Fibers

Callbacks driven by map, for-each, vector-map, vector-for-each, string-map, string-for-each, dynamic-wind, and force run in the bytecode dispatch loop, so a fiber can park inside them (e.g. block on an empty channel) and resume later. Other higher-order procedures are still native drivers — SRFI-1 (fold, filter, find, any, every, ...), hash-table-walk/hash-table-update!, assoc/member with a custom predicate, string-index, eval, ... — and a fiber that blocks on an empty channel inside one of those callbacks cannot be parked: the native call's state lives on the Zig stack and cannot be suspended. If other fibers are runnable the scheduler still makes progress, but if the blocked receive is the only thing left it raises a deadlock error instead of suspending. Move blocking channel-receive calls into plain Scheme loops (named let, do) or the bytecode-driven procedures above when a fiber must wait inside iteration.

Port I/O that would block (a socket or pipe read/write with no data or a full kernel buffer) parks the fiber on the per-thread reactor instead of blocking the OS thread, so fibers reading different connections interleave. The main fiber — or a fiber inside a native-driver callback — cannot be parked; it instead dispatches sibling fibers in place while it waits, so progress continues either way. Ports on fds other than 0/1/2 buffer output until flush-output-port, close-port, a read on the same port, the buffer filling (8 KiB), or program exit; stdin/stdout/stderr remain unbuffered.

On WASI, whether a port can park a fiber depends on the host. Ports flip to non-blocking only if fd_fdstat_set_flags(NONBLOCK) succeeds; where it does not — the playground's browser shim, for one — no fd is ever registered and the reactor falls back to timer-only waits, leaving I/O blocking and single-fiber. Timers and thread-sleep! work either way.

OS threads (SRFI-18)

Each OS thread gets its own VM and GC with an independent heap, and can allocate and collect without affecting the parent. A value reaches another thread by one of two routes, which behave differently:

  • By copy — the thunk closure at thread-start!, the result at thread-join!, and every channel message are deep-copied. Fourteen types are refused outright on this route (ports, continuations, fibers, mutexes, condition variables, and more).
  • By reference — top-level bindings are shared by pointer, so a thunk that merely names a global gets the parent's own object, uncopied. The refusal list above does not apply, and only four types (channels, thread handles, fibers, guardians) check that the caller owns them.

So threads can share mutable state, through a top-level binding — and for mutexes and condition variables that is the only supported way to share one. Doing it with ordinary data is a hazard rather than an idiom: nothing synchronizes the writes, the child collects independently, and the child heap is freed after thread-join!. Prefer channels and return values. The full per-type matrix, and which route checks what, is in docs/dev/thread-value-sharing.md.

A (kaappi fibers) channel captured by a thread's thunk (or nested inside a value sent over one) crosses safely: it is promoted to a mutex-protected, refcounted shared channel outside every GC heap, and every message crosses by copy (KEP-0002). (kaappi parallel) builds worker pools and parallel-map/ parallel-for-each on top of this — see the Concurrency guide for the higher-level API. A channel must reach the other thread through lexical capture in the thunk (or in a message sent over an already-promoted channel) — a channel reached instead through a shared top-level define is never promoted, and raises a descriptive error rather than corrupting memory (#1742 is exactly this trap). See Standards Conformance for current status.

parallel-map/parallel-for-each submit one task per list element. For very large inputs, chunking manually with make-pool/pool-submit/ task-wait (one task per processor, each covering a slice of the input with an ordinary sequential loop) reduces per-task submission overhead — see kaappi-examples/parallel-primes for a worked example.

Macros

Only syntax-rules is supported. syntax-case was intentionally excluded from R7RS-small and is not implemented.

SRFI coverage

178 SRFIs are supported. Some built-in SRFIs have minor coverage gaps (e.g., linear-update variants in SRFI-1, string-xcopy! in SRFI-13). See CONFORMANCE.md for per-SRFI details.

SRFI 261 (Portable SRFI Library Reference) is supported as an import-resolver convention: (import (srfi srfi-1)) and (import (srfi lists-1)) resolve to (srfi 1) — the trailing number is authoritative — and sub-library tails pass through ((srfi srfi-146 hash)). Literal names win when they exist, so a library actually named (srfi srfi-x) is never shadowed. cond-expand's (library …) test honors the same forms.

Contributing

Contributions are welcome — bug reports, SRFI implementations, documentation, and ecosystem libraries alike.

New here? Start with GitHub Discussions — ask questions, report bugs, propose ideas. Issues and PRs are open to org members; request an invite in Discussions when you're ready to contribute directly.

Every bug fix needs a regression test; see the testing guide.

Support This Project

If you find Kaappi useful, consider supporting its development:

Buy Me A Coffee

License

MIT