docs / wisp
Contents
Interpreted Wisp
A small Lisp interpreter written in Wisp. The Rust compiler builds the evaluator once; one Wasm instance then reads and evaluates every input, retaining its own environment and closures. Rust supplies the local terminal/ABI adapter and file I/O.
From the repository root, inside nix develop:
cargo run --example interpreter
# Or load an existing source file into the session first:
cargo run --example interpreter -- examples/factorial-test.wisp
# wisp> (factorial 6)
# 720
(define make-adder (lambda (x) (lambda (y) (+ x y))))
(define add-two (make-adder 2))
(add-two 40) ; 42
(let (x 1000) (add-two 40)) ; 42: lexical capture
(define fact (lambda (n) (if (= n 0) 1 (* n (fact (- n 1))))))
(fact 6) ; 720
(/ 1 0) ; error: division by zero
(add-two 40) ; 42: session still works
Enter one input per line; an input can contain several expressions. A
lambda or let body may hold several forms:
they run in order and the last gives the value (an implicit
begin). :quit, :q, or EOF exits.
Piped input works too. The reader itself accepts multiline source
through evaluate.
The initial language has s32/s64 integers, f32/f64 floats, strings,
symbols, lists, closures, u8 bytes, unit, named records and variants,
typed lists, options, results, tuples, and built-in functions. Forms
include define, lambda, let,
if, begin, quote (also
'), typed fn, record,
variant, match, global,
global.get, global.set, include,
trait, instance, derive,
defmacro, define-syntax,
quasiquote, and export. Both
(x s32) and (x : s32) parameter/field
declarations work. let supports
(let (name expression) body ...) and
(let (name : type expression) body ...).
Arithmetic/comparisons +, -, *,
/, =, <, >,
<=, >= take two numbers of the same type
(s32, s64, f32, f64, or the unsigned u64, u32 and u16 that host calls
return, which compare unsigned and wrap at their width);
list takes any number of values, with cons,
car, and cdr for list operations. Zero and the
empty Lisp list (nil or '()) are false; other
values are true, including typed lists and option/result values. Integer
arithmetic wraps at the operand width except division errors, which
produce diagnostics. The compiler's i32 and
i64 constants, arithmetic, bitwise, shift/rotate, and
comparison operations are available. Comparisons return s32. String
operations are string-len, string-ref,
string-append (any number of strings; none gives
""), string=?, and substring;
positions and lengths count bytes.
A string literal is written in double quotes and accepts these escapes:
| Escape | Byte |
|---|---|
\n |
newline (10) |
\t |
tab (9) |
\r |
carriage return (13) |
\e |
escape, ESC (27) |
\0 |
NUL (0) |
\xHH |
the byte HH: exactly two hex digits, either case
(\x1b, \xFF) |
\" |
double quote |
\\ |
backslash |
Each escape is one byte, so (string-len "\e[1m") is 4.
Any other escape, or \x without two hex digits, is a reader
error. The printer writes control bytes back as escapes
("\e[0m", "\x01"), so printed strings read
back unchanged. \xHH can make bytes that are not valid
UTF-8; string->bytes shows them exactly.
Use an s64 suffix for an explicit wide integer; the
printer preserves that suffix. Unsuffixed integers default to s32 and
must fit its range. A literal can instead adopt s64, f32, or f64 from a
typed return, parameter, field, annotated let, cast, or instruction
operand. Expected types flow through if and
match branches, let bodies, and the final
expression of begin. Stored and quoted values keep their
types; they do not implicitly widen. Use (s64 expr) or
(expr : s64) to convert an s32 value, and
(s32 expr) to keep the low 32 bits of an s64.
i64.extend_i32_s, i64.extend_i32_u, and
i32.wrap_i64 are also available.
(i64.add 4294967296 2) ; 4294967298s64
(define wide 9223372036854775807s64)
(+ wide 1s64) ; -9223372036854775808s64
(i64.div_s -9223372036854775808 -1) ; error: division overflow
(s32 4294967297s64) ; 1
Decimals default to f64; use f32 or f64
suffixes to select an explicit type. Scientific notation works with a
decimal point or a float suffix (1.0e6,
1e6f32). Float literals are read using exact integer
arithmetic and rounded to nearest, ties to even. As in the Rust
compiler, f32 literals are parsed as f64 then demoted. A decimal f64
literal does not implicitly become f32 in a typed context; use a suffix
or cast. All scalar casts and the compiler's float arithmetic,
comparisons, promotion/demotion, and signed/unsigned conversion
instructions work.
(f32.add 1.5f32 2.25f32) ; 3.75f32
(+ 0.1 0.2) ; 0.30000000000000004f64
(f64.div 1 0) ; inff64
(f64.div 0 0) ; nanf64
(s32 -2.75) ; -2
(s64 inff64) ; error: float-to-integer conversion out of range
u8 values preserve their type in parameters, records,
variants, lists, globals, and generic functions. Unsuffixed literals
adopt an expected u8 type; byte lists also accept stored
s32 values in the range 0–255. Values outside that range are
diagnostics, with no list/global mutation. A byte prints as
#<u8 255> (a display form, not reader syntax). Wasm
instructions expecting s32 accept byte operands, matching the compiler;
ordinary typed s32 parameters remain distinct.
(define bytes (list-push (list-new u8) 255))
(list-get bytes 0) ; #<u8 255>
(i32.add (list-get bytes 0) 1) ; 256
(fn finished () unit (tuple))
(finished) ; (tuple)
unit and (tuple) name the same type,
including inside compound types. The expression (tuple)
constructs its sole value. It is distinct from Lisp nil and
can be stored in options, results, lists, records, and globals.
Floating-point operations preserve IEEE behavior, including
subnormals, signed zero, infinity, and NaN. Numeric zero is false; NaN
is true. Conversion to an integer truncates toward zero; NaN, infinity,
and out-of-range conversions produce recoverable diagnostics. The
printer uses up to 9 significant digits for f32 and 17 for f64, with a
type suffix: output reads back to the same numeric value and preserves
signed zero. This is not a shortest-decimal formatter (0.1
may print as 0.10000000000000001f64). NaN sign and payload
are not preserved by text I/O. The evaluator, including decimal
conversion, remains entirely in Wisp.
Typed functions check arguments and return values at runtime.
Constructors check field/payload types, field access checks record
identity, and match checks cases and binding counts against
the declared variant, option, or result. Named types cannot currently be
redefined. Function bodies are checked as they execute; compile-time
rejection of invalid unexecuted branches and other static checks remain
parity work.
(record point (x s32) (y s32))
(fn sum-point ((p point)) s32 (i32.add (point.x p) (point.y p)))
(sum-point (point 20 22)) ; 42
(variant shape (circle s32) (rectangle s32 s32))
(match (rectangle 6 7) ((circle r) r) ((rectangle w h) (i32.mul w h))) ; 42
Compound type declarations can nest (list T),
(option T), (result T E), and
(tuple T1 T2 ...), including named record/variant types.
Empty lists and absent option/result branches retain their declared
types. Tuple construction infers each field's type; values can be
passed, returned, and stored in other containers. Like the compiler,
(tuple ...) requires at least one element and there is
currently no tuple projection form.
(define xs (list-new s64))
(list-push xs 4294967296) ; #<list s64 (4294967296s64)>
(list-get xs 0) ; 4294967296s64
(list-len xs) ; 1
(some s32 42) ; (some s32 42)
(none (list s32)) ; (none (list s32))
(err s32 string "oops") ; (err s32 string "oops")
(match (some s32 42) ((some n) n) ((none) 0)) ; 42
(tuple 42 "x" (none s32)) ; (tuple 42 "x" (none s32))
list-push mutates a typed list and returns it, matching
compiled Wisp. Aliases, closures, and containers holding that list
observe the update. Both operands are evaluated before the push; type
checks complete before that push mutates the list. Earlier successful
side effects remain if a later operation fails. list-get
checks its s32 index and reports out-of-bounds access as a diagnostic.
The Lisp
list/cons/car/cdr
operations remain separate from typed lists. Typed lists display as
#<list TYPE (...)>; this is a display format, not
source syntax. Recursive printing stops at depth 64 with
#<depth-limit>, allowing cyclic records and lists to
be inspected without unbounded recursion.
Local bindings are lexical. Closures see current top-level
definitions, so recursive functions and top-level redefinition work.
Each call extends a copy of its captured environment.
define is currently top-level only and publishes its
binding after the expression succeeds. A whole input is read before any
evaluation; successful preceding definitions remain if a later
expression fails.
Explicit globals have their own namespace, separate from lexical
variables and define. Declarations are top-level, with a
type, mut or const, and an integer constant
initializer. An optional colon before the type is accepted. Assignments
check the declared type and return the assigned value; immutable globals
reject assignment before evaluating its expression. Duplicate
declarations are errors.
(global $counter s32 mut 0)
(fn next () s32
(global.set $counter (i32.add (global.get $counter) 1)))
(next) ; 1
(next) ; 2
(global $items (list s32) mut 0)
(global.set $items (list-new s32))
(list-push (global.get $items) 42)
Numeric initializers convert to the declared scalar type. Other types accept only the compiler's zero placeholder; reading one before assigning a typed value is a diagnostic. Globals persist across inputs and are isolated between sessions.
Top-level (include "path.wisp") expands source before
evaluation. File paths are relative to the including file's directory;
interactive includes start at the host's working directory. Canonical
paths are included once per input graph, including cycles and aliases.
Loading again reads and evaluates the files again; there is no
session-wide include cache. Every file is read and parsed before any
form executes, so file/reader failures leave language state unchanged.
Evaluation errors retain earlier successful definitions and side
effects. Includes inside functions or begin are rejected;
quoted forms and comments do not load files. The Rust host's
Interpreter::load_file(path) and command-line file
arguments use this file-relative loading behavior.
defmacro defines a persistent syntax template. Arguments
are unevaluated forms; commas substitute parameters and comma-at splices
a list of forms. As in the existing compiler surface, this is template
substitution, not an arbitrary compile-time Lisp function. Parameters
must be distinct symbols and arity is checked before expansion. A
template can call other macros recursively.
(defmacro when (condition body) `(if ,condition ,body 0))
(when 1 (+ 20 22)) ; 42
(when 0 (/ 1 0)) ; 0: body is not evaluated
(defmacro sumargs (xs) `(i32.add ,@xs))
(sumargs (15 27)) ; 42
(define x 42)
`(answer ,x ,@(list 1 2)) ; (answer 42 1 2)
Includes expand first. Then all direct top-level macro declarations are collected and all remaining forms are expanded before evaluation, allowing a function to use a macro declared later in the same load graph. Definitions persist between inputs; the latest definition wins. Existing functions and closures retain their already expanded bodies. Quote protects data from expansion; quasiquote expands only the expressions in active unquotes. Backquote, comma, and comma-at also work for ordinary runtime list construction, with nested quasiquotes tracking their own commas. Splicing requires a Lisp list, and standalone comma forms are errors.
Invalid macro declarations or expansion failures publish neither new
macros nor ordinary definitions. After successful expansion the macro
table is published; a subsequent evaluation error retains it along with
earlier successful effects. Macros cannot generate new include
directives or macro declarations for another collection pass. Quotation
prefixes, quote, include,
defmacro, and define-syntax are reserved macro
names.
defmacro uses classic, name-based macros, matching the
self-hosted compiler's capture behavior. Template-introduced local names
can capture names in substituted code; the Rust compiler instead tracks
hygiene scopes. The Rust compiler also currently fails to substitute a
bare parameter in comma-at; the shared fixture uses a spliced literal
list containing unquotes to compare all three paths.
For hygienic templates, use define-syntax with
syntax-rules. Rules are tried in order. _
matches anything without binding; listed literal keywords match by name,
and numeric/string patterns match literal values and their printed type.
Other pattern identifiers bind syntax. Each pattern list supports one
repeated group, with fixed elements before and after it. Repeated groups
can contain compound patterns and nest; zero repetitions preserve empty
captures. Templates can repeat several groups, but variables used
together in one repeated template must have matching lengths. Pattern
variables must be unique and used under enough template ellipses;
malformed rules are rejected before publication.
(define-syntax with-temp
(syntax-rules () ((_ body) (let (tmp 0) body))))
(let (tmp 42) (with-temp tmp)) ; 42: caller's tmp stays distinct
(define-syntax rows
(syntax-rules () ((_ ((x ...) ...)) (list (list x ...) ...))))
(rows ((1 2) () (3))) ; ((1 2) () (3))
Introduced identifiers have a fresh binding identity for each
expansion. This applies to let, lambda and typed function
parameters, and match bindings. Substituted syntax preserves its caller
identity. Introduced free identifiers resolve in the top-level session
environment, so a caller's local binding cannot capture a macro's helper
reference. Top-level redefinition still affects those helpers, like
ordinary functions. Quotation prints/materializes ordinary symbols;
private identities do not become user-visible names. Record/variant type
names, field names, explicit globals, and literal keyword matching
remain name-based.
All macro forms share a persistent namespace: the latest declaration
wins, including across forms. The existing expansion/publication and
recovery rules apply to each. Local macro declarations are not
supported. The self-hosted compiler does not implement
syntax-rules; the Rust compiler currently has incomplete
nested/compound repetition capture and numeric literal pattern matching,
so those cases have interpreter-specific tests.
Procedural transformers use the compiler's
syntax-case-lambda form. Each clause has a pattern, an
optional guard, and an expression that must produce syntax. Use
#' for syntax quotation, or quasisyntax with unsyntax to
insert computed syntax. Pattern variables substitute automatically
inside either kind of syntax template. The transformer's single
parameter holds its entire input form.
(define-syntax fold-add
(syntax-case-lambda (stx)
((_ a b)
(and (integer? #'a) (integer? #'b))
(let (sum (+ (syntax->datum #'a) (syntax->datum #'b)))
#`(i32.const #,sum)))
((_ a b) #'(i32.add a b))))
(fold-add 20 22) ; 42
(define-syntax gather
(syntax-case-lambda (stx) ((_ x ...) #`(list #,@x))))
(gather 1 2 3) ; (1 2 3)
An optional (syntax-case stx (literals...) clauses...)
wrapper selects literal keywords, using the same pattern matcher as
syntax-rules. The wrapper's input must be the transformer
parameter. syntax-case is not a general runtime form.
Syntax prefixes read as syntax, quasisyntax,
unsyntax, and unsyntax-splice forms; they are
reserved for transformer expressions. Repeated captures can be spliced
with #,@, including empty and compound-pattern captures. A
repeated capture cannot be substituted as a single syntax value. Nested
quasisyntax tracks which unsyntax expressions are active. Introduced
syntax retains the same hygiene and top-level helper resolution as
syntax-rules.
The transformer language provides if, lexical
let, identifier?, number?,
integer?, syntax->datum, not,
and, or, integer
+/-, and syntax-error. Integer
computation uses wrapping signed 64-bit arithmetic; inserted results are
unsuffixed syntax literals whose type is determined when evaluated.
Predicates produce transformer booleans. As in the compiler, only a
boolean false rejects a guard; if also treats a computed
integer zero as false. Syntax objects, including quoted zero, are
truthy. and/or evaluate all operands.
syntax->datum extracts an integer or removes an
identifier's context; other values pass through unchanged. An unbound
transformer name denotes introduced syntax, and an unrecognized call
constructs a syntax application. Neither executes ordinary session code.
A clause returning a computed number/boolean rather than syntax is an
error.
Transformer failures use the normal expansion diagnostics and publish no session changes. This implementation additionally binds the whole-input parameter, evaluates compound unsyntax expressions, and splices list-shaped syntax objects; those paths are incomplete in the Rust compiler. It rejects invalid builtin arguments instead of silently treating them as zero. Procedure bodies and helper functions from the ordinary session are not callable during expansion.
Generic functions use the compiler's where syntax. Type
parameters are inferred from argument values and a known scalar return
type. Compound signatures such as (list T),
(option T), (result T U), tuples, and
(-> T U) are matched structurally. The interpreter
substitutes concrete types into type annotations and constructors, then
evaluates the function body with ordinary local bindings. It does not
generate additional Wasm.
(trait (Add T) (fn add ((a T) (b T)) T))
(instance (Add s32) (fn add ((a s32) (b s32)) s32 (i32.add a b)))
(fn twice ((x T)) T (where (Add T)) (add x x))
(twice 21) ; 42
(fn singleton ((x T)) (list T) (where T) (list-push (list-new T) x))
(list-get (singleton 42s64) 0) ; 42s64
Traits can have several type parameters. Instance signatures must match all of the trait's methods; a malformed declaration publishes nothing. Trait and instance definitions are immutable within a session, while generic functions can be redefined like ordinary functions. Declare a trait before its instances and constrained functions. A constrained call checks for the required instances and binds their methods locally. These bindings survive in captured closures. Direct method calls select an instance from arguments and the expected scalar return type; an ambiguous call is a diagnostic.
(derive Eq Record) creates a normal equality instance
for an existing record. Declare Eq first. Each field must
have type s32, s64, f32, f64, or u8; strings, nested records,
containers, unit fields, and variants are not derived. Empty records
compare equal. Float fields use Wasm equality: NaN differs from itself,
and positive and negative zero compare equal. The generated method
captures its primitive operations and field accessors, so redefining
those names later cannot change the comparison. Signature checks and
duplicate-instance checks run before publication. Both the Rust
compiler's = method and the self-hosted compiler's
eq spelling are recognized, with = preferred
when present.
Function parameters use
(-> argument-types... result-type). Typed functions,
generic functions, trait methods, and lexical closures can be passed as
values. Function contracts check argument and return types when called.
An untyped lambda does not itself supply type-inference information:
infer its type from other arguments or give it a concrete annotated
binding. Returned functions and typed container/record fields retain
their function contracts.
The existing standard library can be loaded unchanged in a fresh session:
cargo run --example interpreter -- std/list.wisp
(define xs (list-push (list-push (list-new s32) 20) 21))
(sum (map (lambda (n) (i32.add n 1)) xs)) ; 43
(fold + (zero) xs) ; 41
(contains xs 21) ; 1
Loading std/num.wisp publishes its trait operators,
including + and =. Signature lookahead can use
a later typed argument to resolve an earlier literal or method call, as
in (fold + (zero) xs). It only reads type metadata;
argument expressions still execute once, from left to right. All type
parameters must be resolved before entering the body. Expected compound
return types and arbitrary expression analysis are not implemented;
provide typed arguments when inference has insufficient information.
Quoted data keeps its symbols unchanged.
evaluator.wisp exports
evaluate(source: string) -> string: a printed value or
an error: diagnostic. Interpreter values stay in the
session. This is a local REPL text boundary, not a structured value
transport. The module can also be compiled directly for a host that
keeps its Wasm instance alive. It also exports
evaluate-from(source: string, base: string) -> string,
where base is the canonical source filename or empty for
interactive input. Both use the Pack/Graph ABI. Hosts must supply two
imports in the wisp-source module:
resolve-path(base: string, path: string) -> (result string string)returns a canonical filename.read-source(path: string) -> (result string string)returns UTF-8 source.
Ordinary I/O failures use the result's error string. Reading Lisp syntax, expanding includes, and evaluating forms remain in Wisp. The local host implements these imports and bounds file reads.
cargo run -- compile interpreter/evaluator.wisp target/interpreter/evaluator
This is a feasibility implementation, not full compiled-Wisp parity:
resource handles, general imports, raw memory access, byte/string
conversion operations, and Theater RPC built-ins are not implemented
yet. Type checks run during evaluation; unexecuted branches and function
bodies are not checked. export accepts compiled source
declarations; interpreted functions remain inside the session rather
than becoming new Wasm exports. There is no garbage collection; the
existing bump allocator retains allocations until the session is
discarded. Interactive inputs are limited to 4096 bytes; files to 64 KiB
each, with at most 256 files and 1 MiB of source per load graph. Include
nesting and reader nesting are limited to 64, evaluator nesting to 128,
and evaluation to 10,000 steps. Macro expansion allows at most 100
nesting levels and 10,000 syntax/transformer visits per input. The local
host also applies Wasmtime fuel to reader/printer and expansion work,
which can be exhausted before the file limits are reached. These are
fixed implementation limits, not an expanded request protocol.
Run the behavioral checks with
cargo test --test interpreter --test interpreter_parity.
The parity suite compares existing factorial and record examples,
single-payload variants, and arithmetic/string fixtures across the
interpreter and both compilers. The existing multi-payload variant
example is compared against the Rust compiler; the self-hosted compiler
currently emits an undefined local for its second payload. The
self-hosted compiler also misprints the minimum s32 literal; the shared
fixture constructs that value by arithmetic to test operations
independently of that bug. Full-width s64 literals, typed payloads, and
all integer operations at boundary values are compared against the Rust
compiler. The self-hosted reader currently truncates integer literals to
32 bits, so its i64 fixture builds values with Wasm instructions. The
Rust compiler currently needs explicit suffixes/casts for ordinary
function and constructor arguments and typed list elements where the
interpreter can adopt their expected type. The interpreter also accepts
compound let annotations; the Rust compiler currently accepts only
scalar let annotations. Float arithmetic and every conversion
instruction are compared with compiled Wasm. Reader/printer tests cover
rounding boundaries, subnormals, signed zero, non-finite values, and
samples across binary exponents. A shared float fixture also runs
through both compilers. Compound-operation fixtures compare nested
containers, option/result matching, tuple arguments, aliases, and nested
list mutations across all three paths. Structured outputs are also
compared with the Rust compiler's CGRF values. Globals are compared
across all three paths using declarations without a colon; the
self-hosted compiler does not currently parse the optional colon there.
Relative includes, canonical path deduplication, and cycles are compared
against the Rust compiler. Loading tests cover preflight failures and
session recovery. Macro fixtures compare the existing macro example,
recursive expansion, lazy branches, spliced templates, and repeated side
effects across both compilers and the interpreter. Interpreter tests
also cover persistence, redefinition, quoted data, nested quasiquotes,
invalid declarations, and expansion recovery. Syntax-rules tests compare
the existing example and introduced-binding hygiene with the Rust
compiler, and cover nested/empty repetitions, free identifier
resolution, typed bindings, redefinition, and invalid-rule recovery.
Procedural macro fixtures compare the existing example, guards, integer
folding, literal clauses, hygiene, and splicing against the Rust
compiler. Additional tests cover phase separation, nested quasisyntax,
includes, overflow, invalid results, and bounded expansion with
recovery. Generic functions, trait dispatch, nested specialization, and
generic list construction are compared across both compilers and the
interpreter. The standard list algorithms, including higher-order calls,
are compared with the Rust compiler. Interpreter tests cover
multi-parameter traits, expected return dispatch, closures, hygienic
macros, declaration rollback, type errors, and argument effect ordering.
Byte values and numeric-field derived equality are compared with the
Rust compiler, including CGRF byte outputs, wide integer differences,
NaN, and signed zero. Interpreter-only tests cover unit construction and
empty-record equality: the Rust compiler recognizes unit in types but
its constructor/code generation support is incomplete, and it rejects
empty record declarations. It also stores out-of-range s32 values in
byte lists without validating their range; the interpreter rejects those
values. Shared deriving fixtures use typed maker functions because the
compiler cannot infer trait types from constructor calls.