Creating Lisp Systems
Written on Sun, 02 Nov 2025 21:11:00 +0000 (Last updated on Sun, 02 Nov 2025 22:44:00 +0000)

Background vector created by upklyak - www.freepik.com

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:
- how do I build a binary, including on CI (build server pipelines)?
- how do I write and run tests?
- how to describe/document a project for publication?
- where to r…
Creating Lisp Systems
Written on Sun, 02 Nov 2025 21:11:00 +0000 (Last updated on Sun, 02 Nov 2025 22:44:00 +0000)

Background vector created by upklyak - www.freepik.com

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:
- how do I build a binary, including on CI (build server pipelines)?
- how do I write and run tests?
- how to describe/document a project for publication?
- where to read the hyperspec like it’s not 1992 anymore?
- which tools are used for all that?
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 example above expects the following files on the same directory:
- package.lisp
- types.lisp
- main.lisp
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))
If you want to “build” the project, you can do so as follows:
- in SLIME (or your favourite REPL), run
(load "my-lib.asd"). You could just runslime-load-filewhile on the file buffer in emacs, same thing. - to actually build the system, run
(asdf:make "my-lib").
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.
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).
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.
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:
(asdf: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 ((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)))
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:
- Common Lisp’s modern, searchable documentation.
- Common Lisp Cookbook.
- ASDF System Build Tool.
- Parachute Testing Framework.
- SBCL’s Manual.
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!