Scripting in Common Lisp

Esperanto · English
July 5, 2017
Last updated: September 24, 2018

The light that burns twice as bright burns half as long.
―Dr. Eldon Tyrell, Blade Runner (1982)

common-lisp.net logo

Full-fledged systems and libraries have always been a comfortable zone for Common Lisp users. However, for a long time, there has not been a definitive solution in using CL as a scripting language. A scripting language, in this context, means something that is similar in spirit to command line shells—that is, one that is used to issue, control, and manage system commands on the application level. The meaning also extends to the automation of the execution of tasks that are otherwise done one-by-one. In this article, I will give a short introduction on how to use CL in the scripting domain.

Table of contents

Overview

One of the most common questions I get when I mention that I want to do scripting in CL, is that why would I want to do so and is it possible. The answer is simple: I want more power and expressivity. I want a mature and unencumbered language. I want a language that is able to express my ideas, in least amount of friction.

A script is only as powerful as the language and tools would allow. Bash and friends, for example, are great for expressing ideas, as if you are typing them on the command line itself. It emulates that behavior inside a script. You can define functions to do subroutines, but they’re just that. Functions in Bash are nowhere near functions in languages like CL. As an interactive user shell, it works fine; other than that, no.

Other scripting solutions exist in other languages. Haskell, Python, Scheme, and Ruby, to name a few, has it. However, there’s a neat feature of CL, that is difficult to implement or non-existent in other approaches: since the scripts themselves are valid CL programs, I can load the programs in the REPL and do nice things with it. Nothing comes close to the flexibility that CL provides when interacting with live, running programs.

In this short tutorial, I will also lightly gloss about one nice thing with CL scripting: multi-call binaries. A multi-call binary is a single executable file that can be dereferenced with many names. Each name corresponds to a specific subroutine inside that single binary. The beauty of this approach is that instead of managing many different programs, you only manage one, and it will dispatch the correct subprogram that a user wants. This is similar to what Busybox is doing. In CL, this is handled by cl-launch.

Prerequisites

Scripting in CL works on top of the language, that is, in the form of libraries that provide the abstractions to interact with the system and environment. Utilities for Implementation- and OS- Portability (UIOP) is a set of abstractions that lets us use and write portable CL code. It does the heavy lifting of making sure that we are going to write portable Lisp code. UIOP is part of ASDF3—which is part of most modern CL implementations—so there is no need to manually install it. inferior-shell helps us with managing processes. cl-scripting helps us with more process control.

The program cl-launch must also be installed in your system. It will be responsible in creating the multi-call binary, itself. To install cl-launch on systems that use APT:

$ sudo apt-get install -y cl-launch

To install on systems that use Nix:

$ nix-env -i cl-launch

Basics

Paths

To get started, let’s create a new project directory. We will build our project in $HOME/common-lisp.

$ mkdir -p ~/common-lisp/my-scripts

This directory is one of the standard paths that ASDF will crawl, for .asd files. It is worth nothing that it doesn’t matter if $HOME/common-lisp is a regular directory or a symlink to one.

Definitions

Then, let’s create my-scripts.asd in that directory. To start, it will contain the following:

#-asdf3.1 (error "ASDF 3.1 or bust!")

(defsystem "my-scripts"
  :version "0.0.1"
  :description "CL scripts"
  :license "MIT"
  :author "Muno VAKELO"
  :class :package-inferred-system
  :depends-on ((:version "cl-scripting" "0.1")
               (:version "inferior-shell" "2.0.3.3")
               (:version "fare-utils" "1.0.0.5")
               "my-scripts/main"))

Some of the features that we need are in ASDF 3.1, so we need to conditionalize the whole system. We declare dependencies on cl-scripting, which provides some helpers; and inferior-shell, which provides the things that we need for managing shell processes

Next, let’s create the file main.lisp, in the same directory. It will contain the following:

