mbrock/wisp

Lisp in WebAssembly

JavaScript

306

463 commits

updated Sep 5, 2026

See the code

README

#+bibliography: bibliography.bib

#+BEGIN_EXAMPLE
         █     █░ ██▓  ██████  ██▓███
        ▓█░ █ ░█░▓██▒▒██    ▒ ▓██░  ██▒
        ▒█░ █ ░█ ▒██▒░ ▓██▄   ▓██░ ██▓▒
        ░█░ █ ░█ ░██░  ▒   ██▒▒██▄█▓▒ ▒
        ░░██▒██▓ ░██░▒██████▒▒▒██▒ ░  ░
        ░ ▓░▒ ▒  ░▓  ▒ ▒▓▒ ▒ ░▒▓▒░ ░  ░
          ▒ ░ ░   ▒ ░░ ░▒  ░ ░░▒ ░
          ░   ░   ▒ ░░  ░  ░  ░░
            ░     ░        ░
#+END_EXAMPLE

*Wisp* is a Lisp system for [[https://webassembly.org][WebAssembly]] and native execution.

It aims to offer an unusually pleasant developer experience
both while hacking and in production, though it's still /very
immature/ and far from ready for any serious use.

You can try the live browser-based IDE at [[https://wisp.town][Wisp.Town]].

Feel free to join the [[https://discord.gg/BeKtM3x687][Wisp Discord chat]] if you're interested
in following the development work, hacking on the system, or
chatting about Lisp, WebAssembly, and Zig.

Wisp has:

  - a browser-based IDE for live hacking and interactive
    debugging;

  - saving and loading of full system state images, including
    debugger state, also in browser local storage;

  - thoughtful integration with the browser environment, Node,
    Deno, and Unix in general;

  - first-class delimited continuations for advanced control
    flow;

  - interactive restartable conditions in the style of Common
    Lisp;

  - first-class stepwise execution for custom debuggers,
    schedulers, etc;

  - sandboxed execution with fine-grained capabilities via
    WebAssembly;

  - an implementation written in [[https://ziglang.org][Zig]];

  - and more!

* Building

Wisp currently requires Zig =0.16.0=.  The exact lower bound is
also recorded in =core/build.zig.zon=.

#+BEGIN_SRC sh
make
make test
#+END_SRC

* Macro expansion

=defun= pre-expands its body, including nested callbacks. =fn=
also pre-expands its body when the function is constructed, so
standalone lambdas do not repeatedly expand their macros on
every call. Quoted data, parameter lists, and function names
are not walked as expressions. =%fn= remains the low-level
constructor for an explicit function expression.

Pre-expansion shares a budget of =*macroexpand-limit*= macro
applications (256 by default) across the entire walk and any
reentrant expansion. This bounds macros that reproduce
themselves or branch recursively. On exhaustion, remaining
macro forms are left for normal runtime evaluation rather
than rejected. The limit can be dynamically bound when
calling =macroexpand-completely=; zero disables the walk.
There is no repeated fixed-point pass that restarts the budget.

Ordinary recursive helper functions used by macros still run
normally: the walker expands syntax, not function calls. The
budget does not interrupt a macro implementation that itself
never returns.

* Evaluator profiling

Run the diagnostic evaluator suite with semantic counters enabled:

#+BEGIN_SRC sh
make bench
#+END_SRC

The benchmark runner writes one JSON object per case.  A single
case and iteration count can be selected explicitly:

#+BEGIN_SRC sh
cd core
zig build bench -Doptimize=ReleaseFast -- call-16 25000
zig build bench -Doptimize=ReleaseFast -- --list
#+END_SRC

The counters cover evaluator steps, calls, argument-list scans and
reversals, lexical lookup depth and comparisons, continuation
searches, captures, and copied frames, logical heap allocations, GC
copies, and GC time.  The final bucket in the =call_arity= and
=lexical_depth= histograms means 16 or more.  Core loading,
benchmark setup, and parsing are excluded from the counters and
elapsed time.

The suite has two layers.  The =call-*= and =lookup-*=
diagnostics isolate evaluator mechanisms.  Four small
Gabriel-derived programs exercise them in recognizable Lisp
workloads:

- =tak= uses the historical =(18 12 6)= input and stresses
  non-tail calls and argument binding.
- =deriv= performs symbolic differentiation and allocates syntax
  trees.
- =diviter= and =divrec= divide a prebuilt list of 1000 =NIL=s,
  contrasting tail-recursive and non-tail-recursive traversal.

Four repository-derived cases exercise Wisp as it is used here:

- =stdlib-list= runs a map/append/reverse/remove pipeline using the
  boot core's list functions.
- =backquote= calls the boot core's actual
  =bq-completely-process= implementation on a nested unquote and
  unquote-splicing form.
- =router-hit= and =router-miss= preserve =web/http.wisp='s route
  matcher and nested continuation-prompt search, with fixed data in
  place of the Deno request and response bridge.

Each program checks its result after timing.  When no iteration
count is supplied, each case uses a workload-appropriate default;
an explicit count still overrides it:

#+BEGIN_SRC sh
cd core
zig build bench -Doptimize=ReleaseFast -- tak 1
zig build bench -Doptimize=ReleaseFast -- deriv 100
#+END_SRC

The JSON =input= field identifies the fixed program workload.
Sources are adapted from the
[[https://github.com/ecraven/r7rs-benchmarks][R7RS benchmark collection]]; the repetition count is the
harness parameter and is not part of that fixed input.

For an uncontaminated native CPU profile, compile the semantic
counters out and record a sufficiently long single case:

#+BEGIN_SRC sh
cd core
zig build bench-build \
  -Doptimize=ReleaseFast \
  -Dsemantic-profile=false
xcrun xctrace record \
  --template 'Time Profiler' \
  --output /tmp/wisp-call-16.trace \
  --launch -- \
  "$PWD/zig-out/bin/wisp-bench" call-16 5000000
#+END_SRC

The resulting trace opens in Instruments.  On Linux, the older
=etc/profile= script provides a starting point for recording with
=perf=.

** Cross-language sweep

Run the Gabriel workloads in Wisp, Python, Ruby, Tcl, JavaScript,
Wren, Chibi-Scheme, Racket, Common Lisp, and optimized C.  The
repository-derived Lisp cases run in Wisp, Racket, Chibi-Scheme, and
Common Lisp; the continuation router currently runs in Wisp and
Racket:

#+BEGIN_SRC sh
make bench-sweep
#+END_SRC

The sweep verifies results, takes five samples by default, prints
per-case rankings, a Wisp-focused comparison with the slowest other
runtime in each case, and a normalized geometric mean for runtimes
that support every selected case.  It saves raw JSONL samples outside
version control.  Unsupported cases are omitted rather than replaced
with unlike mechanisms.  Its pinned Wren and Chibi-Scheme runtimes
are built locally on first use, not installed system-wide.  See
[[file:benchmarks/cross-language/README.md][the cross-language benchmark notes]] for workload and
comparability details.

* Browser development

Start the Wisp development server and open
[[http://127.0.0.1:8765/]] in a browser:

#+BEGIN_SRC sh
./wisp-web start
#+END_SRC

Evaluate Wisp inside that live browser image from the shell:

#+BEGIN_SRC sh
./wisp-web '(+ 20 22)'
./wisp-web - < program.wisp
#+END_SRC

The result comes back as a Wisp form rendered by Wisp's own
pretty printer. Conditions remain suspended in the browser and
can be resumed from a later shell invocation:

#+BEGIN_SRC sh
./wisp-web '(oopsfail!)'
./wisp-web tickets
./wisp-web use 1 42
./wisp-web retry 1
./wisp-web abort 1
#+END_SRC

* GitHub Pages

Every push to =master= builds, tests, and publishes the browser
workbench at [[https://mbrock.github.io/wisp/]].  The workbench
uses only relative asset URLs, so the same files work at the
repository subpath and at a custom domain.

The listener's =download= button compacts the active Wisp system
and downloads its complete tape image as a =.core= file.  A
small service worker keeps the static workbench available after
it has been visited once.  Local named images can later use the
same snapshot bytes in IndexedDB without changing the Wisp tape
format.

* Arrow heap export

Export the native boot heap as one Apache Arrow IPC file:

#+BEGIN_SRC sh
./core/zig-out/bin/wisp arrow wisp.arrow
#+END_SRC

The file contains one nested heap record.  Its byte and word
arenas are Arrow arrays, and every Vat table is a list of
structs backed by the table's physical columns.  Zig presents
those existing buffers through the Arrow C Data Interface;
vendored nanoarrow 0.8.0 writes the IPC envelope.  The included
=core/arrow.sql= adds DuckDB views for the nested tables, Wisp
word decoding, strings, and symbols:

#+BEGIN_SRC sh
duckdb < core/arrow.sql
#+END_SRC

* Local Variables :noexport:
  Local Variables:
  fill-column: 62
  End:

Not written in Markdown, so it's shown here as plain text — view it formatted on GitHub.

Contributors

mbrock

459 commits

garlic0x1

3 commits

ampagent

1 commits

mbrock/wisp

Lisp in WebAssembly

JavaScript

306

463 commits

updated Sep 5, 2026

See the code

README

#+bibliography: bibliography.bib

#+BEGIN_EXAMPLE
         █     █░ ██▓  ██████  ██▓███
        ▓█░ █ ░█░▓██▒▒██    ▒ ▓██░  ██▒
        ▒█░ █ ░█ ▒██▒░ ▓██▄   ▓██░ ██▓▒
        ░█░ █ ░█ ░██░  ▒   ██▒▒██▄█▓▒ ▒
        ░░██▒██▓ ░██░▒██████▒▒▒██▒ ░  ░
        ░ ▓░▒ ▒  ░▓  ▒ ▒▓▒ ▒ ░▒▓▒░ ░  ░
          ▒ ░ ░   ▒ ░░ ░▒  ░ ░░▒ ░
          ░   ░   ▒ ░░  ░  ░  ░░
            ░     ░        ░
#+END_EXAMPLE

*Wisp* is a Lisp system for [[https://webassembly.org][WebAssembly]] and native execution.

It aims to offer an unusually pleasant developer experience
both while hacking and in production, though it's still /very
immature/ and far from ready for any serious use.

You can try the live browser-based IDE at [[https://wisp.town][Wisp.Town]].

Feel free to join the [[https://discord.gg/BeKtM3x687][Wisp Discord chat]] if you're interested
in following the development work, hacking on the system, or
chatting about Lisp, WebAssembly, and Zig.

Wisp has:

  - a browser-based IDE for live hacking and interactive
    debugging;

  - saving and loading of full system state images, including
    debugger state, also in browser local storage;

  - thoughtful integration with the browser environment, Node,
    Deno, and Unix in general;

  - first-class delimited continuations for advanced control
    flow;

  - interactive restartable conditions in the style of Common
    Lisp;

  - first-class stepwise execution for custom debuggers,
    schedulers, etc;

  - sandboxed execution with fine-grained capabilities via
    WebAssembly;

  - an implementation written in [[https://ziglang.org][Zig]];

  - and more!

* Building

Wisp currently requires Zig =0.16.0=.  The exact lower bound is
also recorded in =core/build.zig.zon=.

#+BEGIN_SRC sh
make
make test
#+END_SRC

* Macro expansion

=defun= pre-expands its body, including nested callbacks. =fn=
also pre-expands its body when the function is constructed, so
standalone lambdas do not repeatedly expand their macros on
every call. Quoted data, parameter lists, and function names
are not walked as expressions. =%fn= remains the low-level
constructor for an explicit function expression.

Pre-expansion shares a budget of =*macroexpand-limit*= macro
applications (256 by default) across the entire walk and any
reentrant expansion. This bounds macros that reproduce
themselves or branch recursively. On exhaustion, remaining
macro forms are left for normal runtime evaluation rather
than rejected. The limit can be dynamically bound when
calling =macroexpand-completely=; zero disables the walk.
There is no repeated fixed-point pass that restarts the budget.

Ordinary recursive helper functions used by macros still run
normally: the walker expands syntax, not function calls. The
budget does not interrupt a macro implementation that itself
never returns.

* Evaluator profiling

Run the diagnostic evaluator suite with semantic counters enabled:

#+BEGIN_SRC sh
make bench
#+END_SRC

The benchmark runner writes one JSON object per case.  A single
case and iteration count can be selected explicitly:

#+BEGIN_SRC sh
cd core
zig build bench -Doptimize=ReleaseFast -- call-16 25000
zig build bench -Doptimize=ReleaseFast -- --list
#+END_SRC

The counters cover evaluator steps, calls, argument-list scans and
reversals, lexical lookup depth and comparisons, continuation
searches, captures, and copied frames, logical heap allocations, GC
copies, and GC time.  The final bucket in the =call_arity= and
=lexical_depth= histograms means 16 or more.  Core loading,
benchmark setup, and parsing are excluded from the counters and
elapsed time.

The suite has two layers.  The =call-*= and =lookup-*=
diagnostics isolate evaluator mechanisms.  Four small
Gabriel-derived programs exercise them in recognizable Lisp
workloads:

- =tak= uses the historical =(18 12 6)= input and stresses
  non-tail calls and argument binding.
- =deriv= performs symbolic differentiation and allocates syntax
  trees.
- =diviter= and =divrec= divide a prebuilt list of 1000 =NIL=s,
  contrasting tail-recursive and non-tail-recursive traversal.

Four repository-derived cases exercise Wisp as it is used here:

- =stdlib-list= runs a map/append/reverse/remove pipeline using the
  boot core's list functions.
- =backquote= calls the boot core's actual
  =bq-completely-process= implementation on a nested unquote and
  unquote-splicing form.
- =router-hit= and =router-miss= preserve =web/http.wisp='s route
  matcher and nested continuation-prompt search, with fixed data in
  place of the Deno request and response bridge.

Each program checks its result after timing.  When no iteration
count is supplied, each case uses a workload-appropriate default;
an explicit count still overrides it:

#+BEGIN_SRC sh
cd core
zig build bench -Doptimize=ReleaseFast -- tak 1
zig build bench -Doptimize=ReleaseFast -- deriv 100
#+END_SRC

The JSON =input= field identifies the fixed program workload.
Sources are adapted from the
[[https://github.com/ecraven/r7rs-benchmarks][R7RS benchmark collection]]; the repetition count is the
harness parameter and is not part of that fixed input.

For an uncontaminated native CPU profile, compile the semantic
counters out and record a sufficiently long single case:

#+BEGIN_SRC sh
cd core
zig build bench-build \
  -Doptimize=ReleaseFast \
  -Dsemantic-profile=false
xcrun xctrace record \
  --template 'Time Profiler' \
  --output /tmp/wisp-call-16.trace \
  --launch -- \
  "$PWD/zig-out/bin/wisp-bench" call-16 5000000
#+END_SRC

The resulting trace opens in Instruments.  On Linux, the older
=etc/profile= script provides a starting point for recording with
=perf=.

** Cross-language sweep

Run the Gabriel workloads in Wisp, Python, Ruby, Tcl, JavaScript,
Wren, Chibi-Scheme, Racket, Common Lisp, and optimized C.  The
repository-derived Lisp cases run in Wisp, Racket, Chibi-Scheme, and
Common Lisp; the continuation router currently runs in Wisp and
Racket:

#+BEGIN_SRC sh
make bench-sweep
#+END_SRC

The sweep verifies results, takes five samples by default, prints
per-case rankings, a Wisp-focused comparison with the slowest other
runtime in each case, and a normalized geometric mean for runtimes
that support every selected case.  It saves raw JSONL samples outside
version control.  Unsupported cases are omitted rather than replaced
with unlike mechanisms.  Its pinned Wren and Chibi-Scheme runtimes
are built locally on first use, not installed system-wide.  See
[[file:benchmarks/cross-language/README.md][the cross-language benchmark notes]] for workload and
comparability details.

* Browser development

Start the Wisp development server and open
[[http://127.0.0.1:8765/]] in a browser:

#+BEGIN_SRC sh
./wisp-web start
#+END_SRC

Evaluate Wisp inside that live browser image from the shell:

#+BEGIN_SRC sh
./wisp-web '(+ 20 22)'
./wisp-web - < program.wisp
#+END_SRC

The result comes back as a Wisp form rendered by Wisp's own
pretty printer. Conditions remain suspended in the browser and
can be resumed from a later shell invocation:

#+BEGIN_SRC sh
./wisp-web '(oopsfail!)'
./wisp-web tickets
./wisp-web use 1 42
./wisp-web retry 1
./wisp-web abort 1
#+END_SRC

* GitHub Pages

Every push to =master= builds, tests, and publishes the browser
workbench at [[https://mbrock.github.io/wisp/]].  The workbench
uses only relative asset URLs, so the same files work at the
repository subpath and at a custom domain.

The listener's =download= button compacts the active Wisp system
and downloads its complete tape image as a =.core= file.  A
small service worker keeps the static workbench available after
it has been visited once.  Local named images can later use the
same snapshot bytes in IndexedDB without changing the Wisp tape
format.

* Arrow heap export

Export the native boot heap as one Apache Arrow IPC file:

#+BEGIN_SRC sh
./core/zig-out/bin/wisp arrow wisp.arrow
#+END_SRC

The file contains one nested heap record.  Its byte and word
arenas are Arrow arrays, and every Vat table is a list of
structs backed by the table's physical columns.  Zig presents
those existing buffers through the Arrow C Data Interface;
vendored nanoarrow 0.8.0 writes the IPC envelope.  The included
=core/arrow.sql= adds DuckDB views for the nested tables, Wisp
word decoding, strings, and symbols:

#+BEGIN_SRC sh
duckdb < core/arrow.sql
#+END_SRC

* Local Variables :noexport:
  Local Variables:
  fill-column: 62
  End:

Not written in Markdown, so it's shown here as plain text — view it formatted on GitHub.

Contributors

mbrock

459 commits

garlic0x1

3 commits

ampagent

1 commits

Languages

JavaScript

68.3%

Zig

16.4%

wisp

7.7%

Common Lisp

1.7%

Python

1.4%