Creating Lisp Systems

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 mock during tests?
- how to debug code with a line-by-line debugger?
- 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
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 theasdf:prefix.
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))
The
read-all-linesfunction is given just as a simple example, if you need such function, useuiop:read-file-lineswhich is available in most Common Lisp environments.
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. 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-systemwithql: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:
- Step into a function call directly:
(step (myfunction args)). See step. - Enable a breakpoint at the beginning of any function:
(trace myfunction :break T).
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:
v- shows the source code at the cursor stack trace call location.t(orEnter) - toggle stack trace frame details.e- evaluate code within the frame.r- restart the frame. You can recompile and jump right back in.R- return from the frame with a value you enter in the minibuffer.n- move down one frame.p- move up one frame.q- quit the debugger.
If on a breakpoint:
c- continue evaluation (same as theCONTINUErestart option).s- step to the next expression in the frame.x- step to the next form in the current function.o- step out of the current function.
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 writeslime. 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:
- Common Lispâs modern, searchable documentation.
- Common Lisp Cookbook.
- ASDF System Build Tool.
- Parachute Testing Framework.
- SBCLâs Manual.
- Quicklisp package manager.
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!