Liking cljdoc? Tell your friends :D

clj-ant cookbook

Two halves:

  • Recipes — problems Clojure devs hit where Ant has a much sharper tool than what's in the standard kit.
  • File-collection reference — the full grammar of the resource-collection abstraction.

Setup for every snippet:

(require '[clj-ant.core  :as a]
         '[clj-ant.tasks :as t]
         '[clojure.java.io :as io])

Recipes

Stamp variables into config files

Common at deploy time: take a template, substitute environment-driven values, write the result to a target directory. Vanilla Clojure does this with hand-rolled string replacement; Ant has <filterchain> + <tokenfilter>, which streams through the file at copy time:

(a/ant :level :warn
  (t/copy :todir "deploy/etc"
    (t/fileset :dir "etc/templates" :includes "**/*.conf")
    (t/filterchain
      (t/tokenfilter
        (t/replacestring :from "@VERSION@" :to (:version env))
        (t/replacestring :from "@HOST@"    :to (:host env))
        (t/replacestring :from "@DB_URL@"  :to (:db env))))))

Drop in (a/element :replaceregex :pattern …) instead of :replacestring for regex tokens, or :expandproperties to substitute every ${name} from the project properties in one shot. Streaming-style: the file is never fully buffered.

Bulk find-and-replace across a tree

Refactoring across hundreds of files. <replaceregexp> walks a fileset and edits in place, with byline for line-anchored regex and flags for the usual g/i/m/s:

(a/ant :level :warn
  (t/replaceregexp
    :match   "old\\.namespace" :replace "new.namespace"
    :flags   "g" :byline "true"
    (t/fileset :dir "src" :includes "**/*.clj,**/*.cljc,**/*.cljs")))

The same task accepts <substitution expression="…"/> for backref patterns ($1/$2/etc.) when the replacement depends on captures.

Smart copy (skip if destination is newer)

<copy> honours mtimes by default — pass :overwrite "false" plus :granularity to express "only re-copy if the source is newer by at least N millis." Useful in bb scripts that re-run periodically:

(a/ant :level :warn
  (t/copy :todir "build/classes"
          :overwrite "false"
          :granularity "2000"           ; FAT-tolerance
          :preservelastmodified "true"
    (t/fileset :dir "src" :includes "**/*.clj")))

Layer a <modified> selector on the fileset for content-aware "changed" detection (Ant hashes each file, caches the hashes, and skips by content rather than mtime).

Mass file rename via mapper

"Move every *.clj to *.cljc" without a manual loop. The mapper + <move> combo moves or renames each file for you:

(a/ant :level :warn
  (t/move :todir "src"
    (t/fileset :dir "src" :includes "**/*.clj")
    (t/globmapper :from "*.clj" :to "*.cljc")))

Mappers come in many flavours — glob, regexp, package (for com.foo.Barcom/foo/Bar.class-style mappings), flatten, merge, composite, chained. Combine with <copy> for a non- destructive transform.

Selective archive extraction

Pull only certain entries out of a zip/tar/jar without unpacking the rest. <unzip> + <patternset>:

(a/ant :level :warn
  (t/unzip :src "deps/big.jar" :dest "extracted/"
    (t/patternset
            :includes "**/*.properties,META-INF/services/**"
            :excludes "**/test/**")))

If you only need to read an entry without writing it to disk, use a/resources over a zipfileset and slurp the resource stream — see the file-collection reference below.

Download + verify + extract

A common bootstrap: fetch an archive and its checksum, verify it, then unpack only what's needed:

(a/ant :level :warn
  (t/get :src  "https://example.com/release-1.2.3.zip"
         :dest "/tmp/release.zip"
         :usetimestamp "true")
  (t/get :src  "https://example.com/release-1.2.3.zip.sha256"
         :dest "/tmp/release.zip.sha256"
         :usetimestamp "true")
  (t/condition :property "checksum.ok"
    (t/checksum :file "/tmp/release.zip"
                :algorithm "SHA-256"
                :fileext ".sha256"))
  (t/fail :unless "checksum.ok"
          :message "checksum mismatch on release.zip")
  (t/unzip :src "/tmp/release.zip" :dest "/opt/app"))

The nested <checksum> condition is the important bit: <condition> sets checksum.ok only when the downloaded sibling release.zip.sha256 matches, and <fail unless="…"> short-circuits the build with a message when it does not.

Run a shell command for each file

The find/-exec idiom. <apply> spawns the executable once per matched file when :parallel is false:

(a/ant :level :info
  (t/apply :executable "convert" :parallel "false"
           :dest "build/thumbs"
    (t/fileset :dir "src/img" :includes "**/*.png")
    (t/globmapper :from "*.png" :to "*.thumb.png")
    (a/element :arg :value "-resize")
    (a/element :arg :value "120x120")
    (t/srcfile)
    (a/element :targetfile)))

Mapper-driven <apply> is the part that's actually painful from raw ProcessBuilder: matching each input to its mapped output, threading arg lists, propagating non-zero exit codes.

Parallel pipelines

<parallel> runs its child tasks concurrently. Useful when you've composed several independent build phases:

(let [t0 (System/currentTimeMillis)]
  (a/ant :level :warn
    (a/element :parallel
      (t/sleep :seconds "2")            ; pretend: javac main
      (t/sleep :seconds "2")            ; pretend: javac test
      (t/sleep :seconds "2")))          ; pretend: docs
  (println "wall:" (- (System/currentTimeMillis) t0) "ms"))
;; => wall: ~2100 ms (not 6 s)

<parallel> accepts :threadCount, :timeout, and a :failonany flag if you want the first failure to abort the rest.

SSH and SCP from babashka without writing your own SSH

clj-ant ships with ant-jsch and a maintained JSch fork (the Terrapin-fixed com.github.mwiede:jsch), so <scp> and <sshexec> work out of the box from both JVM and bb. This is the recipe that saves the most code in practice — Clojure has nothing built-in for SSH, and rolling it from ProcessBuilder over the ssh CLI means fighting key prompts, host-key verification, and quoting hell.

Push a build artifact and run a remote command in one expression:

(a/ant :level :warn
  (t/scp :file        "target/app.jar"
         :todir       "deploy@web-1.example.com:/srv/app/"
         :keyfile     (str (System/getenv "HOME") "/.ssh/id_ed25519")
         :passphrase  ""
         :trust       "true")        ; or :knownhosts "/path/to/known_hosts"

  (t/sshexec :host       "web-1.example.com"
             :username   "deploy"
             :keyfile    (str (System/getenv "HOME") "/.ssh/id_ed25519")
             :command    "systemctl --user restart app"
             :trust      "true"))

For a pull rather than push, swap source and dest:

(t/scp :file  "deploy@web-1.example.com:/var/log/app.log"
       :todir "/tmp/"
       :keyfile (str (System/getenv "HOME") "/.ssh/id_ed25519")
       :trust "true")

<sshexec> accepts :outputproperty to capture remote stdout into an Ant property and :errorproperty for stderr — combined with :on-event you can pipe remote command output straight into your Clojure side. Pair with <sshsession> for sustained sessions that multiplex multiple commands and forward ports.

Audit a corpus of build.xml files

from-xml + elements + transform is a static-analysis kit for every build.xml in your org. Find every insecure SCP, every <javac> without debug info, every taskdef referencing a deleted class — without writing parsers:

(require '[babashka.fs :as fs])

;; All <scp> calls with trust="true" across every build file
(for [^java.io.File f (fs/glob "." "**/build.xml")
      :let [tree (a/from-xml (.toFile f))]
      hit  (a/elements tree
                       #(and (= :scp (:tag %))
                             (= "true" (-> % :attrs :trust))))]
  {:file (str f) :file-attr (:file (:attrs hit))})

;; Rewrite every <copy> to add :preservelastmodified="true"
(let [tree (a/from-xml "build.xml")
      patched (a/transform tree
                            (fn [e]
                              (cond-> e
                                (= :copy (:tag e))
                                (assoc-in [:attrs :preservelastmodified] "true"))))]
  ;; Run the rewritten tree, or re-emit compact XML with to-xml.
  (a/ant patched)
  (a/to-xml patched))

a/elements is (filter pred (tree-seq element? :children tree)), laziness preserved. a/transform walks depth-first; children are rewritten before parents see them, and returning nil from the mapper drops the element.

Read existing build.xml files

from-xml parses an Ant build file into the same element tree clj-ant data forms produce. Use it for migration (run, refactor, or re-emit), or just to query a corpus of existing builds:

;; Run a legacy build.xml unchanged:
(a/ant (a/from-xml "build.xml"))

;; Run a specific target:
(a/ant :targets ["jar"] (a/from-xml "build.xml"))

;; Walk the tree and audit:
(let [tree (a/from-xml "build.xml")]
  (->> (tree-seq :children :children tree)
       (filter #(= :scp (:tag %)))
       (filter #(= "true" (-> % :attrs :trust)))
       count))
;; how many <scp trust="true"/> calls in the corpus

;; Re-emit compact XML after a data-only rewrite:
(a/to-xml (a/from-xml "build.xml"))

src may be a path string, a File, an InputStream, or an XML string (detected by leading <). The returned root is a :project element; the runner unwraps it transparently and lifts the project's name / basedir / default attributes into execute options.

to-xml is for normal element data. Trees containing real Java Ant objects, such as direct FileSets or lazy file seq children, should be run directly because they cannot be represented faithfully as XML.

Mix Clojure code into the element tree with deftask

Sometimes the work is part Ant (copy, scp, jar), part Clojure (query an API, post to Slack, mutate an atom). Without deftask you'd flip back and forth: assemble Ant calls, run them, do the Clojure step, assemble more Ant. With deftask the Clojure code becomes a first-class Ant task, mixable into the same element tree:

(a/deftask :slack-notify
  (fn [{:keys [channel msg webhook]}]
    (slack/post webhook channel msg)))

(a/ant
  (t/property :name "version" :value (read-version))
  (t/jar     :destfile "app-${version}.jar" ...)
  (t/scp     :file "app-${version}.jar"
             :todir "deploy@host:/srv/" :keyfile key)
  (a/element :slack-notify
             :webhook    slack-url
             :channel    "#deploys"
             :msg        "shipped ${version}"))

The Clojure fn participates in Ant fully:

  • ${version} is property-expanded before the fn is called, so :msg arrives as "shipped 1.2.3".
  • The build logger fires :task-started and :task-finished for the Clojure task — your event stream sees it.
  • Put it inside a target and <antcall> can invoke that target; <macrodef> can wrap it, and <parallel> can run it alongside other tasks.

The fn receives one map: every attribute as a keyword key (values are post-expansion strings), plus :project, :task-name, and :text if the element had a text body.

(a/deftask :greet (fn [{:keys [who]}] (println "hello," who)))

(a/ant
  (t/macrodef :name "greet-twice"
    (t/attribute :name "who")
    (t/sequential
      (a/element :greet :who "@{who}")
      (a/element :greet :who "@{who}")))
  (a/element :greet-twice :who "world"))
;; hello, world
;; hello, world

Tight loops: sessions and prepared plans

For REPL/script workloads making many small Ant calls in a row (deploys, watchers, generators), the per-call init dominates. Two hooks reduce it dramatically:

;; Reuse one Project across many execute! calls. Properties carry
;; over, registered tasks stay registered, logger setup happens once.
(a/with-session [s {:level :info}]
  (a/ant (t/property :name "v" :value "1.2.3"))
  (a/ant (t/echo :message "v=${v}"))
  ...)

;; If the SAME plan runs in a tight loop, prepare it once. The
;; coercion/validation walks happen once; runs just bind a Project
;; and execute.
(a/with-session [s {}]
  (let [p (a/prepare nodes :validate? true)]
    (dotimes [_ 100] (a/run p :session s))))

A 50-call benchmark on a 2-task plan:

fresh        575 ms
session       23 ms     (~25× faster)
prepared+ses  14 ms     (~40× faster)

The bigger win is session reuse. prepare adds another modest chunk by skipping per-call coercion / validation walks.

The babashka pod has the same pattern via open-session / execute-in / close-session:

(let [sid (a/open-session :level :warn)]
  (try
    (dotimes [i 100]
      (a/execute-in sid [(t/echo :message (str "iter " i))]))
    (finally (a/close-session sid))))

Inner-loop watch mode

Edit / save / see-result, like lein-cljsbuild but for any clj-ant pipeline. Polls the named paths, re-runs on change:

(def s    (a/session {:level :warn}))
(def stop (a/watch
            [(t/javac :srcdir "src" :destdir "out"
                      :includeantruntime "false")
             (t/copy  :todir "deploy/classes"
                      (t/fileset :dir "out"))]
            :paths      ["src"]
            :poll-ms    300
            :session    s
            :on-rebuild (fn [{:keys [error]}]
                          (if error
                            (println "[error]" (ex-message error))
                            (println "[ok] rebuilt")))))
;; ...edit, save, watch the loop fire...
(stop)

Polling rather than WatchService-based on purpose: portable across Linux / macOS / Windows without the platform-specific quirks (macOS WatchService is broken for recursive watches; Linux inotify has descriptor limits). 300 ms is cheap and feels instant in practice. Pair with :session so re-runs amortise the project init cost.

Async builds and cancellation

For long-running builds (a big <scp>, a <get> of a slow mirror, an <sshexec> waiting on a remote restart), run on a background thread and cancel on user input:

(let [run (a/execute-async!
            [(t/get :src  "https://huge.example.com/release.tar.gz"
                    :dest "/tmp/release.tar.gz")
             (t/untar :src  "/tmp/release.tar.gz"
                      :dest "/opt/release"
                      :compression "gzip")]
            :on-event (fn [{:keys [phase task]}]
                        (when (= :task-started phase)
                          (println "[start]" task))))]

  ;; Block on a 30-second deadline, otherwise cancel:
  (let [r (deref run 30000 :timeout)]
    (when (= r :timeout)
      (a/cancel! run)
      (println "build exceeded 30s, cancelling")
      ;; cancel! returns immediately; deref again with a smaller
      ;; deadline to actually wait for the cancel to land:
      (deref run 5000 :still-running))))

The Run object behaves like any other promise/future:

@run                    ; blocks indefinitely
(deref run 5000 :tv)    ; bounded wait
(realized? run)         ; true once finished/failed/cancelled
(a/cancel! run)         ; interrupts the build thread
(a/cancelled? run)      ; reflects user intent regardless of build state

Note: Ant tasks vary in how they observe Thread.interrupt:

  • IO-bound (<get>, <scp>, <sshexec>) — abort cleanly
  • CPU-bound or self-pacing (<javac>, big <copy>, <sleep>) — often run to completion regardless

:cancelled? true lands on the result map either way, so callers can test for caller intent independently of whether Ant honoured the interrupt.

OpenTelemetry / build tracing

:on-event receives every state transition as a Clojure map. Mapping that to OpenTelemetry spans takes ~30 lines and zero extra clj-ant deps (you bring your own OTEL):

(require '[clj-ant.core :as a])
(import '[io.opentelemetry.api GlobalOpenTelemetry]
        '[io.opentelemetry.context Context])

(def tracer (.tracerBuilder (GlobalOpenTelemetry/get) "clj-ant")
              .build)

(defn ant-with-otel [nodes & {:as opts}]
  (let [build-span (-> (.spanBuilder tracer "ant.build") .startSpan)
        scope      (atom (.makeCurrent build-span))
        ;; one open span per (target | task) we've seen
        spans      (atom {})
        on-event
        (fn [{:keys [phase task target message error]}]
          (case phase
            :target-started
            (swap! spans assoc [:target target]
                   (-> (.spanBuilder tracer (str "ant.target." target))
                       .startSpan))
            :target-finished
            (when-some [s (get @spans [:target target])]
              (when error
                (.setStatus s
                            (io.opentelemetry.api.trace.StatusCode/ERROR)
                            error))
              (.end s)
              (swap! spans dissoc [:target target]))

            :task-started
            (swap! spans assoc [:task task]
                   (-> (.spanBuilder tracer (str "ant.task." task))
                       .startSpan))
            :task-finished
            (when-some [s (get @spans [:task task])]
              (when error
                (.setStatus s
                            (io.opentelemetry.api.trace.StatusCode/ERROR)
                            error))
              (.end s)
              (swap! spans dissoc [:task task]))

            :message
            (when-some [s (or (some-> @spans first val))]
              (.addEvent s (str message)))
            nil))]
    (try
      (apply a/ant :on-event on-event nodes (mapcat identity opts))
      (finally
        (.end build-span)
        (.close ^AutoCloseable @scope)))))

That's the whole thing. Drop in any other tracer the same way -- the event map is the contract, no Ant-specific types crossing the boundary. Same shape works for Datadog, New Relic, any ServiceMonitor that consumes structured events, or just a file-based audit log.

The events you have to play with:

{:phase :started}
{:phase :target-started   :target "compile"}
{:phase :task-started     :task   "javac"}
{:phase :message          :message "..."  :level int}
{:phase :task-finished    :task   "javac" :error nil-or-string}
{:phase :target-finished  :target "compile" :error nil-or-string}
{:phase :finished         :error nil-or-string}

Errors as data

When Ant raises a BuildException, the error returned in the result map is ex-info carrying the element tree you submitted plus the unwrapped Ant message:

(let [r (a/ant
          (t/copy :tdoir "/tmp/out"          ; typo: should be :todir
                  (t/fileset :dir "src")))]
  (when-let [err (:error r)]
    (let [{:keys [clj-ant/elements
                  clj-ant/targets
                  ant/exception-class
                  ant/message]} (ex-data err)]
      (println "[error]" message)
      (println "   in" (count elements) "top-level element(s)")
      (println "   targets:" targets)
      (println "   underlying:" exception-class)
      ;; Original throwable, if you need the full Ant stack:
      (println "   stack:" (.getMessage (.getCause err))))))

Useful for tooling — log scrapers, dashboards, retry logic — that wants to react to specific build failures without parsing strings.

Babashka: scriptable Ant in <100 ms steady-state

Once the pod is loaded, the same t/copy, t/get, t/unzip, … wrappers you use on the JVM are available in bb. The JVM stays warm across calls in the same script run:

(require '[babashka.pods :as pods])
(pods/load-pod ["clojure" "-M:pod"])
(require '[clj-ant.pod   :as a]
         '[clj-ant.tasks :as t])

;; live-stream events to the bb console as tasks execute
(a/execute-stream
  [(t/get   :src "https://example.com/v1.zip" :dest "/tmp/v.zip")
   (t/unzip :src "/tmp/v.zip"        :dest "/opt/v")]
  (fn [{:keys [phase task message]}]
    (case phase
      :task-started  (println "[start]" task)
      :message       (when message (println " " message))
      :task-finished (println "[done] " task)
      nil)))

Pair with babashka.fs for the small filesystem ops bb already does well, and reach for the pod when you need the heavyweight tasks (filter chains, mappers, replaceregexp, archive entry-level access, parallel, …) that bb itself can't host.

File-collection reference

The single rule for getting files into an Ant task: pass anything. The runner figures out the wrapping.

(a/ant (t/copy :todir "out" (t/fileset :dir "src")))     ; node
(a/ant (t/copy :todir "out" my-real-fileset))            ; Java FileSet
(a/ant (t/copy :todir "out" (filter recent? files)))     ; lazy seq
(a/ant (t/copy :todir "out" (io/file "x.clj")))          ; one File

The single shape for getting files out: a/files (or a/resources for non-File data, or a/realize for the live Java object).

Set algebra

(def clj-files (t/fileset :dir "src" :includes "**/*.clj"))
(def edn-files (t/fileset :dir "src" :includes "**/*.edn"))

(a/files (t/union     clj-files edn-files))
(a/files (t/intersect clj-files (t/fileset :dir "src" :includes "core*")))
(a/files (t/difference clj-files (t/fileset :dir "src" :includes "**/*_test.clj")))

Sorted scans, take-N

(a/files
  (t/first :count "10"
    (t/sort (t/fileset :dir "logs" :includes "*.log")
            (t/date))))         ; or :name, :size, :type, ...

Selectors via restrict

Selectors are richer than glob includes — depth, modified-since, content match, file signature, "present in another tree", and so on.

(a/files
  (t/restrict
    (t/fileset :dir "src")
    (t/size :when "more" :size "1024")            ; > 1KiB
    (t/modified :seconds "86400")))               ; modified in last day

Archive entries as resources

zipfileset / tarfileset expose archive contents without extracting:

(->> (t/zipfileset :src "lib/foo.jar" :includes "**/*.class")
     a/resources
     (map #(.getName %)))           ; "com/foo/A.class" "com/foo/B.class" ...

Each Resource has (.getInputStream r) so you can slurp archive entries from Clojure without ever writing them to disk.

Token streams

Turn a single file into one resource per token. With a line tokenizer you get a resource per line, slurpable individually:

(->> (t/tokens (t/file :file "TODO.md")
               (t/linetokenizer))
     a/resources
     (map #(slurp (.getInputStream %)))
     (filter #(re-find #"^- \[ \]" %)))
;; pending TODO bullets, line-by-line

Mapper-driven renaming

mappedresources virtually renames a collection without copying:

(a/files
  (t/mappedresources
    (t/fileset :dir "src" :includes "**/*.clj")
    (t/globmapper :from "*.clj" :to "*.cljc")))
;; same files reported under .cljc names

Scale: lazy seq → real ResourceCollection

Comma-strings and nested <file> elements don't scale to millions of entries. The runner auto-wraps any seq you pass into a reified ResourceCollection, so this just works:

(let [files (lazy-seq (find-millions-of-files))]
  (a/ant (t/copy :todir "out" files)))

Iteration is on demand: Ant pulls one FileResource at a time. For the rare case where you want to override the size hint or the filesystem-only flag, a/lazy-resources accepts both.

Realize once, reduce many

Per-call materialisation is fine for normal sizes. For tight loops or huge collections, realise once with a/files (or a/realize) and fold:

(transduce (comp (filter #(.isFile %))
                 (map    #(.length %)))
           +
           0
           (a/files (t/fileset :dir "/var/log")))

Note: Clojure's chunked seqs

If you pass a seq produced by map/filter (etc.) as a child, remember that Clojure's lazy seqs are chunked — realising one element drags 31 of its neighbours along. For <first count="5"> over a chunked seq of a million files, the iterator pulls 32, not 5. Functionally fine (32 ≪ 1 000 000), but worth knowing if you've got expensive per-element work or want exact bounds.

To get element-by-element realisation, build the seq with lazy-seq/cons directly instead of going through map:

(letfn [(go [i] (when (< i n)
                  (lazy-seq (cons (work i) (go (inc i))))))]
  (a/lazy-resources (go 0) {:size n}))

Streaming paths from babashka

(a/files-stream
  (t/fileset :dir "/big/data" :includes "**/*")
  (fn [path]
    (when (string? path)               ; :phase :done is the sentinel
      (handle path))))

Can you improve this documentation?Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
Move to previous article
Move to next article
Ctrl+/Jump to the search field
× close