Two halves:
Setup for every snippet:
(require '[clj-ant.core :as a]
'[clj-ant.tasks :as t]
'[clojure.java.io :as io])
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.
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.
<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).
"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.Bar → com/foo/Bar.class-style mappings), flatten,
merge, composite, chained. Combine with <copy> for a non-
destructive transform.
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.
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.
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> 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.
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.
build.xml filesfrom-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.
build.xml filesfrom-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.
deftaskSometimes 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".:task-started and :task-finished for
the Clojure task — your event stream sees it.<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
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))))
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.
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:
<get>, <scp>, <sshexec>) — abort cleanly<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.
: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}
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.
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.
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).
(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")))
(a/files
(t/first :count "10"
(t/sort (t/fileset :dir "logs" :includes "*.log")
(t/date)))) ; or :name, :size, :type, ...
restrictSelectors 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
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.
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
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
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.
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")))
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}))
(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
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |