In 2018, I wrote a blog post and the Cookbook page on how to build Common Lisp binaries, and how to parse command-line arguments with the unix-opts library.

But since then, new libraries were created an they are pretty good! They are simpler to use, and have much more features. I had a good experience with Clingon: its usage is clear, its documentation is very good, it is very flexible (it has hooks and generic functions waiting to have an :around method) and @dnaeon is not at his first great CL project.

You might give adopt a look, or maybe defmain though I felt a little something was missing.

So I updated the guide to use Clingon. Let’s go.

=> This article is best read on the Common Lisp Cookbook where it will receive updates.

As a reminder to this often-asked question, my SBCL standalone binaries, with dozens of dependencies (SBCL’s compiler and debugger (very useful to load code during the application’s lifecycle), a web server, static assets and other libraries) weight about 30MB and start in ±0.4s, with SBCL compression. Without compression, it’s more about 130MB and 0.01s.

Parsing command line arguments

SBCL stores the command line arguments into sb-ext:*posix-argv*.

But that variable name differs from implementations, so we want a way to handle the differences for us.

We have (uiop:command-line-arguments), shipped in ASDF and included in nearly all implementations. From anywhere in your code, you can simply check if a given string is present in this list:

(member "-h" (uiop:command-line-arguments) :test #'string-equal)

That’s good, but we also want to parse the arguments, have facilities to check short and long options, build a help message automatically, etc.

We chose the Clingon library, because it may have the richest feature set:

  • it handles subcommands,
  • it supports various kinds of options (flags, integers, booleans, counters, enums…),
  • it generates Bash and Zsh completion files as well as man pages,
  • it is extensible in many ways,
  • we can easily try it out on the REPL
  • etc

Let’s download it:

(ql:quickload "clingon")

As often, work happens in two phases:

  • we first declare the options that our application accepts, their kind (flag, string, integer…), their long and short names and the required ones.
  • we ask Clingon to parse the command-line options and run our app.

Declaring options

We want to represent a command-line tool with this possible usage:

$ myscript [-h, --help] [-n, --name NAME]

Ultimately, we need to create a Clingon command (with clingon:make-command) to represent our application. A command is composed of options and of a handler function, to do the logic.

So first, let’s create options. Clingon already handles “–help” for us, but not the short version. Here’s how we use clingon:make-option to create an option:

(clingon:make-option
 :flag                ;; <--- option kind. A "flag" does not expect a parameter on the CLI.
 :description "short help"
 ;; :long-name "help" ;; <--- long name, sans the "--" prefix, but here it's a duplicate.
 :short-name #\h      ;; <--- short name, a character
 ;; :required t       ;; <--- is this option always required? In our case, no.
 :key :help)          ;; <--- the internal reference to use with getopt, see later.

This is a flag: if “-h” is present on the command-line, the option’s value will be truthy, otherwise it will be falsy. A flag does not expect an argument, it’s here for itself.

Similar kind of options would be:

  • :boolean: that one expects an argument, which can be “true” or 1 to be truthy. Anything else is considered falsy.
  • :counter: a counter option counts how many times the option is provided on the command line. Typically, use it with -v / --verbose, so the user could use -vvv to have extra verbosity. In that case, the option value would be 3. When this option is not provided on the command line, Clingon sets its value to 0.

We’ll create a second option (“–name” or “-n” with a parameter) and we put everything in a litle function.

;; The naming with a "/" is just our convention.
(defun cli/options ()
  "Returns a list of options for our main command"
  (list
   (clingon:make-option
    :flag
    :description "short help."
    :short-name #\h
    :key :help)
   (clingon:make-option
    :string              ;; <--- string type: expects one parameter on the CLI.
    :description "Name to greet"
    :short-name #\n
    :long-name "name"
    :env-vars '("USER")     ;; <-- takes this default value if the env var exists.
    :initial-value "lisper" ;; <-- default value if nothing else is set.
    :key :name)))

The second option we created is of kind :string. This option expects one argument, which will be parsed as a string. There is also :integer, to parse the argument as an integer.

There are more option kinds of Clingon, which you will find on its good documentation: :choice, :enum, :list, :filepath, :switch and so on.

Top-level command

We have to tell Clingon about our top-level command. clingon:make-command accepts some descriptive fields, and two important ones:

  • :options is a list of Clingon options, each created with clingon:make-option
  • :handler is the function that will do the app’s logic.

And finally, we’ll use clingon:run in our main function (the entry point of our binary) to parse the command-line arguments, and apply our command’s logic. During development, we can also manually call clingon:parse-command-line to try things out.

Here’s a minimal command. We’ll define our handler function afterwards:

(defun cli/command ()
  "A command to say hello to someone"
  (clingon:make-command
   :name "hello"
   :description "say hello"
   :version "0.1.0"
   :authors '("John Doe <john.doe@example.org")
   :license "BSD 2-Clause"
   :options (cli/options) ;; <-- our options
   :handler #'null))  ;; <--  to change. See below.

At this point, we can already test things out on the REPL.

Testing options parsing on the REPL

Use clingon:parse-command-line: it wants a top-level command, and a list of command-line arguments (strings):

CL-USER> (clingon:parse-command-line (cli/command) '("-h" "-n" "me"))
#<CLINGON.COMMAND:COMMAND name=hello options=5 sub-commands=0>

It works!

We can even inspect this command object, we would see its properties (name, hooks, description, context…), its list of options, etc.

Let’s try again with an unknown option:

CL-USER> (clingon:parse-command-line (cli/command) '("-x"))
;; => debugger: Unknown option -x of kind SHORT

In that case, we are dropped into the interactive debugger, which says

Unknown option -x of kind SHORT
   [Condition of type CLINGON.CONDITIONS:UNKNOWN-OPTION]

and we are provided a few restarts:

Restarts:
 0: [DISCARD-OPTION] Discard the unknown option
 1: [TREAT-AS-ARGUMENT] Treat the unknown option as a free argument
 2: [SUPPLY-NEW-VALUE] Supply a new value to be parsed
 3: [RETRY] Retry SLIME REPL evaluation request.
 4: [*ABORT] Return to SLIME's top level.

which are very practical. If we needed, we could create an :around method for parse-command-line, handle Clingon’s conditions with handler-bind and use its restarts, to do something different with unknown options. But we don’t need that yet, if ever: we want our command-line parsing engine to warn us on invalid options.

Last but not least, we can see how Clingon prints our CLI tool’s usage information:

CL-USER> (clingon:print-usage (cli/command) t)
NAME:
  hello - say hello

USAGE:
  hello [options] [arguments ...]

OPTIONS:
      --help          display usage information and exit
      --version       display version and exit
  -h                  short help.
  -n, --name <VALUE>  Name to greet [default: lisper] [env: $USER]

AUTHORS:
  John Doe <john.doe@example.org

LICENSE:
  BSD 2-Clause

We can tweak the “USAGE” part with the :usage key parameter of the lop-level command.

Handling options

When the parsing of command-line arguments succeeds, we need to do something with them. We introduce two new Clingon functions:

  • clingon:getopt is used to get an option’s value by its :key
  • clingon:command-arguments gets use the free arguments remaining on the command-line.

Here’s how to use them:

CL-USER> (let ((command (clingon:parse-command-line (cli/command) '("-n" "you" "last"))))
           (format t "name is: ~a~&" (clingon:getopt command :name))
           (format t "free args are: ~s~&" (clingon:command-arguments command)))
name is: you
free args are: ("last")
NIL

It is with them that we will write the handler of our top-level command:

(defun cli/handler (cmd)
  "The handler function of our top-level command"
  (let ((free-args (clingon:command-arguments cmd))
        (name (clingon:getopt cmd :name)))  ;; <-- using the option's :key
    (format t "Hello, ~a!~%" name)
    (format t "You have provided ~a more free arguments~%" (length free-args))
    (format t "Bye!~%")))

We must tell our top-level command to use this handler:

;; from above:
(defun cli/command ()
  "A command to say hello to someone"
  (clingon:make-command
   ...
   :handler #'cli/handler))  ;; <-- changed.

We now only have to write the main entry point of our binary and we’re done.

By the way, clingon:getopt returns 3 values:

  • the option’s value
  • a boolean, indicating wether this option was provided on the command-line
  • the command which provided the option for this value.

See also clingon:opt-is-set-p.

Main entry point

This can be any function, but to use Clingon, use its run function:

(defun main ()
  "The main entrypoint of our CLI program"
  (clingon:run (cli/command)))

To use this main function as your binary entry point, see above how to build a Common Lisp binary. A reminder: set it in your .asd system declaration:

:entry-point "my-package::main"

And that’s about it. Congratulations, you can now properly parse command-line arguments!

Go check Clingon’s documentation, because there is much more to it: sub-commands, contexts, hooks, handling a C-c (see also the Cookbook for that), developing new options such as an email kind, Bash and Zsh completion…


Thanks for reading and thanks again to @dnaeon.