Anatomy of a Lisp

risp Architecture

A zero-dependency Lisp interpreter in Rust — one homoiconic value, one iterative core, three execution engines. Here is how the pieces fit.

the core idea

Code is data

One Value enum is both the parsed AST and the runtime value. Four consequences follow — each its own idea.

a

One type, two jobs

The parsed syntax tree and the runtime value are the same enum. No node type sits between source and execution.

pub enum Value {
  Nil,
  Bool(bool),
  Int(i64),
  Sym(Rc<str>),
  Pair(Rc<Cons>),
  Closure(Rc<Closure>),
  Builtin(BuiltinFn),
}
b

Lists are values

A list is just nested pairs. cons builds one; car and cdr take it apart. Program structure and data structure are the same structure.

;; a list is just nested pairs
(cons 1 (cons 2 (cons 3 '())))  ;; => (1 2 3)

// …and a Pair is two slots:
struct Pair { car: Value, cdr: Value }
c

Macros for free

Because code is data, a macro is just a function from forms to forms. Quasiquote builds the new form and the evaluator runs it.

;; a macro maps forms to forms
(defmacro unless (c body)
  `(if ,c nil ,body))

;; (unless done (step))
;;   => (if done nil (step))
d

Cheap to pass around

Every heap payload sits behind an Rc, so passing a Value clones a pointer, not a tree.

Pair(Rc<Cons>)        // payload behind an Rc

let b = a.clone();    // O(1): refcount++,
                      // shares the same cons

how it fits together

The dependency flow

Follow the wires. Source becomes one homoiconic value, and that Value hub is what everything else reads from. Hover any node to light up its internals.

fig · text → Value → engines
risp interpreter architecture Source text flows through the reader into a single homoiconic Value enum. The Value hub feeds the macro expander, the environment, and the evaluator, which dispatches to three engines — a tree-walker, a bytecode VM, and a Cranelift JIT — that all share one error type, one native builtin table, and one Lisp prelude, and are kept honest by a differential oracle. deopt agree() source ;; risp (map f xs) reader tokenizer · Vec<Token> explicit-stack parser 'x → (quote x) macro expander unevaluated forms quasiquote · depth gensym {g 0} Value enum Nil Bool Int Float Sym Str Pair(Rc) Closure Macro Builtin AST ≡ runtime value environment Rc<RefCell> chain lookup define set! iterative 2-path drop evaluator · CEK St::Eval / St::Ret heap frame-stack Rust stack: 3 frames TCO in tail call tree-walker reference oracle fib(30) · 1682 ms bytecode VM beats CPython 3.14 fib(30) · 95 ms Cranelift JIT --features jit 6.3 ms · 10–23× shared core — every engine sees the same world RispError · BuiltinFn table · prelude.lisp (fold → map · filter)
  1. 01

    Read

    Text → tokens → one Value tree, on an explicit stack that never overflows.

  2. 02

    Branch

    The Value hub feeds the macro expander, the environment, and the evaluator.

  3. 03

    Dispatch

    The evaluator hands each form to a tree-walker, a bytecode VM, or a JIT.

  4. 04

    Share

    All three see the same errors, the same builtins, the same prelude.

inside the core

Inside the core

Three components do the real work between source and result. Each runs on an explicit stack, so user-controlled depth never reaches the host call stack.

01

The reader

Text becomes tokens, then one explicit-stack loop folds them into a Value tree. A million-deep nest parses without recursive descent.

struct Frame {
  items: Vec<Value>,       // gathered so far
  wrappers: Vec<Rc<str>>,  // pending 'quote
  tail: Option<Value>,     // after a dot
}
// one loop — never recursive descent
02

The environment

Scopes are a chain of Rc<RefCell> frames. Lookup walks parents in a loop; a closure captures its defining frame — lexical, not dynamic.

type Env = Rc<RefCell<Environment>>;

fn lookup(env: &Env, name: &str) -> Option<Value> {
  let mut cur = env.clone();
  loop {                  // walk parents
    if let Some(v) = cur.borrow().vars.get(name)
      { return Some(v.clone()); }
    cur = cur.borrow().parent.clone()?;
  }
}
03

The evaluator

An explicit-stack CEK machine. The Rust call stack stays three frames deep; nesting lives on a heap Vec<Frame>, so tail calls loop in constant space.

enum St { Eval(Value, Env), Ret(Value) }

fn run_loop(mut st: St, mut stack: Vec<Frame>) {
  loop {
    st = match st {
      St::Eval(e, env) => step_eval(e, env, stack)?,
      St::Ret(v) => match stack.pop() {
        None => return Ok(v),
        Some(f) => step_return(f, v, stack)?,
      },
    };
  }
}

three ways to run

Three engines, one core

The tree-walker, the bytecode VM, and the JIT are interchangeable front-ends over a single evaluator, a single environment model, and a single standard library. Swap the engine; the language does not change.

measured

Fast — and provably the same

Every program runs on all three engines, and the results must match the tree-walker byte for byte. Speed never costs correctness.

1682 ms
tree-walker · fib(30)
95 ms
bytecode VM · beats CPython 3.14
6.3 ms
Cranelift JIT · fib(30)
10–23×
faster than CPython on loops & arithmetic
0
runtime dependencies in the default build
0
panics on user input — failure is a value

never crashes

Built to not fall over

User input controls recursion depth, so every part of the core runs on an explicit heap stack instead of the host call stack.

the standard library

A stdlib that recurses on its own stack

A flat native table covers the primitives — checked arithmetic, cons / car / cdr, equality, predicates. Everything else is Lisp, embedded at compile time: fold is tail-recursive, and map and filter are defined in terms of fold, so their recursion lives on the evaluator's heap stack, never the host C stack.

(def fold
  (lambda (f acc xs)
    (if (null? xs) acc
        (fold f (f acc (car xs)) (cdr xs)))))

(def map
  (lambda (f xs)
    (reverse (fold (lambda (a x) (cons (f x) a)) '() xs))))
map and filter are fold; fold is tail-recursive.