You know, Clojure just doesn’t get used for scripting as often as it should. And that’s a shame. Clojure is concise, readable, and eminently useful: excellent qualifications for any scripting language.

Common operations in the scripting domain, such as reading and writing files, or performing HTTP requests, are a snap using Clojure … but unlike Bash, there’s truly endless power on tap when you really need it.

But there’s also challenges to using Clojure for scripting: The JVM kind of gets in the way. And scripting often means parsing command line options, so now you need to deal with that. And anything interesting you are doing seems to need some Clojure or Java libraries, so time to bring in something that understands how to download Maven dependencies and get them on the classpath. And before you know it, you’re using Leiningen (a build tool) or just throwing up your hands and going back to Ruby or Python. Or Bash.

And that’s a shame. Because you’ve overlooked a really nice, really well thought out alternative: Boot.

Boot really nails a way of just getting things done in Clojure. You can write a minimal amount of code in clean, simple Clojure and get command line options and a sophisticated runtime environment for free.

Boot scales from one-off scripts up to very sophisticated publishing pipelines. And it does it with just a couple of core concepts: composable tasks and immutable filesets.

Build tools are generally declarative: you describe your goals and project layout, and the tool (Maven, Gradle, even Make) tries to figure out what commands to run, and in what order, to achieve your goal.

Boot upends that almost entirely: there’s no hierarchy of goals, or thicket of plugins: just tasks. There’s no dependency ordering: you specify the tasks to execute and the order of execution. Boot just makes it easy to define those tasks, and compose them together. And, of course, it deals with Maven artifacts and the classpath, so you can use any libraries, or even bundles of pre-written tasks, without a second thought.

Much has been said about the power of composability; often in terms of Unix processes connected by pipes. When you reflect on it, a pipe is a sequential, immutable data structure … but a very limited one. Boot keeps the idea of small, focused commands (the tasks), but replaces the data passed between those tasks with an immutable fileset (which also happens to be a persistent Clojure record).

The fileset is a set of directories and files that can be treated as an immutable Clojure value. A task can manipulate the fileset, for instance, to add new directories and files to the fileset: like any good Clojure value, this results in a new fileset value without invalidating the old fileset value.

That’s a bit of a tricky proposition, because the fileset is half a set of data in memory, and half directories and files on the file system. In practical (and simplified!) terms, Boot creates and manages a file system directory to back the fileset. When the fileset changes, a new directory is created. The new directory contains any new or changed files, as well as hard links to unchanged files.

Tasks fit into this pattern as the agents that manipulate the fileset and, sometimes, perform other side-effects outside of the fileset. For example, the built in javac task exists to run the Java compiler (that’s the side effect) and add the resulting .class files to the fileset (for later packaging into a JAR file).

Tasks fit together into a pipeline. At startup, boot parses command line options and uses them to identify tasks to execute. For example:

> boot pom jar install

This invokes boot, which takes care of setting up the classpath. It identifies three tasks: pom, jar, and install. In this example, the default options for each of the tasks get the job done. The fileset is initialized by boot, then passed through the pom task, then the jar task, and then the install task.

Tasks can also accept command line options:

> boot pom --project example --version 0.1 -- jar -- install

Writing pom.xml and pom.properties...

Writing example-0.1.jar...

Installing example-0.1.jar...

Here, the pom task has command line options: —-project and --version . The use of double dashes to separate tasks is not specifically necessary here (but still recommended for readability).

Each task is a function that accepts command line options, and returns middleware. Each middleware accepts a handler and returns a new handler: the handlers each accept and return a fileset.

That seems like a whole lot, but it is patterned on the familiar approach used in Ring, where handlers accept Request maps and return Response maps.

The middleware for all the tasks are composed using clojure.core/comp and invoked to generate a pipeline. The value that flows through the pipeline is the fileset.

In practice, Boot provides macros that keep things terse and easy.

Here’s Hello World in Boot:

