For a basic Clojure project it can be daunting to begin with Boot (even
for the experienced Clojurist). This post will go through the basics of
setting up the build.boot
file to keep the main tasks in Leiningen.
This post assumes a lot of prior knowledge in Leiningen and at least some familiarity with Boot.
We start from a basic Lein project; project.clj
with src
& test
folders. I started with lein new my-project
to get the structure.
Feature-parity
Of course, not everything will be exactly the same, there will be some differences between the two build systems. The main features that I will show are:
- REPL
- test
- jar
- uberjar
REPL
By far, the most important task is to launch a REPL to work effectively.
To get your work done, both src
& test
directories need to be
available.
(set-env!
:source-paths #{"src" "test"}
:dependencies '[[org.clojure/clojure "1.7.0"]])
Many developers have a namespace loaded on REPL startup to help with interactive development: to reload source files; to startup systems; and to run tests. We want to have this available to use when starting a REPL in boot.
I usually call this namespace user
which lives in a supplementary
dev
folder. This folder is added in to the REPL only.
REPL task-options
If there is a boot hook available, the simplest way to change the task
is to use task-options!
to set defaults. For more complicated
replacement we need to use replace-task!
. Below is an example
(task-options!
repl {:eval (println "Howdy!")
;; my user ns in dev folder
:init-ns 'user
;; skip the Boot automatic ns injection, also called `user`
:skip-init true})
(replace-task!
[r repl] (fn [& xs]
;; add "dev" into source-paths only for this task
(merge-env! :source-paths #{"dev"})
;; just to verify the change, not required
(println "Our paths are now" (get-env :source-paths))
;; and continue on with our original repl task.
(apply r xs)))
So things got complicated real fast! Stick with it, it should make sense soon.
Test
In Leiningen when we run lein test
it just works. There isn’t much to
worry about. In Boot, it will be a bit more involved.
We must add some more boot code to our script.
;; add in to the existing :dependencies
(set-env! :dependencies '[[adzerk/boot-test "1.1.0" :scope "test"]])
(require '[adzerk.boot-test :refer :all])
Now we have boot test
available as a task. If you use Leiningen’s test
selectors to filter tests then more work is needed to run these tests.
Creating a JAR
In Leiningen, the jar command will do many things: compile classes (if
there is a :main
or :aot
declared); create the pom file; then
package clj from src
and the classes into a JAR.
If we just run boot jar
without any configuration we won’t get much,
just an empty project.jar
in the target directory!
Understanding the pipeline
We must understand how to pipeline tasks in Boot. This is where Boot starts to digress from Leiningen.
We wish to make our own task that will do all of the Leiningen sub-tasks in one go.
(deftask build
"Build the JAR file"
[]
(comp
(aot)
(pom)
(jar)))
We have now created a build
task to compose all of the tasks together.
It’s quite simple really. But in order for this to work we will need to
set some options for pom
. Also we’d like to compile some files. And
set the main class.
;; again, both should be added into the existing declarations.
(set-env! :project 'my-project
:version "0.1.0-SNAPSHOT")
(task-options! pom {:project (get-env :project)
:version (get-env :version)}
aot {:namespace '#{my-project.core}}
jar {:main 'my-project.core})
If we inspect the JAR after running boot build
$ boot build
Howdy # <- we still see our REPL running!
Writing pom.xml and pom.properties...
Writing my-project-0.1.0-SNAPSHOT.jar...
$ unzip -l target/my-project-0.1.0-SNAPSHOT.jar
Archive target/my-project-0.1.0-SNAPSHOT.jar
Length Date Time Name
-------- ---- ---- ----
0 01-05-16 10:54 META-INF/
0 01-05-16 10:54 META-INF/maven/
0 01-05-16 10:54 META-INF/maven/my-project/
0 01-05-16 10:54 META-INF/maven/my-project/my-project/
146 01-05-16 10:54 META-INF/maven/my-project/my-project/pom.properties
1063 01-05-16 10:54 META-INF/maven/my-project/my-project/pom.xml
25 01-05-16 10:54 META-INF/MANIFEST.MF
-------- -------
1234 7 files
D’oh! We don’t get anything.
Resources & sources
You must set :resource-paths #{"src"}
in order to include the clj
files in the JAR. Leiningen has helpfully included our src
directory
as part of the resources to copy into the JAR. Boot has done the
opposite, we must explicitly include our sources if we want to publish
them.
Building an uberjar
In a similar fashion to building a jar we can create our own pipeline task.
(deftask build-uber
"Build the uberjar file"
[]
(comp
(aot)
(pom)
(uber)
(jar)))
Unfortunately this just makes a jar named project.jar
. We can alter
the jar name by setting task options on our new task.
(deftask build-uber
"Build the uberjar file"
[]
(comp
(aot)
(pom)
(uber)
(jar :file (format "%s-%s-standalone.jar" (get-env :project) (get-env :version)))))
It’s a bit awkward but it works.
Conclusion
Leiningen has been around for years and it works really well for creating projects. There are some nice features built-in to get working quickly without needing to know a lot of things.
Boot has some really nice features, most of which are not displayed in this demonstration. The fact that you can simply pipeline all of the subtasks in the build process means that you can replace any part you don’t like. It is not for the feint of heart, there is a lot to set up before you can get hacking effectively.
The final files
A fully working setup is available on Github to experiment with.
The build.boot
file:
;; An example setup showing how to move a simple library from Leiningen to Boot.
(set-env!
:source-paths #{"src" "test"}
;; If resource-paths is not set then the clj files will not appear in
;; the JAR or uberjar
:resource-paths #{"src"}
;; these values must be set to use the pom task.
:project 'my-project
:version "0.1.0-SNAPSHOT"
;; beware the initial quote on the vector!
:dependencies '[[org.clojure/clojure "1.7.0"]
;; Add your other dependencies as normal in here
;; we must include this boot task in order to run
;; boot test. It is scoped for the test life-cycle
;; only
[adzerk/boot-test "1.1.0" :scope "test"]])
(require '[adzerk.boot-test :refer :all])
;; or :refer [test] if you prefer to specify.
;; We set the default values for task options. We may override them
;; from the command line or if we call tasks.
(task-options!
pom {;; needed to write the pom.xml file.
:project (get-env :project)
:version (get-env :version)
;; How to add in your project license
:license {"Eclipse Public License"
"http://www.eclipse.org/legal/epl-v10.html"}
;; And url.
:url "https://juxt.pro/"}
;; beware the initial quote here too.
;; you could use :all true instead
aot {:namespace '#{my-project.core}}
jar {:main 'my-project.core}
;; we have our own dev/user.clj file that we wish to load. We
;; skip-init so that we don't clash with Boot's user ns.
repl {:init-ns 'user
:skip-init true})
;; We want to change the behavior of the repl task to include our own
;; namespace.
(replace-task!
[r repl] (fn [& xs]
;; we only want to have "dev" included for the REPL task
(merge-env! :source-paths #{"dev"})
(apply r xs)))
(deftask build
"Build the JAR file"
[] ;; we have no options for this task.
;; compose the tasks.
(comp
(aot)
(pom)
(jar)))
(deftask build-uber
"Build the uberjar file"
[]
(comp
(aot)
(pom)
(uber)
;; In this case, we want the jar to be named in a way that mirrors
;; the Leiningen way.
(jar
:file (format "%s-%s-standalone.jar"
(get-env :project)
(get-env :version)))))
versus project.clj
definition:
(defproject my-project "0.1.0-SNAPSHOT"
:description "An example project that demonstrates using Boot as a build tool."
:url "https://juxt.pro/"
:license {:name "Eclipse Public License"
:url "http://www.eclipse.org/legal/epl-v10.html"}
:dependencies [[org.clojure/clojure "1.7.0"]]
:main my-project.core
:profiles {:dev {:source-paths ["dev"]}}
:repl-options {:init-ns user})