Renato Athaydes Personal Website

Sharing knowledge for a better world

Creating Lisp Systems

Written on Sun, 02 Nov 2025 21:11:00 (Last updated on Sun, 09 Nov 2025 20:04:00)
A nice World

Background vector created by upklyak - www.freepik.com

Lisp Alien Mascot

I like to fiddle with Common Lisp from time to time, but unfortunately I always run in trouble when trying to do some basic things, normally outside of the language itself:

Even though most answers can be found, at least on a surface level, either on the wonderful Common Lisp Cookbook or on the Common Lisp Docs website, they normally don’t go very deep on these topics and ends up leaving me scouring the Internet for answers.

The Cookbook is so good that I keep a local PDF open all the time while doing anything in Common Lisp. The authors were generous enough to publish it as a free PDF, and you have the option to pay for the EPUB version. If you’re learning Common Lisp, the Common Lisp Docs linked above has an extremely good tutorial, highly recommended!

For that reason, I decided to write a few notes on how I did all these things on my latest Common Lisp project.

I expect to update this as I find out more, so don’t be surprised if I change something later!

I hope this will help my future self and also perhaps you, the reader, who also enjoys Common Lisp but can’t really find things easily online. Hopefully, now it will be a tiny bit easier!

The Build System: asdf

Everyone seems to have finally converged on using ASDF for the “build tool”.

The SBCL Manual also contains a copy of the ASDF Manual that you may find more readable.

I say finally because ASDF is an acronym for Another System Definition Facility so I suppose there were many tools before… I don’t really know as I came late to this party.

ASDF calls itself a system definition facility, but if you come from literally any other language, you probably won’t know what that means, so yeah, it’s just the build tool.

However, ASDF itself does not download libraries by itself! To do that, you’ll need Quicklisp or Ultralisp.

Creating a system (think of it as a library, like a Java jar) is done on a .asd file like this:

(asdf:defsystem "my-lib"
  :description "A Library for Lisp"
  :version "0.1.0"
  :author "Renato Athaydes"
  :license "GPL"
  :components ((:file "package")
               (:file "types" :depends-on ("package"))
               (:file "main" :depends-on ("package" "types"))))

