every systems programmer should write an interpreter at least once. it demystifies the languages we use every day and it teaches you to think about code as data — a skill that transfers directly to compilers, debuggers, and analysis tools.
zig was the language i used for mine. the result is lil, a lisp dialect that fits in 400 lines of zig.
the design
the interpreter has three phases: read, eval, and print. the read phase tokenizes input into s-expressions, the eval phase walks the tree and produces values, and the print phase renders values back to text. that is the whole architecture.
the core data structure is a tagged union representing every possible lisp value:
const Value = union(enum) {
integer: i64,
boolean: bool,
symbol: []const u8,
pair: struct { car: *Value, cdr: *Value },
builtin: *const fn (arena: *Arena, args: []Value) anyerror!Value,
lambda: struct { params: []const u8, body: *Value, env: *Env },
};
the builtin variant stores a function pointer for native operations like +, -, cons, and car. the lambda variant stores the parameter list, the body expression, and the enclosing environment for user-defined functions. everything else — numbers, booleans, symbols, and pairs — is self-evaluating or evaluated by the reader.
the allocator
zig’s manual memory management was the hardest part. an interpreter allocates constantly: every token, every cons cell, every captured environment frame. i used a simple arena allocator that frees everything at the end of each REPL cycle.
the arena pattern works well for an interpreter because the allocation lifetimes are known upfront: everything allocated during eval of one expression can be freed when the next expression starts. this avoids the complexity of reference counting or tracing garbage collection while keeping the code safe.
const Arena = struct {
buffer: []u8,
offset: usize,
fn alloc(self: *Arena, comptime T: type) *T {
const ptr = @ptrFromInt(@intFromPtr(self.buffer.ptr) + self.offset);
self.offset += @sizeOf(T);
return ptr;
}
};
what i would add
the language supports integers, booleans, cons cells, and lambdas. that is enough to express recursion, higher-order functions, and data structures built from pairs. it is not enough to write real programs.
if i were to extend it, i would add strings first (the minimal set of operations: concatenation, indexing, comparison), then a simple io system (read from stdin, write to stdout). those two additions would make the language self-hosting — capable of reading and evaluating its own source files.
conclusion
400 lines produced a working lisp that can compute fibonacci numbers, implement map in terms of fold, and run a repl that handles basic arithmetic. that is a remarkable amount of leverage from a small amount of code. lisp’s simplicity is not an aesthetic choice — it is a mathematical property that lets a tiny implementation produce real results.