(rontolisp) docs

Compile to WASM

Give rontolisp an output path ending in .wasm with -o, and it compiles the source to a WebAssembly binary instead of interpreting it. As with the JVM backend, the output extension selects the target, and the binary is emitted by hand without a third-party assembler:

echo '(print (+ 1 2))' > hello.lisp
rontolisp hello.lisp -o hello.wasm
wasmtime run -W gc hello.wasm
3

The WASM backend can produce several different output shapes, selected by compile flags. The next section is the map: pick your shape there, then read only that shape's section — each one is self-contained.

Choosing an Output

Two independent choices determine the shape of the output:

  • Value model. By default, values live on the WebAssembly GC heap (integers as i31ref, floats boxed in a struct), which supports the full language but requires a wasm-GC capable runtime (wasmtime 14+, Node 22+, current browsers). --no-gc instead lowers a pure-compute subset of the language onto unboxed i64/f64 scalars and linear-memory strings — the result is a plain MVP module that runs on any WebAssembly engine and is orders of magnitude smaller.
  • Packaging. By default the output is a WASI Preview 1 core module. --component wraps it as a component: on the GC path a WASI 0.3 component with full I/O over the async canonical ABI, on the --no-gc path a compact typed reactor component that runs with no host flags at all. On the Preview 1 GC path, --no-wasi instead drops the WASI imports, turning the module into a pure-compute library ("reactor") a host can instantiate with no import object.

Crossing the two axes gives the five shapes:

Output shapeFlagsLanguageRuns onPick it for
WASI command module(none)fullwasm-GC engine with WASI Preview 1 (wasmtime run -W gc)running a whole program from the command line
Library (reactor) module--no-wasifull (pure-compute exports)any wasm-GC engine, no imports needed (Node 22+, current browsers)calling Lisp functions from JavaScript
WASI 0.3 component--componentfull, plus component-only I/O (rontolisp:fetch, TCP sockets)wasmtime 46+ (with the flags below) or another component host with wasm-GCtyped component exports plus real I/O
Plain core module--no-gcnumeric/string subsetany WebAssembly engine, even without wasm-GC or SIMDtiny, dependency-free compute kernels
Compact typed component--no-gc --componentnumeric/string subsetany component host, zero flagstiny typed components

Rule of thumb: pick the value model by what the code needs — the full language means the GC heap; a numeric/string kernel that fits the subset gains universal portability and a hundreds-of-bytes binary from --no-gc — then pick the packaging by the host: a component host gets --component, a plain engine or JavaScript embedder gets a core module.

Two further flags are orthogonal to the shape, and covered in Cross-Cutting Flags at the end:

  • --optimize tree-shakes the module (a no-op on the GC --component path);
  • --simd accelerates the numeric vector kernels with native v128 instructions, on both value models.

Exporting Lisp Functions

By default a compiled module only exposes its entry point (_start). To make an individual Lisp function callable directly from a host (wasmtime --invoke, JavaScript, or another module), mark it with the rontolisp:wasm-export directive, declaring the WASM-boundary types of its parameters and result:

rontolisp fact.lisp -o fact.wasm
wasmtime run --invoke fact -W gc fact.wasm 5
120

The directive itself is the same in every output shape; what changes per shape is the host contract of the export — a raw core function on the core-module shapes, a typed component-model export under --component. On the interpreter and JVM backends the directive is a no-op (it just returns the named symbol), so the same source runs on every backend.

The type designators and their boundary representations are:

DesignatorWASM boundaryNotes
:inti3231-bit signed range (the internal i31ref)
:longi64--no-gc only; full 64-bit signed range, matching the non-GC backend's internal i64
:floatf64
:booli320 is nil, any non-zero value is t
:string(ptr, len)UTF-8 bytes in linear memory; a component-model string under --component
:s-expr(ptr, len)s-expression text (any value except a function); GC value model only