The asdf: prefix is not necessary on the REPL, but it is if running from the command line. You can avoid that by adding something like this to the beginning of the file: (import '(asdf:defsystem asdf:test-op)). Now, you can use (defsystem ...) without the asdf: prefix.

The example above expects the following files on the same directory:

Plus the asd file itself, say my-lib.asd.

If you want to put the source files in a folder like src/, just add this to the defsystem declaration:

    :pathname "src"

Normally, you do need to list every file, but at least you don’t need to include the file extension since that’s inferred.

ASDF provides some alternative ways to declare things more easily with a few conventions, but I didn’t feel like I needed that and it looked too difficult to understand how the convention is supposed to work.

The :depends-on list lets ASDF determine which files to load first. As you may know, when you call (load "file.lisp"), you must ensure that you have any declarations you’re using from other files already loaded, otherwise it errors.

An example package.lisp file could look like this:

(defpackage :my-pkg
  (:use :cl)
  (:export
   :input-stream-t
   :read-all-lines))

You can find all options you may pass to defpackage on the Hyperspec.

With a package definition in place, you can now define some code in that package.

Example types.lisp:

(in-package :my-pkg)

(deftype input-stream-t ()
  `(satisfies input-stream-p))

Example main.lisp:

(in-package :my-pkg)

(declaim (ftype (function (input-stream-t) list) read-all-lines))
(defun read-all-lines (stream)
  (loop for line = (read-line stream nil nil)
        while line collect line))

The read-all-lines function is given just as a simple example, if you need such function, use uiop:read-file-lines which is available in most Common Lisp environments.

If you want to “build” the project, you can do so as follows:

HINT: if you start SLIME while on a file buffer, the SLIME session will have the same working directory as the file’s parent directory. You can also use the C-c ~ shortcut in emacs to set that to the current file after you’ve started SLIME.

asdf:make takes the actual name of the system as declared by defsystem.

To just load it in the REPL, you can also use asdf:load-system:

asdf:make is documented as follows:

The recommended way to interact with ASDF3.1 is via (ASDF:MAKE :FOO).
It will build system FOO using the operation BUILD-OP,
the meaning of which is configurable by the system, and
defaults to LOAD-OP, to load it in current image.

While asdf:load-system is shorthand for (operate 'asdf:load-op system).

To avoid issues with loading dependencies, you can replace asdf:load-system with ql:quickload (from Quicklisp), as that ensures all dependencies are installed before loading the system.

Feel free to dive into the ASDF documentation if you really want to know what that means!

Testing

Once you have a system, you want to make sure it works! Enter testing.

First thing we need to do is create a new ASDF system (it can be on the same asd file) for testing and then declare that the main package is tested by that.

Here’s what the asd file should look like now:

(asdf:defsystem "my-lib"
  :description "A Library for Lisp"
  :version "0.1.0"
  :author "Renato Athaydes"
  :license "GPL"
  :components ((:file "package")
               (:file "types" :depends-on ("package"))
               (:file "main" :depends-on ("package" "types")))
  :in-order-to ((asdf:test-op (asdf:test-op "my-lib/tests"))))

(asdf:defsystem "my-lib/tests"
  :depends-on ("my-lib" "parachute")
  :components ((:module "test"
                :components ((:file "package")
                             (:file "basic" :depends-on ("package")))))
  :perform (asdf:test-op (o c) (uiop:symbol-call :parachute :test :my-pkg/tests)))

We needed to add a :in-order-to declaration in the original system because that allows ASDF to know that in order to test the system, it should use another system’s test-op…

It seems to be a convention to call the test system <system>/tests, which makes sense!

The test system itself is just a normal looking system, except that it defines a perform for the test-op ASDF operation. That, in turn, declares some Common Lisp function, which in this case happens to call the Parachute test library with the name of the test package:

(uiop:symbol-call :parachute :test :my-pkg/tests)

I decided to define a module to group the test files and show how that works, but that’s completely unnecessary. A module named test will, by default, look for files in the test directory, so it sounded like a good thing to me (though using :pathname, as mentioned before, would probably be more adequate).

As with the main system, we define a package file, test/package.lisp:

(defpackage :my-pkg/tests
  (:use :cl :my-pkg :parachute))

And then a first test file I named test/basic.lisp:

(in-package :my-pkg/tests)

(defvar *multiline-string* "hello
  world")

(define-test can-read-all-lines
  (let* ((st (make-string-input-stream *multiline-string*))
         (result (read-all-lines st)))
    (is equal "foo bar" result)))

Before we can run the tests, we need to load the system again:

(load "my-lib.asd")

If this does not work, run slime-repl-sayoonara to kill SLIME and then try again. Make sure to start SLIME while on the buffer with the asd file to avoid file path issues.

Once that works, you can run the tests with:

(asdf:test-system "my-lib")

If all worked well, you should see the Parachute report:

;; Summary:
Passed:     0
Failed:     1
Skipped:    0

;; Failures:
   1/   1 tests failed in MY-PKG/TESTS::CAN-READ-ALL-LINES
The test form   result
evaluated to    ("hello" "  world")
when            "foo bar"
was expected to be equal under EQUAL.

I let it fail intentionally 😉 as a failing test report is more interesting!

Here’s the fixed test:

(define-test can-read-all-lines
  (let* ((st (make-string-input-stream *multiline-string*))
         (result (read-all-lines st)))
    (is equal '("hello" "  world") result)))

Saving the file and trying again, it passes now:

; compilation finished in 0:00:00.004
        ? MY-PKG/TESTS::CAN-READ-ALL-LINES
  0.000 ✔   (is equal '("hello" "  world") result)
  0.017 ✔ MY-PKG/TESTS::CAN-READ-ALL-LINES

;; Summary:
Passed:     1
Failed:     0
Skipped:    0

Finally, it’s also possible to run particular tests as you just recompile anything on SLIME, keeping your development flow as interactive as ever:

(in-package :my-pkg/tests)
(parachute:test :can-read-all-lines)

Parachute has lots of options for customizing the reports and everything else, it’s a nice library for testing. Check it out if you want to learn more.

The Cookbook recommends Fiveam, but that has lots of issues and I can’t really agree with that.

Finally, let me mention that I found this little helper function somewhere (actually Claude gave it to me!) that is really helpful when you just need to check that some function is being invoked with the right arguments under some test:

(defmacro with-temporary-function ((name new-fn) &body body)
  "Replace some function within the scope of BODY."
  `(let ((old (fdefinition ,name)))
     (unwind-protect
          (progn
            (setf (fdefinition ,name) ,new-fn)
            ,@body)
       (setf (fdefinition ,name) old))))

Now, it’s possible to do something like this in your test:

(defmacro mocking-format ((captured-args) &body body)
  "Another helper macro to mock a specific function, `my-format` in this case."
  `(let ((,captured-args nil))
     (with-temporary-function
         ('mypkg::my-format
          (lambda (destination control-string &rest format-arguments)
            (setf ,captured-args (list destination control-string format-arguments))))
       ,@body)))

;; Elsewhere in an actual test:
(define-test my-format-gets-the-expected-args
  (let ((captured-args (mocking-format (format-args)
                         (a-function-that-may-call-my-format T "hello" :other-args)
                         format-args)))
    (is equalp (list T "~A[~Am~A~A" (list #\ESC 28 "hello" +reset-all+)) captured-args)))

The test above is checking the my-format function was invoked with the expected arguments!

This may not work if you compile the code under test without debug information! Which brings us to debugging (the next section explains how to avoid this problem)…

Debugging line by line

Ok, debugging is actually pretty well covered in the Cookbook’s section about debugging.

But because it’s so comprehensive, it’s long and therefore hard to quickly search through, so here’s a very brief summary:

The :break argument can be a boolean expression (conditional breakpoint):

(trace myfunction :break (equal 0 (sb-debug:arg 0)))

(myfunction 0) ;; this will trigger the debugger.
(myfunction 1) ;; this will not!

You can also include a (break) call inside the function itself to explicitly trigger the interactive debugger, if one is available, every time the function is called.

Stop tracing with (untrace myfunction).

While on the interactive debugger, the available commands are:

If on a breakpoint:

If you can’t step through some code, re-compile the whole file with C-u C-c C-k or just some functions with C-u C-c C-c to enable full debugging. You can also add this to some files to make that automatic (while you’re debugging):

(declaim (optimize (debug 3)))

In emacs, you can open the SLIME Manual with C-h R, then write slime. There’s a whole section about the debugger.

You can also use numbers to select one of the available restarts, of course.

Anyone familiar with other more “conventional” debuggers should be familiar with the commands above. It just works slightly different in Common Lisp.

My workflow is to call (trace ... :break T) on the target function, then hit x and v repeatedly to see the code which is executing and its arguments. It’s extremely useful to get familiar with the order methods are called, or example, on unfamiliar code bases.

In some Common Lisp distributions, trace also accepts the :methods T argument to show CLOS methods being invoked, in order.

Creating binaries for distribution

If you’re using SBCL, you probably know that you can create a binary from a Lisp image quite easily with the infamously named save-lisp-and-die function:

(load "main.lisp")

(sb-ext:save-lisp-and-die
 #P"my-binary"
 :toplevel #'run
 :executable t
 :compression t)

Very cool… but ASDF also lets you do it, and in whatever Common Lisp environment you happen to be running on.

First, we need to write a main function, of course. Save this on entrypoint.lisp:

(in-package :my-pkg)

(defun my-main ()
  "This is my applications's main function!"
  (let ((files (uiop:command-line-arguments)))
    (dolist (file files)
      (with-open-file (st file)
        (format t "~A~%" (read-all-lines st))))))

It will just print all lines of the files passed as arguments to it, similar to cat.

Now, add the following lines to the main system declaration:

  :build-operation program-op
  :build-pathname "my-app"
  :entry-point "my-pkg::my-main"

You also need to add the new file to :components, of course. For clarity, here’s the whole file with these added:

(import '(asdf:defsystem asdf:test-op))

(defsystem "my-lib"
  :description "A Library for Lisp"
  :version "0.1.0"
  :author "Renato Athaydes"
  :license "GPL"
  :build-operation program-op
  :build-pathname "my-app"
  :entry-point "my-pkg::my-main"
  :components ((:file "package")
               (:file "types" :depends-on ("package"))
               (:file "main" :depends-on ("package" "types"))
               (:file "entrypoint" :depends-on ("main")))
  :in-order-to ((test-op (test-op "my-lib/tests"))))

(defsystem "my-lib/tests"
  :depends-on ("my-lib" "parachute")
  :components ((:module "test"
                :components ((:file "package")
                             (:file "basic" :depends-on ("package")))))
  :perform (test-op (o c) (uiop:symbol-call :parachute :test :my-pkg/tests)))

Unfortunately, we can no longer run asdf:make from SLIME since to build an executable from a Lisp image requires a single Thread to be running. So, the way to do that is to run sbcl (or you preferred Common Lisp implementation) directly from the command line.

As you’ll probably do this often, create a bash script, build.sh, to make it simple to do:

#! /bin/sh

sbcl --script /dev/stdin <<'EOF'
(require "asdf")
(load "my-lib.asd")
(asdf:make :my-lib)
EOF

Don’t forget to chmod +x build.sh so you can execute it… which we can do right away!

./build.sh

You should now have an executable file named my-lib that’s ready to run. On my Mac, the file is 34MB big, which is ok considering it embeds the full Lisp image.

> du -h ./my-app
34M	./my-app

SBCL binaries are extremely fast, by the way!

Creating a script to execute the tests should be trivial now and is left as an exercise to the reader.

Conclusion

That’s all I have for now. Hope this was helpful.

As a summary, here’s the stuff we’ve used:

If you still can’t figure something out, try asking an LLM. I used Claude when nothing else would do and I was getting desperate, and to my surprise it helped immensely! Claude knows Common Lisp surprisingly well given the lack of resources when compared to other much larger languages!