Liking cljdoc? Tell your friends :D

clj-ant

Apache Ant's task ecosystem, fluent from Clojure. Tasks are functions; resource collections (fileset, path, dirset, union, restrict, …) are lazy java.io.File seqs you can filter/map/transduce over — and pass straight back as children of any task.

CI Clojars Project cljdoc License

Pre-1.0 alpha. Architecture is stable; surface APIs may shift slightly during the alpha period based on real-world feedback. Once 1.0.0 lands, semver applies normally.

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

;; Build a release zip from ordinary files and files inside archives.
;; The plan is data, so lint it before Ant touches the filesystem.
(let [version "1.2.3"
      app-dir "target/release/app"
      configs (->> (a/files (t/fileset :dir "etc"
                                       :includes "**/*.tmpl,**/*.properties"
                                       :excludes "**/secrets/**"))
                   (remove #(str/ends-with? (.getName %) ".bak")))
      plan [(t/delete :dir "target/release")
            (t/mkdir :dir app-dir)

            ;; A live Clojure seq of java.io.File values, copied by Ant.
            ;; The filterchain streams token replacement; no temp strings.
            (t/copy :todir (str app-dir "/conf")
              configs
              (t/filterchain
                (t/tokenfilter
                  (t/replacestring :from "@VERSION@" :to version))))

            ;; Copy directly from inside zip/jar files, without unpacking
            ;; them first. zipfileset is just another Ant resource collection.
            (t/copy :todir (str app-dir "/public")
              (t/zipfileset :src "vendor/admin-ui.zip"
                            :includes "dist/**"
                            :prefix "admin"))
            (t/copy :todir (str app-dir "/licenses")
              (t/zipfileset :src "target/app.jar"
                            :includes "META-INF/LICENSE*,META-INF/NOTICE*"))

            (t/zip :destfile (str "target/app-" version ".zip")
              (t/fileset :dir app-dir))]]
  (when-some [issues (seq (a/lint plan))]
    (throw (ex-info "Invalid Ant plan" {:issues issues})))
  (a/ant plan))

That single expression weaves together things vanilla Clojure rarely composes cleanly: Ant's pattern grammar, lazy resource collections, Clojure filtering, streaming text transforms, archive-entry copying, plan validation, and zip creation. No XML, no shelling out to zip / unzip, no temporary extraction just to grab files from a jar.

Why

Clojure has excellent build tooling for Clojure code (tools.build, tools.deps, bb). But the long tail of real-world build operations — sign a jar, scp it somewhere, restart a remote service, replace tokens in a config file, audit zip entries, run a command per file — is exactly what Apache Ant has been good at for 20 years.

clj-ant gives you Ant's ~470 tasks and types as Clojure functions that return data, with full Ant semantics underneath: property expansion, refid, macrodef, target dependency resolution, custom taskdefs. Compose them with the rest of your Clojure code freely.

Install

;; deps.edn
{:deps {io.github.mbjarland/clj-ant {:mvn/version "VERSION"}}}

Replace VERSION with the current Clojars version shown by the badge above.

Requires JDK 8+. Released jars include the compiled Java bridge class — downstream consumers do not need to run javac.

For babashka:

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

Documentation

DocRead it for
doc/intro.mdWhere to start.
doc/examples.mdRecipe cookbook (templating, bulk find-replace, smart copy, archive surgery, parallel pipelines, SSH, watch mode, …).
doc/architecture.mdDesign rationale, layer model, anti-patterns. For contributors.
doc/babashka.mdThe bb pod story.
doc/tools-build.mdInterop with clojure.tools.build.
doc/roadmap.mdWhat's done, what's planned.
doc/pre-release.mdOperational checklist for v1.0.

For the underlying Ant tasks themselves — what each one does, what attributes they take, what nested elements they accept — the canonical reference is:

📖 Apache Ant Tasks Reference

Every wrapper in clj-ant.tasks has a docstring with a direct link to its corresponding Ant manual page. Browse the full list there for tasks not yet covered in the cookbook.

Highlights

Data-first

Every task returns a plain map. Nothing executes until the tree is handed to a/ant:

(t/copy :todir "out"
  (t/fileset :dir "src" :includes "**/*.clj"))
;; => {:tag :copy :attrs {:todir "out"}
;;     :children [{:tag :fileset :attrs {:dir "src" ...} ...}]}

So you can update, walk, assoc plans before running them.

Resource collections as Clojure sequences

(->> (t/fileset :dir "src" :includes "**/*.clj")
     a/files                              ; lazy seq of java.io.File
     (filter #(> (.lastModified %) cutoff))
     (mapv  #(.getName %)))

Anything that's an Ant ResourceCollection (fileset, filelist, path, dirset, restrict, intersect, union, …) flows out via a/files and a/resources. Round-trip back into a (t/copy ...) without any string-join glue:

(let [recent (->> (a/files (t/fileset :dir "src"))
                  (filter #(> (.lastModified %) cutoff)))]
  (a/ant (t/copy :todir "out" recent)))

Sessions for tight loops

Many small calls in a REPL loop? Reuse one Ant Project:

(a/with-session [s {:level :info}]
  (a/ant (t/property :name "v" :value "1.2.3"))
  (a/ant (t/echo :message "v=${v}")))

50-call benchmark on a 2-task plan: ~575 ms fresh → ~23 ms sessioned → ~14 ms with (a/prepare ...).

Async + cancellation

For long-running builds, execute-async! returns a Run that behaves like a promise/future:

(let [run (a/execute-async! [(t/scp :file "big.tar"
                                     :todir "deploy@host:/srv/"
                                     :keyfile "..." :trust "true")]
                            :on-event #(println (:phase %)))]
  ;; ...do other work...
  (when (slow?) (a/cancel! run))    ; interrupts the build thread
  @run)                             ; blocks for the result

IO tasks (<scp>, <get>, <sshexec>) honour the interrupt cleanly. Pure-CPU tasks (<javac>, large <copy>) often don't observe it, so :cancelled? true lands on the result map either way to reflect caller intent.

Watch mode

The "edit, save, see rebuild" loop bb developers expect, for any clj-ant pipeline:

(def stop (a/watch [(t/javac :srcdir "src" :destdir "out")
                    (t/copy  :todir "deploy" (t/fileset :dir "out"))]
                   :paths   ["src"]
                   :poll-ms 300
                   :session (a/session {:level :warn})))
;; ...edit src/...
(stop)

Polling-based, so it works the same on Linux, macOS, and Windows. Pair with :session for the cheapest re-runs.

Errors carry the element tree

When Ant raises a BuildException four levels into nested elements, the error coming back is ex-info you can pattern- match in code rather than a stringly-typed mystery:

(let [r (a/ant (t/copy :tdoir "/tmp"))]   ; typo: tdoir
  (when-let [err (:error r)]
    (let [{:keys [clj-ant/elements ant/message]} (ex-data err)]
      (println message "in" (pr-str elements)))))
;; copy doesn't support the "tdoir" attribute in [#Element{...}]

Read existing build.xml

(a/ant (a/from-xml "build.xml"))                ; default target
(a/ant :targets ["jar"] (a/from-xml "build.xml"))   ; pick one

;; static analysis over a corpus
(for [^File f (fs/glob "." "**/build.xml")
      hit (a/elements (a/from-xml f)
                       #(and (= :scp (:tag %))
                             (= "true" (-> % :attrs :trust))))]
  {:file (str f) :scp-target (-> hit :attrs :file)})

;; rewrite and re-emit compact XML
(-> (a/from-xml "build.xml")
    (a/transform #(cond-> %
                    (= :copy (:tag %))
                    (assoc-in [:attrs :preservelastmodified] "true")))
    a/to-xml)

to-xml re-emits data-only element trees. 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.

Clojure functions as first-class Ant tasks

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

(a/ant
  (t/jar :destfile "app.jar" ...)
  (a/element :slack-notify :webhook url
             :channel "#deploys"
             :msg "shipped ${version}"))   ; ${version} expanded

The Clojure fn participates fully: build-listener events, macrodef parameter expansion, <antcall> targeting, the lot.

Validation (malli) and rich REPL

(a/lint (t/copy :tdoir "out"))
;; => [{:tag :copy
;;      :path []
;;      :errors {:tdoir ["disallowed key"]}
;;      :suggestions {:tdoir [:todir]}}]

(a/ant :validate? true (t/copy :tdoir "out"))
;; ExceptionInfo: Validation failed: 1 issue(s)

(a/describe :copy)
;; => {:tag :copy
;;     :description "Copies a file or resource collection ..."
;;     :manual-url "https://ant.apache.org/manual/Tasks/copy.html"
;;     :attrs {"todir" {:type java.io.File
;;                       :description "The directory to copy to."
;;                       :required "..."}
;;             ...}
;;     ...}

(a/lint (t/mkdir))
;; => [{:tag :mkdir
;;      :path []
;;      :errors {:dir ["missing required attribute (Required: Yes)"]}}]

user=> (doc t/copy)
clj-ant.tasks/copy
([& {:keys [todir tofile overwrite encoding ...] :as attrs} & nested])
  Copies a file or resource collection ...
  Attributes:
    :todir          File
    :tofile         File
    :overwrite      boolean
    ...
  https://ant.apache.org/manual/Tasks/copy.html

Cursive / CIDER / clojure-lsp read :arglists for keyword completion, so typing (t/copy : brings up the attribute names inline.

SSH out of the box

<scp> and <sshexec> ship with the Terrapin-fixed com.github.mwiede JSch fork (CVE-2023-48795 patched). The everyday "deploy and restart" chain is one expression; see doc/examples.md for the SSH recipes.

Babashka pod

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

(a/with-session [s]
  (a/execute-in s [(t/get   :src url :dest "/tmp/v.zip")
                   (t/unzip :src "/tmp/v.zip" :dest "/opt/v")]))

Streaming events, files, sessions — same surface as the JVM API. See doc/babashka.md.

Getting started for contributors

git clone https://github.com/mbjarland/clj-ant.git
cd clj-ant
clj -T:build javac     # one-time, compiles the Java bridge
clj -M:test            # run the test suite

Released jars include the pre-compiled bridge — only contributors need the javac step.

Project layout

src/clj/clj_ant/core.clj    the runner + sessions + execute! + ant
src/clj/clj_ant/spec.clj    malli schemas from IntrospectionHelper
src/clj/clj_ant/tasks.clj   auto-generated wrappers (one per Ant
                            task, type, and nested element)
src/clj/clj_ant/pod.clj     babashka pod
src/gen/clj_ant/gen.clj     the generator (clj -X:gen)
src/java/cljant/            the single Java bridge class
test/clj_ant/core_test.clj
doc/                        examples / architecture / babashka / etc.
build.clj                   tools.build entry points
deps.edn

Aliases

clj -T:build javac          # compile src/java/** -> target/classes
clj -T:build jar            # build the jar
clj -T:build install        # install to local maven repo
clj -T:build deploy         # deploy to clojars (needs CLOJARS_*)
clj -X:gen                  # regenerate clj-ant.tasks from Ant
clj -M:test                 # run tests via kaocha
clj -M:pod                  # bb pod entry point

Contributing

Bug reports, recipes for the cookbook, and PRs welcome. The pipeline:

  1. Open an issue first for anything beyond a typo fix — saves wasted effort if the change doesn't fit the design.
  2. Read doc/architecture.md. The "anti-patterns" section in particular flags directions that look reasonable but break things.
  3. Tests required for behaviour changes. Use test/clj_ant/core_test.clj as the model — kaocha, with tmp-dir for filesystem fixtures.
  4. Don't edit tasks.clj by hand. It's generated; run clj -X:gen after gen.clj changes or an Ant version bump.
  5. Commit format: summary line, blank line, body wrapped at 80 columns. No AI/LLM attribution trailers.

Acknowledgments

This project stands on top of Apache Ant (Apache 2.0). The data-first design draws inspiration from how ProjectHelper2 already builds an UnknownElement AST during XML parse — the "don't fight the framework" insight that makes everything else fall out cheaply.

The babashka pod uses babashka/pods and a hand-rolled bencode codec.

SSH support comes via org.apache.ant:ant-jsch plus the maintained com.github.mwiede:jsch fork (the original com.jcraft:jsch is unpatched against CVE-2023-48795).

License

Eclipse Public License 1.0 — see LICENSE. Apache Ant is distributed separately under Apache 2.0.

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