A side-effecting function can declare a void result by omitting :returns (or giving it as nil, '() or :void); the wrapper then discards the Lisp return value and has no WASM result. Likewise an omitted, nil or '() :params means no arguments.

:as renames the export — useful when the host-facing API wants a name that is not an idiomatic Lisp symbol, e.g. camelCase for JavaScript:

Limitations shared by every shape:

  • Only a top-level defun can be exported, the declared parameter count must match its arity, and functions that take or return function values are out of scope.
  • The exported name defaults to the bare Lisp name (fact) and can be renamed with :as; how arguments are written depends on the host (wasmtime --invoke fact module.wasm 5, instance.exports.fact(5), ...).

Export Modes at a Glance

The same directive compiles into four different host contracts depending on the --no-gc / --component flags:

GC core module (default / --no-wasi)GC --component--no-gc core module--no-gc --component
Host requirementswasm-GC engine (wasmtime -W gc, Node 22+, current browsers)wasmtime 46+ (-W gc=y -W component-model-more-async-builtins=y) or a component host with wasm-GC + JSPIany WebAssembly engineany component-model host, no flags
Export shaperaw core functiontyped component-model export (WAVE --invoke, jco)raw core functiontyped component-model export (WAVE --invoke, jco)
Scalars:int/:float/:bool/void:int/:float/:bool/void+ :long (i64)+ :long (s64)
:stringmanual (ptr,len) + __ronto_alloccomponent-model string (canonical ABI)manual (ptr,len) + __ronto_alloccomponent-model string (canonical ABI)
:s-exprmanual (ptr,len)component-model string (printed text)not supportednot supported
Function body may usethe full languagethe full languagethe non-GC subsetthe non-GC subset
I/O inside the exportworks (real WASI imports; traps under --no-wasi)traps in a sync export; declare :async tprint only (one fd_write import)print only (built-in WASI 0.2 stdio bridge)
Program top levelruns as _startco-exists as wasi:cli/rundefun + directives onlydefun + directives only
Per-call string memoryhost-managed (__ronto_alloc)freed by the canonical post-returnhost-managed (__ronto_alloc + the arena API; automatic for scalar returns)freed by the canonical post-return
Typical size~100 KB (~2 KB with --optimize)~110 KBtens of bytes to a few KBhundreds of bytes to a few KB

The rest of this page details each shape: how its exports are called, what runs inside them, and what each host must provide.

The Default Output: a wasm-GC Core Module

The default output — no flags beyond -o file.wasm — is a WASI Preview 1 core module over the wasm-GC value model:

  • wasm-GC — Integers are represented as i31ref. Floating-point numbers are boxed in a float_struct { f64 }. All values on the stack are typed as (ref eq). This is what supports the full language (cons cells, symbols, closures, hash tables, eval, ...), and why the module needs a wasm-GC capable runtime such as wasmtime 14+ (-W gc), Node 22+, or a current browser.
  • WASI Preview 1 — the module imports the eight wasi_snapshot_preview1 functions (fd_write for stdout, random_get, clocks, environment, ...) and exposes the _start entry point, so wasmtime run executes the program's top level like a command.

An exported function is a raw core function: scalars (:int/:float/:bool) cross as plain numbers, so wasmtime --invoke and instance.exports.fact(5) work directly. The memory-backed :string and :s-expr designators pass a (ptr, len) pair through the module's exported memory, together with a __ronto_alloc(size) bump allocator the host uses to stage argument bytes — that protocol needs a host that can read and write memory (JavaScript, not wasmtime --invoke), and is walked through end to end in the appendix. Instantiating the module still needs the eight WASI imports satisfied; wasmtime run provides them automatically, a browser host can supply no-op stubs for a pure-compute function, or add --no-wasi to drop them entirely.

Two behavioral notes on this value model:

  • Parameter limit. A function (defun or lambda) may take at most seven parameters (the interpreter and JVM backends have no such limit). A fixed-arity defun past the limit is bundled automatically: the compiler keeps the first six parameters, packs the rest into a list, and rewrites every direct call site to match — so wide library signatures compile unchanged. Taking such a function's value with #'name/symbol-function is a compile error (only direct calls know the bundled shape), and a lambda or variadic function past the limit still errors — bundle those arguments into a list yourself. The rest list of a variadic function counts as one parameter, so a &rest function may declare at most six required parameters while accepting any number of arguments at a direct call site.
  • Float printing shape. Floats of every magnitude print on WASM: the integer part is exact up to 2⁶³, larger values fall back to an approximate exponent form (1.0E19), and Infinity, -Infinity and NaN print as those words, like the other backends. One shape difference remains: from 10⁷ up to 2⁶³ WASM prints all the digits (1500000000000.0) where the interpreter and the JVM use exponent notation (1.5E12); rontolisp:json-stringify inherits that shape difference.

Importing Host Functions

rontolisp:wasm-import is the reverse of wasm-export: it declares a function the host provides and makes it callable from Lisp under the given name exactly like a top-level defun — including #'name, funcall, mapcar and eval. :from names the import module (default "env"), :as names the field inside it (default: the Lisp name), and the type designators are the same table as above:

In wasmtime, satisfy the imports by preloading another module that exports them — here a host module that is itself written in Lisp, exporting its function under the :as alias add:

$ cat host.lisp
(defun host-add (a b) (+ a b))
(rontolisp:wasm-export 'host-add :as "add" :params '(:int :int) :returns :int)
$ rontolisp host.lisp -o host.wasm --no-wasi
$ rontolisp main.lisp -o main.wasm --no-wasi
$ wasmtime run -W gc --preload host=host.wasm --invoke add10 main.wasm 32
42

In a browser (or Node) the import object is the module table — one key per :from name, one property per :as name. This is also the escape hatch for anything the WASM backend does not provide; for example it has no trigonometric built-ins, so borrow JavaScript's:

const imports = { math: { sin: Math.sin, cos: Math.cos } };
const { instance } = await WebAssembly.instantiate(bytes, imports);

The WebGL triangle example is the hello world of this pattern: ten imported functions, no exports, and a colored triangle drawn entirely from Lisp. The WebGL cube example adds 3D: the perspective and rotation matrices are computed in Lisp every frame. The WebGL galaxy example is the same idea grown into a complete browser program: the entire WebGL pipeline is driven from Lisp — the GLSL shaders live in the Lisp source, and Lisp compiles, links, buffers and issues every draw call through 34 imported host functions, while JavaScript supplies only one-line bindings over a handle table.

Boundary details beyond the scalar types:

  • A :string/:s-expr argument reaches the host as a (ptr, len) pair into the module's exported memory (an :s-expr argument is printed to readable text first).
  • A :string result must be written into linear memory by the host — reserve the buffer with the exported __ronto_alloc, then return the (ptr, len) pair (a two-element array in JavaScript).
  • An :s-expr result is parsed with the embedded reader, so the host can hand back a whole list structure as text.

Limitations:

  • Default (wasm-GC) Preview 1 output only: --component and --no-gc reject the directive with an error.
  • On the interpreter and JVM backends the directive defines a stub that signals an error when called, so a shared source still loads everywhere, but actually calling an import needs the WASM host.
  • Imported functions have the same 7-parameter arity limit as other functions.
  • Instantiating the module requires every declared import to be provided: wasmtime run needs a --preload <module>=<file>.wasm per import module name, and a JavaScript host passes an import object.

No-WASI (Reactor) Mode

Add --no-wasi to emit a Preview 1 module that imports no WASI functions, so a host can instantiate it with no import object at all — a "reactor"/library module whose only surface is the exported Lisp functions:

rontolisp fact.lisp --no-wasi -o fact.wasm
wasmtime run --invoke fact -W gc fact.wasm 5      # => 120

A reactor is just as easy to drive from JavaScript: there is no import object, so the host side is just "instantiate, then call the exports" (WebAssembly.instantiate(bytes).then(({ instance }) => instance.exports.fact(5))). A complete, copy-paste runnable Node + browser example is in the appendix at the end of this page.

The eight WASI import slots are filled with internal trap stubs so every function index stays fixed (no other codegen changes). This mode is for pure-compute exports only: any I/O (print/read/open/getenv/time/ random, including a top-level form that prints) hits a stub and traps. It is Preview 1 only — --no-wasi is ignored under --component.

Because the module is a reactor (not a WASI command), its top-level initializer is exported as _initialize rather than _start. A host should call _initialize once after instantiation to run top-level forms (defvar/defparameter/setq globals that an exported function reads); pure-compute reactors that hold no top-level state can skip it.

WASI 0.3 Component (--component)

Add --component to emit a WASI 0.3 (Preview 3) component instead of a Preview 1 core module. The component prints through wasi:cli/stdout@0.3.0:

rontolisp hello.lisp --component -o hello.wasm
wasmtime run -W gc=y -W component-model-more-async-builtins=y hello.wasm
3

In WASI 0.3 all byte I/O flows through the built-in component-model stream<u8> / future<T> types and the async canonical ABI. rontolisp keeps the same Preview 1 core module unchanged — it still imports the eight wasi_snapshot_preview1 functions — and an adapter core module implements them over WASI 0.3 (wasi:cli, wasi:filesystem, wasi:clocks, wasi:random) using stream.new/stream.read/stream.write and future.read. The component's wasi:cli/run@0.3.0 export (an async func) is lifted as a stackful async export, so the synchronous stream/future built-ins block cooperatively and the adapter stays straight-line code. The async canonical ABI and the stackful lift are enabled by default in wasmtime 46+; only the synchronous stream/future built-ins are still feature-gated, hence -W component-model-more-async-builtins=y (plus -W gc=y for the wasm-GC core).

The wasmtime invocation does not select the output kind. wasmtime run is wasmtime's default subcommand and auto-detects a core module vs a component, so wasmtime run -W gc runs a Preview 1 hello.wasm just as well. Only the --component compile flag decides whether a Preview 1 core module or a WASI 0.3 component is produced. (The practical difference shows up on a component-only runtime, which runs the component but not the Preview 1 core module.)

What works inside a component, and what each feature needs at run time:

  • print/stdout, stdin (read, 0-argument read-line, over wasi:cli/stdin@0.3.0), and file I/O (open, close, write-line, stream read-line, load, with-open-file) all work. File access requires --dir (paths resolve against the first preopened directory):
cat > fileio.lisp <<'EOF'
(with-open-file (out "greeting.txt" :direction :output)
  (write-line "hello" out))
(with-open-file (in "greeting.txt")
  (print (read-line in)))
EOF
rontolisp fileio.lisp --component -o fileio.wasm
wasmtime run -W gc=y -W component-model-more-async-builtins=y --dir . fileio.wasm
# "hello"
  • random draws real entropy from wasi:random@0.3.0 (Preview 1 uses the host's random_get), so (random N) differs each run. get-universal-time / get-internal-real-time / get-internal-run-time read wasi:clocks@0.3.0 (system-clock/monotonic-clock), and getenv reads wasi:cli/environment@0.3.0.
  • Outgoing HTTP (rontolisp:fetch with the rontolisp:await / rontolisp:then / rontolisp:promisep promise operations) works in component mode, including true asynchrony: fetch sends the request and returns a promise (wrapping the in-flight wasi:http response handle) immediately, so several requests can overlap before await blocks on each. The promise operations themselves compile in every mode; only fetch is component-only. It is a hybrid: the base I/O stays WASI 0.3 while fetch itself imports wasi:http@0.2 + wasi:io@0.2 (async wasi:http@0.3 does not exist upstream yet). Run a fetch component with -S http=y in addition to the async flags. Non-fetch components do not import wasi:http, so they do not need -S http.
  • TCP sockets (rontolisp:tcp-connect / tcp-listen / tcp-accept / tcp-local-port) work in component mode over wasi:sockets@0.3.0 (natively WASI 0.3 — no 0.2 hybrid). A socket is a bidirectional stream handle, so read-line / write-line / read-byte / write-byte / close work on it directly. Run a socket component with -S tcp=y -S inherit-network=y in addition to the async flags; without them the component still starts but every socket operation fails and yields nil. Hosts must be IPv4 literals (no hostname resolution yet), and rontolisp:fetch cannot be combined with the tcp functions in one component yet.
  • The compiled Lisp otherwise behaves identically to the Preview 1 output for the supported features. Serving incoming HTTP (rontolisp:http-handler) also compiles to a component, but a different kind (wasi:http/incoming-handler) run under wasmtime serve — see the HTTP handler guide.

Component-model Function Exports (wasm-export)

Under --component, a rontolisp:wasm-export becomes a typed component-model export, callable through the canonical ABI with WAVE syntax (wasmtime run --invoke 'name(args)', no experimental warning) — and it co-exists with the wasi:cli/run command entry, so the same component still runs as a command:

rontolisp sumsq.lisp --component -o sumsq.wasm
wasmtime run -W gc=y -W component-model-more-async-builtins=y --invoke 'sumsquared(2, 3)' sumsq.wasm
# 25    (the export's return value, rendered by wasmtime)
wasmtime run -W gc=y -W component-model-more-async-builtins=y sumsq.wasm
# 400    (the ordinary run entry executes the top-level program)

The two commands print different things: --invoke calls only the named export — the top-level program (the wasi:cli/run entry) does not run — and the 25 is wasmtime rendering the export's return value in WAVE syntax, not output from print. The plain run executes the top-level program instead, so the 400 is the output of (print (sumsquared 10 10)).

The typed signature (:ints32, :floatf64, :boolbool, :stringstring, :s-exprstring carrying the printed s-expression text, omitted :returns → no result) is visible to any component host, and :as renames the component export just like the core one.

A :string boundary crosses as a real component-model string — no manual pointer handling on either side. The host lowers the argument bytes into linear memory and reads the result back out through the canonical ABI, and the module frees the per-call allocations afterwards (a canonical post-return function pops the bump allocator), so a resident instance stays flat across repeated calls:

rontolisp greet.lisp --component -o greet.wasm
wasmtime run -W gc=y -W component-model-more-async-builtins=y --invoke 'greet("世界")' greet.wasm
# "Hello, 世界"

By default an export is lifted synchronously and must be pure-compute: I/O inside it (print, read, rontolisp:fetch, file access) traps at runtime with "cannot block a synchronous task". Declare the export async with :async t to lift it against an async function type instead — the same stackful-async shape as the run entry — and I/O inside it works. wasmtime --invoke calls an async export exactly the same way:

rontolisp status.lisp --component -o status.wasm
wasmtime run -W gc=y -W component-model-more-async-builtins=y -S http=y \
  --invoke 'fetch-status("https://httpbin.org/status/204")' status.wasm
# "fetching"
# 204

In the component's WIT-level contract an :async t export is an async func (for example, jco types it as a Promise-returning function, while a sync export stays a plain function). Sync and async exports mix freely in one component, :async composes with every boundary type including :string/:s-expr, and a program without :async exports produces byte-identical output.

Current limitations of component exports:

  • A sync (default) export is pure-compute only: I/O inside it traps at runtime with "cannot block a synchronous task". Opt into :async t when the export prints, fetches, or otherwise does I/O; keep pure-compute exports sync.
  • :async is meaningful only here: Preview 1 / --no-wasi core exports ignore it (the host provides I/O directly there), and --no-gc --component rejects it (the compact reactor component has no async adapter).
  • jco (1.25.2) transpiles an :async t export and types it as async, but cannot call it yet — its generated driver assumes callback-style async tasks, and stackful async exports are not implemented upstream (the same gap as calling the transpiled run). wasmtime run --invoke is the verified path for async exports; sync exports work on both.
  • The export name must be a lower-kebab-case component-model name (sum-squared); for a Lisp name outside that grammar the compiler asks you to rename it with :as.
  • Invoking an export does not run the program's top level first, so an export that reads a defvar/defparameter global would see it uninitialized (this matches the Preview 1 --invoke behavior).

For a pure-compute export kit, the compact --no-gc --component variant emits the same typed exports (plus :longs64, minus :s-expr) in a component of a few hundred bytes that needs no wasmtime flags at all.

Non-GC Output (--no-gc)

Every GC-value-model output above — even an optimized reactor — still needs a wasm-GC capable runtime, because every value is a GC heap type (i31ref, the float struct, (ref eq)). Add --no-gc to emit a plain MVP module instead: no rec group, no struct/array/i31 type, no eqref and no import (a plain linear memory is added only when the program uses strings — see below — and the single fd_write import only when it prints). A print-free module instantiates with no import object and runs on any MVP-class runtime with no -W gc:

rontolisp fact.lisp --no-gc --optimize -o fact.wasm
wasmtime run --invoke fact fact.wasm 5      # => 120, no -W gc needed

It achieves this by lowering each value directly onto an unboxed wasm scalar, plus a small linear-memory representation for strings — so the eligible subset is a restriction of the language, not a different one. The program shape is also restricted: the top level may contain only defuns and rontolisp:wasm-export directives (a pure-compute reactor — there is no _start), and the boundary designators are :int, :long, :float, :bool, :string (and :void/omitted); :s-expr is not supported — it would need the cons/reader/printer runtime this backend deliberately omits.

Numeric vector kernels (the vec: package) work under --no-gc too, lowered to plain scalar loops by default — so a vector program keeps the "runs on any MVP runtime" property above. Add --simd to lower those kernels to native WebAssembly SIMD (v128) instead, which then needs a runtime with the SIMD proposal (on by default in wasmtime).

Eligible subset

A function is eligible only if its entire transitive call graph stays inside this subset:

  • numbers and booleans: arithmetic (+ - * / mod rem 1+ 1- abs min max sqrt), the integer bitwise operators (logand logior logxor lognot ash), comparison and predicates (= < <= > >= not zerop plusp minusp evenp oddp);
  • control and binding: if/when/unless/cond/progn/let/let*, recursion and calls to other eligible functions;
  • iteration and local mutation: dotimes/do/do* and the underlying while/setq/return, with a let/do-bound variable freely reassigned; loop is eligible only for its non-consing clauses (numeric for, sum/count/maximize/minimize, repeat/while/until/do/return) — its collect/append/nconc and for ... in/on clauses allocate lists and are not;
  • float/int conversions: float truncate floor ceiling round;
  • strings and characters: string literals, character literals, (concatenate 'string ...), length, subseq, string=, char, char-code/code-char, char= and princ-to-string (of integers, floats and strings). There is no separate character type: a character is represented by its code point, so the portable idioms (char= (char s i) #\x) and (char-code (char s i)) behave exactly like the other backends, while a bare (char s i) crossing an :int boundary shows the code;
  • printing: print, princ and terpri (without the optional stream argument) — see below;
  • memory reclamation: rontolisp:with-arena.

Anything else that would allocate a heap object (cons/list, symbols, vectors, hash tables, eval/apply, I/O, dolist/list iteration, a free variable or assignment to a global, a lambda-list keyword such as &optional/&rest/ &key — the rest list is a cons) makes the function ineligible. Rather than miscompile silently, that is a compile error naming the offending operation, so the boundary stays explicit.

Numeric model

Each value's wasm type is chosen by static type inference: integers use i64, floats use f64. Types are inferred with a fixpoint over the call graph seeded by the export boundary designators, and where an integer and a float meet (e.g. (* 3.14 n)) the integer is promoted to f64. Using i64 makes integer arithmetic exact to 2^63 — far wider than both the GC backend's i31 fixnums and what an all-f64 lowering (exact only to 2^53) could offer; for example a*a - (a-1)*(a+1) stays exactly 1 even when the intermediates exceed 2^53.

Inference also widens automatically: a let/do-bound variable takes the join of its initializer and every value assigned to it, so an integer accumulator summed with floats becomes an f64:

Under --no-gc this infers acc (and the return value) as f64 while the loop counter i stays i64.

There is no rational type, so two things differ from full Common Lisp and from the GC backend: / is floating-point division (no 1/3 ratios), and a value is false in a boolean context exactly when it is zero (Common Lisp treats only nil as false). The boundary designators stay host-width — :int/:bool cross as a 32-bit i32 (as in the GC backend), so a returned value outside the 32-bit range wraps; the wide i64 range applies only to the internal computation. When a parameter or result can exceed the 32-bit range, declare it :long — it crosses the boundary as i64 with no wrap/extend (:long is --no-gc-only; the GC backend rejects it, its integers being i31ref). For the numeric kernels this mode targets (factorials, math/finance functions, validators) the results match the interpreter and the GC backend.

Strings

A string is an i32 pointer to a [length][bytes] header in linear memory, and (concatenate 'string ...) bump-allocates a fresh buffer — so building up a string is just an accumulator loop:

Slicing and inspection work on the same representation: length reads the header, subseq copies a slice into a fresh buffer, string= compares content byte-wise, char indexes a byte, and princ-to-string renders an integer — enough for routing/parsing kernels, not just accumulation:

A module that uses strings gains a (growable) linear memory, and exports that memory plus a __ronto_alloc(size) bump allocator alongside your functions. A :string parameter arrives as a (ptr, len) pair the host writes into memory, and a :string result is returned the same way — so a string-valued export needs a host that can read/write the exported memory (JavaScript, a small Node script, the browser playground) rather than just wasmtime --invoke. The appendix walks through the JS side, and --no-gc --component removes the manual protocol entirely.

This is what lets the ASCII-art Mandelbrot renderer run with no wasm-GC: examples/console/mandelbrot-nogc.lisp keeps the floating-point escape-time loop but returns the rendered grid as one string instead of printing it:

$ rontolisp examples/console/mandelbrot-nogc.lisp --no-gc --optimize -o mandelbrot.wasm
$ node -e '(async () => {
  const ex = (await WebAssembly.instantiate(
    require("fs").readFileSync("mandelbrot.wasm"), {})).instance.exports;
  const [p, n] = ex.mandelbrot(-2.5, 1.0, -1.2, 1.2, 70, 30, 30);
  process.stdout.write(Buffer.from(new Uint8Array(ex.memory.buffer, p, n)).toString());
})()'

Printing (print / princ / terpri)

An exported function can print: print (readable form plus a trailing newline, so strings come out quoted), princ (display form, no newline) and terpri (a newline) work inside the eligible subset, with output byte-identical to the interpreter:

$ cat show.lisp
(defun show (n)
  (print n)
  (print (* 1.5 n))
  (print "done"))
(rontolisp:wasm-export 'show :params '(:int) :returns :void)
$ rontolisp show.lisp --no-gc -o show.wasm
$ wasmtime run --invoke show show.wasm 4
4
6.0
"done"

Floats print through the same digit-extraction printer as the GC backend, including the IEEE edges (NaN, Infinity/-Infinity, -0.0; a magnitude ≥ 2^63 uses the WASM backends' E-notation shape). Each print of a number renders its text into a transient string that is reclaimed immediately, so a print loop does not grow the heap.

Two things to know:

  • A printing module has one import. print/princ/terpri write through a single wasi_snapshot_preview1.fd_write import — added only when the program prints, so a print-free module keeps zero imports and its exact bytes. Any WASI Preview 1 host provides fd_write for free (wasmtime run, Node's built-in node:wasi module), but a printing module no longer instantiates with an empty {} import object the way the Mandelbrot snippet does — a raw JavaScript embedder must supply { wasi_snapshot_preview1: { fd_write } } (or use node:wasi).
  • Booleans print by literal only. The value model has no runtime boolean type: (print t) / (print nil) print t / nil, but a computed boolean such as (print (> a b)) prints its 0/1 integer. The optional stream argument and printing a packed float array are compile errors.

Reclaiming memory (the arena API)

__ronto_alloc is a bump allocator that never frees, so a resident host — one that keeps a single instance alive and calls it in a loop, allocating a fresh input buffer each time — grows its linear memory without bound. Two mechanisms keep it flat:

  • Automatic, for scalar returns. When an export returns a non-memory scalar (:int/:long/:float/:bool/:void), its wrapper snapshots the heap top on entry and restores it on exit, so everything the call allocates (the internal copy of a :string argument, plus any concatenate/subseq/princ-to-string scratch) is reclaimed on return. Nothing to do host-side.
  • Manual, for the host's own buffer. The host allocates its input buffer before the call, so it sits below the wrapper's auto-reset mark and is left live. To reclaim it too, the string-using module also exports a matched pair over the same heap pointer:
exportsignaturemeaning
__ronto_alloc_mark() -> i32snapshot the current bump-heap top
__ronto_alloc_reset(i32 mark) -> ()restore the top to a saved mark

Snapshot before allocating the input, restore after reading the result, and a resident instance stays perfectly flat no matter how many times it is called:

node -e '(async () => {
  const ex = (await WebAssembly.instantiate(
    require("fs").readFileSync("count_vowels.wasm"), {})).instance.exports;
  const enc = new TextEncoder();
  const countVowels = (s) => {
    const b = enc.encode(s);
    const mark = ex.__ronto_alloc_mark();        // snapshot BEFORE allocating input
    const ptr = ex.__ronto_alloc(b.length);
    new Uint8Array(ex.memory.buffer, ptr, b.length).set(b);
    const n = ex.count_vowels(ptr, b.length);    // scalar result read out here
    ex.__ronto_alloc_reset(mark);                // pop the input + wrapper scratch
    return n;
  };
  const before = ex.memory.buffer.byteLength;
  for (let i = 0; i < 100000; i++) countVowels("Hello, World! " + i);
  console.log(before, "->", ex.memory.buffer.byteLength);   // 65536 -> 65536 (flat)
})()'

The arena is a manual stack, not a garbage collector, so two rules apply:

  • Only reset to a mark taken before everything still live — popping to a mark taken after data you still need frees that data.
  • A :string-returning export does not auto-reset (its result is a live heap pointer). Read the returned bytes out of memory before calling __ronto_alloc_reset — resetting first frees the string and the next allocation overwrites it.

The count-vowels example walks through this recipe with both a Node and an Endive (Java) host.

Reclaiming from Lisp (rontolisp:with-arena)

Both mechanisms above fire at the export boundary — nothing is freed within one call. A loop that allocates each iteration (concatenate 'string builds a fresh buffer, vec:zeros/vec:ones a fresh vector) therefore grows the heap for the duration of the call. rontolisp:with-arena names that reclamation boundary in the source: it snapshots the bump heap pointer, runs its body, and pops everything the body allocated — keeping only the body's own value (a string or packed float array result is copied down to the snapshot point):

With the arena, a hundred thousand iterations stay within the initial linear memory; without it, the same loop grows by one vector per iteration. The escape contract is the same as __ronto_alloc_reset's: nothing allocated inside the body may be reachable after it, except the body's own value. On the interpreter, the JVM backend and the default (wasm-GC) output, with-arena is observationally a plain progn — a real garbage collector already reclaims — so the same source runs on every backend.

Compact Component Output (--no-gc --component)

Add --component to wrap the same MVP core module as a WASM component whose exports become typed component-model exports, callable through the canonical ABI with WAVE syntax. A print-free core module has zero imports, so the wrap needs no WASI adapter, no shared-memory module and no wasm-GC — the whole component stays in the hundreds of bytes for a small program and runs with no wasmtime flags at all:

rontolisp fact.lisp --no-gc --component -o fact.wasm
wasmtime run --invoke 'fact(5)' fact.wasm
# 120

The typed WIT signature maps :ints32, :longs64, :floatf64, :boolbool, :stringstring, and an omitted :returns → no result. The component also transpiles with jco (jco transpile, where :long surfaces as a JavaScript BigInt) and runs on any component-model host, with no wasm-GC support required.

:long is valid here, unlike the GC component path — use it when a value can exceed the 32-bit range, matching the backend's internal i64 arithmetic:

rontolisp cube.lisp --no-gc --component -o cube.wasm
wasmtime run --invoke 'cube(2000000)' cube.wasm
# 8000000000000000000

A :string boundary crosses as a real component-model string — no manual pointer handling on either side. The host lowers the argument bytes into the module's own memory and reads the result back out through the canonical ABI, and the module frees every per-call allocation afterwards (a canonical post-return function pops the bump allocator to its base), so a resident instance stays flat across repeated calls:

rontolisp greet.lisp --no-gc --component -o greet.wasm
wasmtime run --invoke 'greet("world")' greet.wasm
# "Hello, world"

Printing works here too: a program that prints gets a built-in print micro-adapter — three tiny fixed core modules that implement the core's single fd_write import over WASI 0.2 stdio (wasi:cli/stdout plus wasi:io/streams' synchronous blocking-write-and-flush), wired in only when the program prints. The exports stay ordinary sync lifts, the zero-flag property is kept (hosts provide 0.2 stdio by default), and the print output is byte-identical to the interpreter — with the earlier show.lisp:

rontolisp show.lisp --no-gc --component -o show.wasm
wasmtime run --invoke 'show(4)' show.wasm
# 4
# 6.0
# "done"
# ()

Trade-offs against the plain --no-gc output, and current limits:

  • A component needs a component-model-capable host; the raw core module runs on any WebAssembly engine through the plain embedding API. Both outputs stay available — pick per host, and note the component is not the default for --no-gc. (Without --component, a :string crosses as the manual (ptr,len) core ABI instead.)
  • The component is a pure reactor: there is no wasi:cli/run entry (nothing runs at the top level). Printing inside an export works through the micro-adapter above; every other I/O stays outside the --no-gc subset as usual. :async t is rejected (there is no async adapter to suspend on).
  • The export name must be a lower-kebab-case component-model name; for a Lisp name outside that grammar the compiler asks you to rename it with :as.
  • --optimize composes: the core module is tree-shaken before the wrap.

Cross-Cutting Flags

Optimize (Tree Shaking)

By default a compiled module embeds the entire runtime (printer, rational, string, reader and eval helpers, the WASI import slots, …) regardless of what the program actually uses, because function indices are held fixed. Add --optimize to drop every function unreachable from the module's roots (its exports and the _start/_initialize entry) and renumber the survivors. Unused WASI imports are removed too, so a pure-compute reactor module shrinks to a handful of functions:

rontolisp fact.lisp --no-wasi --optimize -o fact.wasm
wasmtime run --invoke fact -W gc fact.wasm 5      # => 120, from a ~2 KB module

For the fact example the module drops from ~100 KB to under 2 KB. --optimize is opt-in and behavior-preserving: it walks the call graph from the actual call instructions, so anything reachable (including code an embedded eval/load dispatches to) is kept. On the GC --component path it is a no-op (the WASI 0.3 adapter relies on the core's fixed import/index layout, so the component is emitted unchanged); under --no-gc --component it works — the core module is shaken before the wrap. The same flag also dead-code-eliminates the JVM output.

Independently of --optimize (and on every output mode, --component included), compilation always tree-shakes the bundled Lisp-source libraries (linalg:, vec:, JSON, URL, equalp/string<): a library function your program never mentions -- by name anywhere in the source, including quoted symbols and string literals -- is not compiled into the module. The one consequence: a library function whose name is only assembled at runtime from computed strings and called through eval/apply signals the usual "undefined function" error. Compile with --no-prune (or --dynamic) to keep every library definition in that case.

SIMD Acceleration (--simd)

--simd is the one acceleration switch shared by every backend: it lowers the vectorizable vec: and linalg: kernels to real vector instructions. On WASM it is orthogonal to the value model:

  • wasm-GC + --simd lowers the kernels to native fixed-width SIMD (f64x2/f32x4) over GC-managed lane-group arrays — packed float arrays stay ordinary GC objects, and memory behaves exactly as without the flag. Composes with --component and --optimize; run as usual with wasmtime run -W gc (wasmtime enables the SIMD proposal by default).
  • --no-gc + --simd lowers the same kernels to v128 over the packed linear-memory blocks. Without --simd, --no-gc emits plain scalar loops instead — a v128-free MVP module that also runs on a runtime lacking the SIMD proposal.

The full story — which kernels vectorize, precision rules for single-float reductions, measured effects, and the linalg interception — lives in the SIMD acceleration guide.

Appendix: Calling a Module from JavaScript

A reactor module (--no-wasi or --no-gc) imports nothing, so the whole host side is "instantiate, then call the exports" — and it is the same code in Node and the browser. Here is a complete, copy-paste example end to end. Start with a small kit of three exports:

Compile it with --no-gc (runs on any engine) and --optimize (drops everything unreachable from the exports — here the whole module is ~200 bytes):

rontolisp mathkit.lisp --no-gc --optimize -o mathkit.wasm

On Node 18+, save this as run.mjs and run node run.mjs:

import { readFile } from 'node:fs/promises';

// Node reads the .wasm from disk. In a browser, use the streaming fetch shown below.
const bytes = await readFile(new URL('./mathkit.wasm', import.meta.url));
const { instance } = await WebAssembly.instantiate(bytes);   // no import object

const ex = instance.exports;
console.log(ex.fact(10));                         // 3628800
console.log(ex.area(2));                          // 12.566370614359172
console.log(Boolean(ex['in-range'](5, 0, 10)));   // true   (:bool crosses as 0 / 1)
console.log(Boolean(ex['in-range'](42, 0, 10)));  // false
3628800
12.566370614359172
true
false

The browser differs only in how the bytes are loaded — instantiateStreaming takes a fetch directly — so a whole page is:

<!doctype html>
<script type="module">
  const { instance } = await WebAssembly.instantiateStreaming(fetch('./mathkit.wasm'));
  const ex = instance.exports;
  document.body.textContent = `fact(10) = ${ex.fact(10)}, area(2) = ${ex.area(2)}`;
</script>

A few boundary details worth knowing:

  • A hyphenated Lisp name such as in-range is not a valid JavaScript identifier, so reach it with bracket access: ex['in-range'](...).
  • :int/:float arrive as plain JS numbers; :bool crosses as an i32 (0/1), so wrap it in Boolean(...) for a real JS boolean.
  • A --no-gc module runs on any WebAssembly engine; a GC --no-wasi module needs a wasm-GC-capable one (Node 22+, current browsers). The JavaScript above is byte-for-byte identical for both — swap the compile flag and nothing else changes.

Passing strings (:string)

The scalar example above needs no memory because :int/:float/:bool cross the boundary as plain numbers. A :string instead passes a (ptr, len) pair through the module's exported memory: the host writes the argument bytes into memory (at an offset reserved by the exported __ronto_alloc(size) bump allocator), passes (ptr, len), then decodes the (ptr, len) the export returns.

:string works under --no-gc, so the module still runs on any engine — as long as the function stays within the non-GC string subset (see the eligible subset above). A greeting builder is enough to show the protocol:

rontolisp greetkit.lisp --no-gc --optimize -o greetkit.wasm
import { readFile } from 'node:fs/promises';

const bytes = await readFile(new URL('./greetkit.wasm', import.meta.url));
const { instance } = await WebAssembly.instantiate(bytes);   // no import object
const ex = instance.exports;
const enc = new TextEncoder(), dec = new TextDecoder();

// Copy a JS string into linear memory; return its (ptr, len).
function write(str) {
  const b = enc.encode(str);
  const ptr = ex.__ronto_alloc(b.length);
  new Uint8Array(ex.memory.buffer, ptr, b.length).set(b);
  return [ptr, b.length];
}
// Decode a (ptr, len) result. Re-read ex.memory.buffer AFTER the call: a call may grow
// memory, which detaches the previous ArrayBuffer.
const read = (ptr, len) => dec.decode(new Uint8Array(ex.memory.buffer, ptr, len));

console.log(read(...ex.greet(...write('rontolisp'))));     // Hello, rontolisp!
Hello, rontolisp!

With --no-gc --component the same :string export instead crosses as a typed component-model string, and all of the host-side glue above disappears (the canonical ABI does the copying, and a post-return function keeps the heap flat).

Richer string functions (string-upcase, subseq, string=, …) are outside the non-GC subset; using one means compiling for the wasm-GC backend (--no-wasi) instead — the boundary protocol is identical, only the engine must be wasm-GC capable. The :s-expr example below shows that path.

Passing lists (:s-expr)

A :s-expr carries any Lisp value as s-expression text: the module parses the input with its embedded reader and prints the result back, over the same (ptr, len) / __ronto_alloc protocol. That reader/printer/cons machinery is wasm-GC only, so :s-expr (and the richer string functions above) need --no-wasi and a wasm-GC-capable engine (Node 22+, a current browser):

rontolisp textkit.lisp --no-wasi --optimize -o textkit.wasm
// Same instantiate + write/read helper as above (textkit.wasm needs a wasm-GC engine).
console.log(read(...ex.shout(...write('hello'))));         // HELLO
console.log(read(...ex.rev(...write('("a" "b" "c")'))));   // ("c" "b" "a")
HELLO
("c" "b" "a")

In the browser only the loading line changes (WebAssembly.instantiateStreaming(fetch(...))); the write/read/memory/__ronto_alloc logic is identical. A function that returns a multi-value (ptr, len) shows up in JS as a two-element array, hence read(...ex.shout(...)).