;; build.boot (deftask hello

"Prints a greeting."

[g greeting GREETING str "Greeting to use."

n name NAME str "name to greet."]

(with-pass-thru _

(info "%s, %s

" greeting name)))



(task-options! hello {:greeting "Hello" :name "Howard"})

When boot launches, it looks for a file named build.boot in the current directory. This file simply contains Clojure code with a few namespaces automatically imported. The above example defines a task, hello, and provides default options for the task.

The use of with-pass-thru in the task implementation indicates that this task accepts the fileset, but does not change it. We follow Clojure idiom and assign the fileset to _, as the task implementation does not even use the fileset.

The vector at the front of the task definition declares command line options. The -g / —-greeting option is a string, and -n / --name option also a string (Boot supports several different types for options beyond string). Boot uses this information to provide help for the task:

> boot hello --help

Prints a greeting.



Options:

-h, --help Print this help info.

-g, --greeting GREETING GREETING sets greeting to use.

-n, --name NAME NAME sets name to greet.

The boot command can run this task, using either default task options, or those supplied at the command line:

> boot hello

Hello, Howard

> boot hello -g Bonjour

Bonjour, Howard

>

You can also run a Boot REPL, and use the boot function to create and run pipelines:

> boot repl

nREPL server started on port 62614 on host 127.0.0.1 - nrepl://127.0.0.1:62614

REPL-y 0.3.7, nREPL 0.2.12

Clojure 1.9.0-alpha10

Java HotSpot(TM) 64-Bit Server VM 1.8.0_74-b02

Exit: Control+D or (exit) or (quit)

Commands: (user/help)

Docs: (doc function-name-here)

(find-doc "part-of-name-here")

Find by Name: (find-name "part-of-name-here")

Source: (source function-name-here)

Javadoc: (javadoc java-object-or-class-here)

Examples from clojuredocs.org: [clojuredocs or cdoc]

(user/clojuredocs name-here)

(user/clojuredocs "ns-here" "name-here")

boot.user=> (boot (hello))

Hello, Howard

nil

boot.user=> (boot (hello "-n" "Clojarians"))

Hello, Clojarians

nil

boot.user=> (boot (hello :name "Booters"))

Hello, Booters

nil

boot.user=> (boot "hello" "-n" "Medium" "--" "hello" "-g" "Welcome")

Hello, Medium

Welcome, Howard

nil

boot.user=>

There’s a lot going on in this short example.

The arguments to the boot function can either be strings, as from the command line, or Boot middleware functions (ready to compose with comp).

As you can see, Boot also gives you a lot of flexibility: the task function can accept either a keyword to identify each argument, or a short or long form option name string.

I’m doing some work using Docker where I want to populate a Docker image with artifacts from a Maven repository. The full scope of this outside the context of this article, but one example task reflects a bit more of using Boot in anger:

(deftask init

"First step when building a Docker image. Optionally specifies a directory

of resources that can be added to the image."

[d dir DIR file "Directory to add."

f from IMAGE str "Base image name."]

(assert from "--from is required")

(assert (or (nil? dir)

(is-readable-directory? dir))

"--dir must specify an existing directory")

(with-pre-wrap fs

(cond-> (-> fs

(rm (user-files fs))

(df/edit df/instruction :preamble :from from))

dir (add-resource dir)

true commit!)))

This init task is the first thing in a pipeline (following in the pipeline are further tasks to refine the contents of the Docker image, then a file task to write the Dockerfile and invoke docker to build the image). Because this task will modify the fileset, it uses the with-pre-wrap macro; it is passed the fileset and must return the modified fileset.

The expressions (add-resource dir) and commit! are important: when a directory is provided using the —-dir option, it is added to the fileset as a resource directory. After modifying the fileset, it is necessary to commit! the fileset, which synchronizes the directory structure on the actual file system.

Another important aspect of Boot is that tasks are functions that return functions: that makes it a snap to define new tasks in terms of pipelines of existing tasks:

(deftask base-image

"Builds the base image for other images."

[]

(comp (init :dir (io/file "base") :from "anapsix/alpine-java:8")

(add :file ["launch.sh"])

(artifact :dependency '[[org.bouncycastle/bcprov-jdk15on "1.54"]] :target "/opt/jdk/jre/lib/ext/")

(artifact :dependency '[[com.walmartlabs/timestamper "0.1.2"]] :target "/usr/local/java-agents/")

(inst :section :postamble :inst :run :arguments ["chmod a+x launch.sh"])

(build-image :image-name "base" :version default-base-version)))

This defines base-image in terms of other tasks: init, add, artifact, inst, and finally, build-image. There’s a lot more going on in this simple example, and in Boot in general, than can be covered here.

In summary: Boot is just what I need for my particular application. Likely, you have some bit of work you’ve been meaning to get around to automating, but just couldn’t face the prospect of doing that work in Bash or Python. Try Boot, I think you’ll be quite pleased!