Positronia

Building a REPL OS in Common Lisp

Building a REPL OS in Common Lisp, Part 2: The Command System

Part 2 of a series where we build a custom operating system that runs inside a Lisp REPL.


Where We Left Off#

In Part 1, we built a basic REPL with a prompt, error handling, and a clean exit. But our quit check was hardcoded:

(when (and (consp form) (eq (car form) 'quit))
  (format t "~%Goodbye.~%")
  (return))

This works, but it doesn’t scale. What if we want ten commands? A hundred? We’d have a mess of conditionals. And there’s no way for users to discover what commands exist.

Let’s build a proper command system. Or sort of.

What Makes a Command?#

A Command will be defined by these three things:

  1. A name: the symbol users type
  2. A description: we will create a help command that will use it
  3. A function: the code that actually runs when the command is invoked

In Common Lisp, when you need to bundle data together, you define a struct:

(defstruct command
  name
  description
  function)

Doing this automatically gives us make-command to create commands, and accessors like command-name, command-description, command-function to read their parts. Perks of Lisp.

The Registry#

We need somewhere to store commands, that is our Registry. A hash table works well, and allows us to look up commands by name in constant time:

(defvar *commands* (make-hash-table :test 'equal))

(defun register-command (cmd)
  (setf (gethash (symbol-name (command-name cmd)) *commands*) cmd))

(defun find-command (name)
  (gethash (symbol-name name) *commands*))
`register-command` stores a command under its name. `find-command` retrieves it, returning `nil` if it doesn't exist.

For the attentive reader: yes, `find-command` and `register-command` both wrap the same `gethash` form, and Lisp would let me clean it up. I could define `(setf find-command)` so registration becomes a single assignment, and a small macro can describe the place once so neither function repeats itself. Cool, that version is shorter to look at and longer to understand. Two new concepts, three layers of indirection, in exchange for not writing the same easy expression twice. I left the simple version in because it is more evident.

There's a subtlety here: we use `symbol-name` to convert symbols to strings. Why? In Common Lisp, symbols belong to packages. When we register `'quit` from inside the `REPL` package, we get `REPL::QUIT`. But when a user types `(quit)`, the reader creates `COMMON-LISP-USER::QUIT`, a different symbol. By using the string `"QUIT"` as our key, we sidestep package issues entirely. The `:test 'equal` makes the hash table compare strings properly.


## The Dispatcher

Let's add a small but useful abstraction here, the Dispatcher. Instead of the `REPL` calling `eval` directly, it calls a dispatcher. The dispatcher checks if the input matches a registered command. If so, it runs the command. If not, it falls through to `eval`.

```lisp
(defun dispatch (form)
  (let* ((name (if (consp form) (car form) form))
         (args (if (consp form) (cdr form) nil))
         (cmd (find-command name)))
    (if cmd
        (apply (command-function cmd) args)
        (format t "~%=> ~S" (eval form)))))

Let’s trace through this:

  1. Extract the name: if form is a list like (help), the name is help. If it’s a bare symbol, that’s the name.
  2. Extract arguments: everything after the first element, or nil if there are none.
  3. Look up the command: check if this name is registered.
  4. If found: call the command’s function with the arguments using apply.
  5. If not found: fall through to eval and print the result.

This is what makes our REPL special: commands and Lisp expressions coexist. (help) runs our command. (+ 1 2) evaluates as normal Lisp.

Registering Commands#

Now we can define commands declaratively. Here’s quit:

(register-command
 (make-command
  :name 'quit
  :description "Exit the REPL"
  :function (lambda ()
              (format t "~%Goodbye.~%")
              (throw 'repl-exit nil))))

The function uses throw to exit the REPL loop. We’ll need a matching catch in the main loop (repl-exit). More on that shortly.

And here’s help, which lists all registered commands:

(register-command
 (make-command
  :name 'help
  :description "List available commands"
  :function (lambda ()
              (format t "~%Available commands:")
              (maphash (lambda (name cmd)
                         (declare (ignore name))
                         (format t "~%  (~A) - ~A"
                                 (command-name cmd)
                                 (command-description cmd)))
                       *commands*))))

The maphash function iterates over every entry in the hash table, calling our lambda with two arguments: the key and the value. We don’t need the key (we get the name from the command struct itself), but we have to accept it. The (declare (ignore name)) tells the compiler “I know I’m not using this variable, and that’s intentional, not a mistake.” It is not very important right now, but could be annoying. Without it, most Common Lisp compilers will warn about an unused variable.

The New REPL Loop#

Our main loop becomes simpler now because dispatch handles everything:

(defun my-repl ()
  "Start the REPL. Type (help) for commands, (quit) to exit."
  (format t "~%Welcome to REPL OS v2")
  (format t "~%Type (help) for commands, or any Lisp expression.~%")
  (catch 'repl-exit
    (loop
      (format t "~%λ ")
      (force-output)
      (handler-case
          (dispatch (read))
        (error (e)
          (format t "~%ERROR: ~A" e))))))

Remember the quit command? Notice the catch 'repl-exit wrapping the loop. When quit calls (throw 'repl-exit nil), control jumps here and the function returns cleanly. This is Common Lisp’s non-local exit mechanism. Like exceptions, but more general.

The loop itself just reads, dispatches, and handles errors. All the command logic lives elsewhere. I want to believe that Uncle Bob would be proud.

Project Structure#

Alright, we have advanced quite a bit. We now have enough code to split into multiple files:

repl/
  repl.asd       # System definition
  packages.lisp  # Package exports
  core.lisp      # Command struct, registry, dispatch
  repl.lisp      # Main loop and built-in commands

repl.asd#

repl.asd
(asdf:defsystem :repl
  :description "A custom REPL in Common Lisp - Version 2: Commands"
  :version "2.0.0"
  :serial t
  :components
  ((:file "packages")
   (:file "core")
   (:file "repl")))

packages.lisp#

We export the command system so users can register their own commands:

packages.lisp
(defpackage :repl
  (:use :cl)
  (:export
   #:my-repl
   #:command
   #:make-command
   #:command-name
   #:command-description
   #:command-function
   #:register-command
   #:find-command
   #:dispatch
   #:*commands*))

core.lisp#

The command system:

core.lisp
(in-package :repl)

;;; Command structure

(defstruct command
  name
  description
  function)

;;; Command registry

(defvar *commands* (make-hash-table :test 'equal))

(defun register-command (cmd)
  (setf (gethash (symbol-name (command-name cmd)) *commands*) cmd))

(defun find-command (name)
  (gethash (symbol-name name) *commands*))

;;; Dispatcher

(defun dispatch (form)
  (let* ((name (if (consp form) (car form) form))
         (args (if (consp form) (cdr form) nil))
         (cmd (find-command name)))
    (if cmd
        (apply (command-function cmd) args)
        (format t "~%=> ~S" (eval form)))))

repl.lisp#

The main loop and built-in commands:

repl.lisp
(in-package :repl)

;;; Built-in commands

(register-command
 (make-command
  :name 'quit
  :description "Exit the REPL"
  :function (lambda ()
              (format t "~%Goodbye.~%")
              (throw 'repl-exit nil))))

(register-command
 (make-command
  :name 'help
  :description "List available commands"
  :function (lambda ()
              (format t "~%Available commands:")
              (maphash (lambda (name cmd)
                         (declare (ignore name))
                         (format t "~%  (~A) - ~A"
                                 (command-name cmd)
                                 (command-description cmd)))
                       *commands*))))

;;; The REPL

(defun my-repl ()
  "Start the REPL. Type (help) for commands, (quit) to exit."
  (format t "~%Welcome to REPL OS v2")
  (format t "~%Type (help) for commands, or any Lisp expression.~%")
  (catch 'repl-exit
    (loop
      (format t "~%λ ")
      (force-output)
      (handler-case
          (dispatch (read))
        (error (e)
          (format t "~%ERROR: ~A" e))))))

Running It#

cd repl
sbcl
(load "repl.asd")
(asdf:load-system :repl)
(repl:my-repl)
Welcome to REPL OS v2
Type (help) for commands, or any Lisp expression.

λ (help)
Available commands:
  (QUIT) - Exit the REPL
  (HELP) - List available commands

λ (+ 1 2 3)
=> 6

λ (quit)
Goodbye.

Commands and Lisp work side by side.

Adding Commands at Runtime#

Here’s something powerful: because we export the command system, users can add commands while the REPL is running:

λ (register-command
    (make-command
     :name 'greet
     :description "Say hello"
     :function (lambda (name)
                 (format t "~%Hello, ~A!" name))))
=> #S(COMMAND :NAME GREET :DESCRIPTION "Say hello" :FUNCTION #<FUNCTION>)

λ (help)
Available commands:
  (GREET) - Say hello
  (QUIT) - Exit the REPL
  (HELP) - List available commands
  
λ (greet "World")
Hello, World!

λ (quit)
Goodbye.

The command appears in help immediately. This is the Lisp advantage: the system is always live, always extensible. Note that the REPL has limitations when using multi-line inputs. This means that you should input that code as a one-liner, which is tedious. I leave it to the reader to experiment with it :)

What We Built#

Starting from a hardcoded quit check, we built:

  1. Command struct: bundles name, description, and function
  2. Registry: hash table for O(1) command lookup
  3. Dispatcher: routes to commands or falls through to eval
  4. Built-in commands: help and quit
  5. Runtime extension: users can add commands on the fly (and we added the inline command greet)

The REPL loop itself got simpler. All the complexity moved into a clean, extensible system.

What’s Next#

We have commands, but they all run synchronously, which means that the REPL blocks until they finish. What if we want a timer that counts down while we keep working? A clock that updates the prompt every second?

In Part 3, we’ll add threading. We’ll use the library Bordeaux Threads to run background tasks, we will add a ps command to list running threads, and kill to stop them. Our simple REPL will start to feel like a real operating system.


Next: Part 3: Threading