(uiop:define-package #:my-scripts/main
    (:use #:cl
          #:uiop
          #:cl-scripting
          #:inferior-shell
          #:fare-utils
          #:cl-launch/dispatch)
  (:export #:symlink
           #:help))

(in-package #:my-scripts/main)

(exporting-definitions
 (defun symlink (src)
   "Build the symlinks."
   (let ((binarch (resolve-absolute-location
                   `(,(subpathname (user-homedir-pathname) "bin/")) :ensure-directory t)))
     (with-current-directory (binarch)
       (dolist (i (cl-launch/dispatch:all-entry-names))
         (run `(ln -sf ,src ,i)))))
   (success))

 (defun help ()
   "Display usage."
   (format! t "~A commands: ~{~A~^ ~}~%" (get-name) (all-entry-names))
   (success))

 (defun main (&rest args)
   "Top-level function."
   (format t "main~%")))

(register-commands :my-scripts/main)

We’re going to start by using UIOP:DEFINE-PACKAGE. Unlike DEFPACKAGE, this creates the necessary environment that is friendly to UIOP. In the :USE clause, we’re going to use helpers from other libraries. In the body of this file, you can see EXPORTING-DEFINITIONS. This marker effectively marks the boundaries of what will be created as an executable, or not. It will be used by REGISTER-COMMANDS, later.

Here, we define several functions: SYMLINK is responsible for creating the symlinks for the multi-call binary; HELP displays some basic usage information; and MAIN is the entrypoint of our script. The multi-call binary will be available in $HOME/bin/. To make it convenient to build the script and the symlinks, we’re going to put the build instructions in a Makefile. Create the file Makefile in the current directory, then put in the following:

NAME=my-scripts
BINARY=$(HOME)/bin/$(NAME)
SCRIPT=$(PWD)/$(NAME)
CL=cl-launch

.PHONY: all $(NAME) clean

all: $(NAME)

$(NAME):
	@$(CL) --output $(NAME) --dump ! --lisp sbcl --quicklisp --system $(NAME) --dispatch-system $(NAME)/main

install: $(NAME)
	@ln -sf $(SCRIPT) $(BINARY)
	@$(SCRIPT) symlink $(NAME)

clean:
	@rm -f $(NAME)

In the $(NAME) target, we call cl-launch with options to build the script. In the install target, we invoke the script with the options symlink $(NAME), to build the symlinks for the multi-call binary. Since we only defined three functions within the body of EXPORTING-DEFINITIONS, it is only going to build three symlinks to my-scripts. The ‑‑output $(NAME) option specifies the output file. The ‑‑dump ! means to create an image, to enable a faster startup. The ‑‑lisp sbcl option specifies that we want to use SBCL, for this script. The option ‑‑quicklisp specifies that we load Quicklisp with the image. The ‑‑system $(NAME) loads the system the we are building. The ‑‑dispatch-system $(NAME)/main specifies the entrypoint of our program.

Building

We are now ready to build the script and the symlinks. To do that, run:

$ make install

This will build the multi-call binary—./my-scripts and the corresponding symbolic links. The directory tree of ~/bin should look like the following:

$ tree ~/bin
/home/vakelo/bin
├── getuid -> my-scripts
├── help -> my-scripts
├── my-scripts -> /home/vakelo/common-lisp/my-scripts/my-scripts
└── symlink -> my-scripts

0 directories, 5 files

To test that it indeed works, run:

$ getuid

If it displays your UID, we’re good to go.

More

Say, you want to know the battery status of your laptop from the command line. We can define that with several functions. Let’s modify my-script.asd to contain the additional declaration:

#-asdf3.1 (error "ASDF 3.1 or bust!")

(defsystem "my-scripts"
  :version "0.0.1"
  :description "CL scripts"
  :license "MIT"
  :author "Muno VAKELO"
  :class :package-inferred-system
  :depends-on ((:version "cl-scripting" "0.1")
               (:version "inferior-shell" "2.0.3.3")
               (:version "fare-utils" "1.0.0.5")
               "my-scripts/main"
               "my-scripts/general"))

Then, let’s populate the file general.lisp with the following contents:

(uiop:define-package #:scripts/general
    (:use #:cl
          #:fare-utils
          #:uiop
          #:cl-scripting
          #:inferior-shell
          #:cl-launch/dispatch
          #:optima
          #:optima.ppcre
          #:local-time)
  (:export #:battery
           #:screenshot))

(in-package #:scripts/general)

(defvar *screenshots-dir*
  (subpathname (user-homedir-pathname) "Desktop/"))

(defun battery-status ()
  "Return battery status."
  (let ((base-dir "/sys/class/power_supply/*")
        (exclude-string "/AC/"))
    (with-output (s nil)
      (loop :for dir
            :in (remove-if #'(lambda (path)
                               (search exclude-string (native-namestring path)))
                           (directory* base-dir))
            :for battery = (first (last (pathname-directory dir)))
            :for capacity = (read-file-line (subpathname dir "capacity"))
            :for status = (read-file-line (subpathname dir "status"))
            :do (format s "~A: ~A% (~A)~%" battery capacity status)))))

(exporting-definitions
 (defun battery ()
   "Display battery status."
   (format t "~A" (battery-status))
   (values))

 (defun screenshot (mode)
   "Take a screenshot."
   (let* ((dir *screenshots-dir*)
          (file (format nil "~A.png" (format-timestring nil (now))))
          (dest (format nil "mv $f ~A" dir))
          (image (format nil "~A/~A" dir file)))
     (flet ((scrot (file dest &rest args)
              (run/i `(scrot ,@args ,file -e ,dest))))
       (match mode
         ((ppcre "(full|f)") (scrot file dest))
         ((ppcre "(region|r)") (scrot file dest '-s))
         (_ (err (format nil "invalid mode ~A~%" mode))))
       (run `(xclip -selection clipboard) :input (list image))
       (success)))))

(register-commands :scripts/general)

In the definition of BATTERY, it outputs the return value of (BATTERY-STATUS), in a human friendly way, i.e., sans the double quotes. The BATTERY function then returns no values. We need to do this because we only want the output of the call to BATTERY-STATUS.

The function SCREENSHOT, on the other hand, takes a screenshot with scrot then makes the absolute path of the image available from the clipboard selection, using xclip. We use the libraries local-time, for the date string and library; and optima, for the pattern matching. For the command screenshot to work, install the binary dependencies. Run the following commands for Debian and Nix systems, respectively:

$ sudo apt-get install -y scrot xclip
$ nix-env -i scrot xclip

Launching and managing user applications is easy. Let’s start by adding a dependency in my-scripts.asd:

#-asdf3.1 (error "ASDF 3.1 or bust!")

(defsystem "my-scripts"
  :version "0.0.1"
  :description "CL scripts"
  :license "MIT"
  :author "Lolu VAKELO"
  :class :package-inferred-system
  :depends-on ((:version "cl-scripting" "0.1")
               (:version "inferior-shell" "2.0.3.3")
               (:version "fare-utils" "1.0.0.5")
               "my-scripts/main"
               "my-scripts/general"
               "my-scripts/apps"))

Then, let’s populate apps.lisp:

(uiop:define-package #:scripts/apps
    (:use #:cl
          #:fare-utils
          #:uiop
          #:inferior-shell
          #:cl-scripting
          #:cl-launch/dispatch)
  (:export #:chrome
           #:kill-chrome
           #:stop-chrome
           #:continue-chrome))

(in-package #:scripts/apps)

(exporting-definitions
 (defun chrome (&rest args)
   "Run the Chrome browser."
   (run/i `(google-chrome-beta ,@args)))

 (defun kill-chrome (&rest args)
   "Send a KILL signal to the Chrome process."
   (run `(killall ,@args chromium-browser chromium google-chrome chrome)
        :output :interactive :input :interactive :error-output nil :on-error nil)
   (success))

 (defun stop-chrome ()
   "Send a STOP signal to the Chrome process."
   (kill-chrome "-STOP"))

 (defun continue-chrome ()
   "Send a CONT signal to the Chrome process."
   (kill-chrome "-CONT")))

(register-commands :scripts/apps)

Let’s rebuild my-scripts:

$ make install
my-scripts available commands: battery chrome continue-chrome help kill-chrome main screenshot stop-chrome symlink

Yay!

Caveats

An important thing to note is that in the definitions, you can’t use a CL keyword as the name of the command. So inside EXPORTING-DEFINITIONS, you can’t have something like this:

(exporting-definitions
  (defun t (&rest args)
    (run/i `(terminator ,@args)`)))

If you do, and try to compile the file, your CL implementation will complain about a name that is already in use.

Closing Remarks

It has been said many times that CL has already faded into obscurity; that no one longer uses it; that it is no longer useful. No, that is not true. Just because it is not being discussed in mainstream news, means it is dead or have fallen out of favor. CL is a standardized language, and a program that conforms to the standard has the guarantee—to an extent—that it can still run in the future. To create a language standard is a monumental task—it requires that different, possibly conflicting parties, to agree to how things should be done. There are different implementations of CL, and each implementation strives to achieve goals that may not necessarily be compatible with other implementations. That’s OK, because it gives room for implementors and designers, on how to work on the base specifications. As long as they conform to the standard, things are green.

baf, a Nixpkgs/NixOS helper, is a working example of CL scripting. pelo, a ping wrapper, is also another example that uses this facility. I wrote several personal helper scripts, that I hooked with my StumpWM config. The code used for this article can be found here.

The human responsible for making scripting in CL possible and acceptable, is François-René Rideau. It was this blog entry that motivated me to see the viability of CL as a scripting language. You may send your donations to him via PayPal or Bitcoin.

The banner image used at the top is from common-lisp.net.

Thanks to Raymund Martinez for the corrections.