Building a REPL OS in Common Lisp
Building a REPL OS in Common Lisp, Part 1: The Foundation
Part 1 of a series where we build a custom operating system that runs inside a Lisp REPL.
What Are We Building?#
Over this series, we’ll build something unusual: an operating system that lives inside a REPL. Not a real OS that boots on hardware, but a simulation but a playground where we can explore OS concepts like process management, filesystems, and command interpreters, all while staying in the comfortable world of Lisp.
By the end of the series, our REPL OS will have:
- A command system with custom commands
- Background threads (processes)
- A virtual filesystem with persistence
- The ability to mount real directories
- Self-modifying capabilities
But we’re going to build it incrementally. Each article adds one layer. Today, we start with the most fundamental piece: the REPL itself.
Why is a REPL?#
If you are here I assume that you at least have some experience using an interactive shell. REPL stands for Read-Eval-Print Loop. A shell is a form of REPL, and it’s the interactive shell that Lisp programmers live in. You type an expression, the system evaluates it, prints the result, and waits for the next input.
(+ 1 2)
=> 3
(cons 'a '(b c))
=> (A B C)
Every Lisp environment already has a REPL. So why build our own?
Because we want to intercept that loop. We want to:
- Add custom commands that aren’t standard Lisp functions
- Control what happens before and after evaluation
- Handle errors our way
- Track history and state across evaluations
Our REPL will wrap the standard Lisp evaluator. When you type something, we’ll eventually check if it’s one of our commands. If not, we pass it through to regular Lisp eval. This means everything you can do in normal Lisp still works and we’re just adding capabilities, not replacing them.
Starting Simple#
Let’s build the REPL step by step, starting with the absolute minimum. And the minimum is surprisingly small when you use Lisp.
In Common Lisp, we have built-in functions that do exactly what a REPL needs: read parses input into a Lisp form, eval evaluates it, and print displays the result. The simplest possible REPL is just these three nested in a loop:
(loop (print (eval (read))))
That’s it. Three functions and a loop. Nothing else is required. You could run this right now and it would work, or sort of. Try typing (+ 1 2) and you’d get 3. But there are problems: there is no prompt (you stare at a blank line), there is no error handling (one mistake crashes everything), and there is no way to exit cleanly.
Let’s fix these one at a time.
Adding a Prompt and Better Output#
First, let’s give users a visual cue that we’re ready for input, and make results visually distinct:
(loop
(format t "~%λ ")
(force-output)
(format t "~%=> ~S" (eval (read))))
The format function prints to standard output. ~% means newline, ~S prints the result in readable form. We use λ as our prompt because what better symbol for a Lisp environment? The force-output ensures the prompt appears before read blocks waiting for input.
Now sessions look like:
λ (+ 1 2)
=> 3
Error Handling#
Right now, (/ 1 0) crashes us back to the shell. We need to guard against this type of situations, and for that we wrap the evaluation in a handler-case:
(loop
(format t "~%λ ")
(force-output)
(handler-case
(format t "~%=> ~S" (eval (read)))
(error (e)
(format t "~%ERROR: ~A" e))))
If any error occurs, we catch it and print a message instead of crashing. Now we can make mistakes and keep going, which is essential for an interactive environment.
Clean Exit#
We need a way out. For that, let’s check for (quit) before evaluating and:
(loop
(format t "~%λ ")
(force-output)
(let ((form (read)))
(when (and (consp form) (eq (car form) 'quit))
(format t "~%Goodbye.~%")
(return))
(handler-case
(format t "~%=> ~S" (eval form))
(error (e)
(format t "~%ERROR: ~A" e)))))
We read the form first, then check: is it a list whose first element is quit? If so, return from the loop.
Why (quit) instead of just quit? Consistency and simplicity. Everything in our REPL will look like a function call: (command arg1 arg2). The command is always car, arguments are cdr. This uniform syntax will make parsing easy when we add more commands.
The Complete Function#
Wrapping it in a function with a welcome message:
(defun my-repl ()
"Start the REPL. Type (quit) to exit."
(format t "~%Welcome to REPL OS v1")
(format t "~%Type any Lisp expression. Use (quit) to exit.~%")
(loop
(format t "~%λ ")
(force-output)
(let ((form (read)))
(when (and (consp form) (eq (car form) 'quit))
(format t "~%Goodbye.~%")
(return))
(handler-case
(format t "~%=> ~S" (eval form))
(error (e)
(format t "~%ERROR: ~A" e))))))
We tell users how to exit right away: nothing’s more frustrating than being trapped in a program.
Project Structure#
To make this a proper loadable system, we’ll use ASDF, the standard Common Lisp build system. Create a folder with three files:
repl/
repl.asd # System definition
packages.lisp # Package definition
repl.lisp # The REPL itself
repl.asd#
The system definition tells ASDF what files to load and in what order:
(asdf:defsystem :repl
:description "A custom REPL in Common Lisp - Version 1: The Foundation"
:version "1.0.0"
:serial t
:components
((:file "packages")
(:file "repl")))
The :serial t option means files are loaded in order. packages.lisp first, then repl.lisp.
packages.lisp#
We define a package to keep our code organized:
(defpackage :repl
(:use :cl)
(:export #:my-repl))
We :use :cl to inherit all standard Common Lisp symbols. We export my-repl, which will be our entry point.
repl.lisp#
Our REPL function from above, wrapped in the package with (in-package :repl) at the top.
Running It#
To use the REPL, you need a Common Lisp implementation. SBCL is a good choice. Here’s how to get started:
The Standard Way#
This is how you’d typically load a Common Lisp project:
-
Navigate to your repl directory and start SBCL:
cd repl sbcl -
Load the system definition and the system itself:
(load "repl.asd") (asdf:load-system :repl) -
Start the REPL:
(repl:my-repl)
This three-step process is standard in Common Lisp development. The .asd file tells ASDF about our system, then load-system compiles and loads all the components.
The Quick Way#
If you want a one-command launch, create a run.lisp file:
(load (merge-pathnames "repl.asd" *load-truename*))
(asdf:load-system :repl)
(repl:my-repl)
(quit)
Then just:
sbcl --load run.lisp
This loads the system, starts the REPL, and cleanly exits SBCL when you quit. The *load-truename* trick ensures paths resolve correctly regardless of where you invoke the command from.
You should see:
Welcome to REPL OS v1
Type any Lisp expression. Use (quit) to exit.
λ
Try some expressions:
λ (+ 1 2 3)
=> 6
λ (reverse '(a b c))
=> (C B A)
λ (defun square (x) (* x x))
=> SQUARE
λ (square 5)
=> 25
λ (/ 1 0)
ERROR: arithmetic error DIVISION-BY-ZERO signalled
λ (quit)
Goodbye.
Notice that errors don’t crash us. We catch them, report them, and keep going.
What We Built#
Up to this point it does not look like much, but starting from a one-liner, we incrementally added:
- A prompt: so users know when to type
- Output flushing: so the prompt appears before we block on input
- Better formatting:
=>prefix for results - Error handling: mistakes don’t crash the session
- Clean exit:
(quit)to leave gracefully - Project structure: proper ASDF system for easy loading
We can evaluate any Lisp expression. We can define functions and use them. We can make mistakes and recover. It’s a foundation.
What’s Next#
Our REPL is functional but primitive. Here’s what we’ll add in future versions:
Version 2 will introduce a command system. Instead of hardcoding quit, we’ll have a registry of commands. Each command will be a struct with a name, description, and function. We’ll be able to add new commands at runtime. And we’ll implement help to list available commands.
Version 3 will add threading. We’ll use Bordeaux Threads to run background tasks: timers, clocks, anything that needs to happen while the REPL is waiting for input. We’ll add ps to list threads and kill to stop them.
Version 4 will build a virtual filesystem. We’ll create directories and files that exist only in memory, persist them to disk, and mount real directories so we can interact with actual files using the same commands.
Version 5 will add a text editor, so we can create and modify files from within our OS.
Each version builds on the last. By the end, we’ll have something that feels surprisingly like a real operating system, all running inside a Lisp REPL.
The Philosophy#
There’s a deeper reason to build a REPL OS: to understand abstractions.
An operating system is a collection of abstractions. Processes, files, permissions, none of which exist in hardware. They’re ideas, implemented in software, that make computers usable.
By building our own, we see how these abstractions are constructed. We see that a “file” is just a node in a tree with a name and contents. We see that a “process” is just a thread with an entry in a registry. We see that a “command” is just a function with metadata.
These aren’t just simplifications, this is genuinely how it works. Real operating systems are more complex because they handle more edge cases at a bigger scale, but the core ideas are the same.
Lisp is fun to use, and I think it is the perfect language for this exploration. Some people feel intimidated by the amount of parenthesis, but its simplicity and uniform syntax means we can extend the language in a very natural way. Its interactive nature means we can experiment in real time. Its homoiconicity means our OS can manipulate its own code.
The foundation is laid. Now we start building.
Next: Part 2: The Command System