This is the doc for someone new to the codebase who wants to know why it's shaped the way it is, not just what the API looks like. If you're sending a PR, this is the file to read first.
It pairs with three others:
README.md — quickstart and the user-facing surface.doc/examples.md — recipes for real use cases.doc/babashka.md — the bb pod story.If you read only one section here, read the next one. Everything else falls out of it.
clj-ant is a Clojure interface to Apache Ant's task and type
ecosystem. It is not a build tool: there's no opinion about
project layout, no tasks defined "by clj-ant," no replacement for
tools.build. The generated namespace currently contains 467 wrappers:
180 executable tasks, 72 data types, and 215 nested element helpers.
They make Ant operations such as <copy>, <fileset>, <scp>,
<sshexec>, <replaceregexp>, <jar>, <get>, and <checksum>
callable as fluent Clojure code, with results readable as Clojure data.
The audience is people who would otherwise:
clojure -X:foo,ProcessBuilder over the ssh CLI for deployment,babashka.fs/copy-tree then realise they need filter chains or
mappers and there's no clean answer.Build Ant's own AST (
UnknownElement+RuntimeConfigurable) directly from Clojure data. Skip the XML round-trip. Skip theMain.javafork. Don't touch anything Ant has marked package-private.
When Ant's XML parser (ProjectHelper2) reads build.xml, it
produces a tree of UnknownElement instances, each wrapping a
RuntimeConfigurable that holds the element's raw attributes and
child references. At execute time RuntimeConfigurable.maybeConfigure
walks that tree, expands ${…} properties, resolves refids,
applies macrodef / presetdef, and then invokes the element's
backing class.
Both UnknownElement and RuntimeConfigurable are fully public
Ant API. Building that tree from Clojure data instead of XML gives
us the entire Ant runtime — property expansion, refid, macrodef,
presetdef, custom taskdefs, if/unless attributes — with zero
glue code on our side.
Clojure data form UnknownElement / RuntimeConfigurable
{:tag :copy the same AST ProjectHelper2 builds
:attrs {:todir "out"} from XML, just with "load XML" step
:children [...]} ─────► swapped for "walk Clojure data"
│
▼ executeTarget
Ant runs the build
The previous incarnation of this repo took a different path: emit
XML, register a custom URL protocol handler so the in-memory XML
masqueraded as a file, fork Main.java (1300 lines!) to suppress
System.exit, subclass ProjectHelper2. The new model deletes all
of that — about 1500 lines of Java plus the XML emit/parse step,
replaced by ~150 lines of Clojure walking the data into
UnknownElement directly.
┌─────────────────────────────────────────────────────────────┐
│ User code │
│ (a/ant (t/copy :todir "out" (t/fileset :dir "src"))) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Generated wrappers (clj-ant.tasks) │
│ Each is one line: (apply c/element :tag args). │
│ Carries rich :doc + :arglists for the IDE. │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Data layer (clj-ant.core/element, /Element) │
│ Map-like Clojure data. Mix in nodes, real Ant objects, │
│ Files, lazy seqs -- as-child coerces them all. │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ AST layer (->unknown-element, java-child->ue) │
│ Build UnknownElement / RuntimeConfigurable trees. │
│ Resource collections come in via project refid proxies. │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Ant runtime (Project, Target, Task) │
│ We do not patch this layer. We feed it the AST it expects. │
└─────────────────────────────────────────────────────────────┘
src/clj/clj_ant/
core.clj the runner: element/Element, as-child,
->unknown-element, execute!, ant, sessions,
target/deftarget, deftask + task, from-xml/to-xml,
realize, files, resources, plan, describe, lint,
explain, datafy
tasks.clj GENERATED -- one wrapper per Ant task, type, and
nested element. Don't edit; regenerate via clj -X:gen.
spec.clj runtime validation. Builds malli schemas lazily from
IntrospectionHelper. validate, validate-tree,
schema-for, :closed?, :parent (context-aware).
pod.clj babashka pod. Inline bencode. Exposes execute,
execute-stream, files, files-stream, plan, and
open-session/execute-in/close-session, plus the
full clj-ant.tasks namespace via the describe
payload so bb scripts use t/* directly.
src/gen/clj_ant/
gen.clj the generator. Walks defaults.properties + the
recursive nested-element graph, reflects via Ant's
IntrospectionHelper, parses the bundled manual
HTML for descriptions, and emits the wrappers.
src/java/cljant/
ClojureTask.java the only Java in the project. Bridges Ant's
Task lifecycle to a Clojure IFn registry keyed by
task name -- needed because Ant requires a real
instantiable Class for taskdefs and uses
Class.getDeclaredConstructor().newInstance() to
build them.
Everything user-facing lives in clj-ant.core. The other namespaces
are either generated or single-purpose.
Element record(defrecord Element [tag attrs children text])
The data form. tag is a keyword like :copy. attrs is a map of
keyword to anything-stringifiable. children is a vec of Elements
or JavaChilds. text is an optional string body.
Construct via (element :copy :todir "out" (fileset …)) or directly
through any t/foo wrapper. element? is the predicate. The
record IS a map — (:tag e) works as expected, and update /
assoc round-trip cleanly.
JavaChild record(defrecord JavaChild [object tag])
Holds a real Ant DataType (typically a ResourceCollection) that
the user wants to inject as is — no rebuilding from data. The
runner registers object on the project under a synthetic refid
and emits a <tag refid="…"/> proxy in the AST. :tag defaults to
:resources, which works for any ResourceCollection because
Ant's polymorphic add(ResourceCollection) adder accepts that
proxy.
End users almost never construct one directly — as-child does it.
as-child(as-child x) -> Element | JavaChild
The single rule for "what can be a child of an element":
Element / JavaChild -> as is
map (Element-shaped) -> as is
ResourceCollection -> JavaChild(:resources)
File / Resource -> single-element ResourceCollection
seq of File/Resource -> reified ResourceCollection over the seq
This is what makes (a/ant (t/copy :todir "out" my-thing)) work
regardless of whether my-thing is a clj-ant element, a real Ant
FileSet, a single File, or a lazy seq of millions of Files.
->unknown-element(->unknown-element element-or-java-child project target) -> UnknownElement
The whole interop with Ant. Walks an Element (or JavaChild)
recursively, builds a real UnknownElement for each, sets attrs
via RuntimeConfigurable.setAttribute, recurses for children. The
key incantation:
(.setRuntimeConfigurableWrapper ue wrap)
is the same hook ProjectHelper2.ElementHandler uses when
assembling the AST from XML. Both methods are public.
For JavaChild: we register the object under _clj-ant.ref-N
on the project (per call, cleaned up in finally), build a stub
UnknownElement with refid set, and let Ant resolve it at
configure time.
src/java/cljant/ClojureTask.java (~70 lines, sole Java in the
repo) exists because of deftask. The constraint:
// Ant's Project.createTask:
Class<?> klass = ...; // from getTaskDefinitions
Task t = (Task) klass.getDeclaredConstructor().newInstance();
Ant looks up the task by name, gets a Class, calls a no-arg
constructor. For (deftask :slack-notify (fn [...] ...)) to plug a
Clojure fn into that lifecycle, we need an actual instantiable
class.
Options considered and rejected:
| Option | Why not |
|---|---|
gen-class | Requires AOT — awkward for clj users |
| Runtime ASM/insn | Adds a dep just for one class |
proxy | Survives the proxy form's instance creation, but Ant's newInstance() produces a "blank" proxy with no fns wired |
deftype | Can implement interfaces, can't extend Task (an abstract class) |
| Fake-task (no class) | Loses macrodef/antcall/event-stream integration; defeats the point of deftask |
A 70-line Java file is the lightest answer. The class:
Task,DynamicAttribute so any keyword maps to an attr,REGISTRY of task-name -> IFn,execute() looks up its fn via getTaskName() and invokes it
with a map of expanded attrs + :project + :task-name + :text.This is the only thing in the project that needs clj -T:build javac
before tests/REPL. The compiled class lives in target/classes,
which is on :paths in deps.edn. Pre-built .class ships in the
jar via tools.build.
ant-jsch:1.10.17 for SSH supportcom.jcraft:jsch:0.1.55ant-jsch transitively pulls in com.jcraft:jsch:0.1.55,
unmaintained since 2018, with CVE-2023-48795 (Terrapin)
unpatched. We exclude that and pin com.github.mwiede:jsch:0.2.25
— a maintained drop-in using the same com.jcraft.jsch package
name, with the Terrapin fix.
This is two deps but one SSH implementation by design. See
deps.edn comment.
Schemas as data, not as macros. IntrospectionHelper gives us
attribute types at runtime; we map them straight to malli schemas
without a single defmacro or registry registration. Malli's
string-transformer covers Ant's "everything coerces from String"
quirk for free. Spec would have meant generating s/def forms via
macros — much more code, less inspectable, harder to extend.
Some nested tags are ambiguous: <attribute> under <macrodef> is
MacroDef$Attribute (with :default), under <manifest> it's
Manifest$Attribute (with :value). The generator records every
(parent-class, tag) → child-class triple it sees during the
nested-element walk, and emits a :clj-ant/by-parent map on the
wrapper for ambiguous tags. validate-tree threads parent context
down through the walk so each child validates against the right
class — (t/attribute :name "x" :default "y") passes under
<macrodef> and gets rejected under <manifest>, matching Ant's
runtime.
:closed? false default for validationMost users pull in custom tasks via <taskdef>, whose attributes
our static schema can't see. Defaulting open means validation
surfaces real type errors (boolean / int / enum mismatches) without
false-positives on user taskdefs. :closed? true is opt-in for the
"catch all typos" mode.
The runner's as-child accepts Files, Resources, RC instances, and
seqs of those, and wraps each appropriately. Earlier versions had
three named wrappers (child, eager-resources, lazy-resources)
the user had to pick from. That was five names for one concept; we
collapsed it. lazy-resources is kept around for the rare case
where you need to override the size hint or isFilesystemOnly flag.
with-project(a/session opts) returns a Session record holding one Ant Project,
plus with-session for scoped lifetimes. execute! accepts
:session directly. The same shape exists in the bb pod
(open-session / execute-in / close-session). Steady-state
benchmark on a 50-call loop: 575 ms fresh → 23 ms with session →
14 ms with prepare + session.
The session machinery is also where lifecycle correctness matters
most: every synthetic refid we mint for a JavaChild and every
named target we addOrReplace is tracked per-call in a dynamic
*execute-ctx* and removed in finally. Without that, reused
sessions would grow the Project's reference and target tables
across every call. With it, properties carry over (intentional)
but the build-local clutter doesn't.
GraalVM-native-image of the pod would give instant startup but Ant
is reflection-heavy — substantial reflect-config.json work for
marginal gain. The warm-JVM pod starts in well under a second; in
steady state a bb invoke is just bencode-over-stdio. If someone
ever has a hard cold-start requirement, the protocol surface stays
identical so existing scripts wouldn't change.
clj-ant.tasks namespaceThe pod's describe payload synthesises a clj-ant.tasks namespace
at startup (one tiny wrapper per task, plus an make-element
builder), populated by reflecting over the JVM-side clj-ant.tasks.
So bb scripts use (t/copy :todir "out" (t/fileset :dir "src")) —
identical syntax to the JVM. Without this the bb side could only
construct elements as raw {:tag … :attrs …} maps, which made
recipe code diverge.
Element, JavaChild), not oneElement is built by clj-ant code (the data layer). JavaChild
wraps an opaque Java object. Both flow through children lists, but
the runner dispatches differently: Element → ->unknown-element
recursion, JavaChild → register-and-refid. Keeping them as separate
records makes instance? checks unambiguous and lets the runner's
top-level dispatch read as a one-liner if.
tasks.clj), not dynamic dispatchWe could resolve task names at runtime — every (t/foo …) call
delegates to a single (element :foo …). Instead the generator
emits explicit defns with rich docstrings + :arglists
metadata.
Why: IDE ergonomics. Cursive, CIDER, and clojure-lsp read
:arglists for keyword completion. With explicit defns, typing
(t/copy : brings up :todir :tofile :overwrite … inline. With
dynamic dispatch the IDE has nothing to introspect.
The cost is tasks.clj being a checked-in generated file (large).
Worth it. Regen with clj -X:gen after an Ant version bump.
<replaceregexp>, <patternset>, <sshexec> keep their lowercase
no-hyphen Ant names as keywords (:replaceregexp, :patternset,
:sshexec). The generator only renames tags that collide with
Clojure special forms (:if, :let, :do, …) — those get a
-task suffix. clojure.core shadowing (apply, concat,
replace, sort, filter) is handled by :refer-clojure :exclude
on the generated namespace.
This means the Ant manual is the source of truth for attribute names.
Open https://ant.apache.org/manual/Tasks/copy.html and you will see
the same names that work in Clojure code.
:on-event (JVM) and execute-stream (bb) give you
{:phase :task-started :task "echo"} shaped maps. We don't expose
Ant's BuildEvent directly — most callers shouldn't care about the
JVM type, and crossing the bb pod boundary requires plain edn
anyway. The phases:
:started :target-started :task-started
:message
:task-finished :target-finished :finished
(The outermost pair was named :build-started / :build-finished
inheriting from Ant's BuildListener method names. Renamed —
clj-ant isn't a build tool.)
(a/ant (t/copy :todir "out" (t/fileset :dir "src")))
│
▼ ant fn parses leading kw opts, calls execute!
(execute! [Element copy] :level :info)
│
▼ binding *execute-ctx* with refs/targets atoms
▼ make-project: Project + DefaultLogger, init,
│ register all deftask'd names (or sync if reused)
▼ collect inline tasks (those with :clj-ant/inline-fn)
▼ collision-check + register inline tasks transiently
▼ for each top-level Element, ->unknown-element
│
│ ->unknown-element(Element copy, project, target)
│ UnknownElement ue("copy")
│ RuntimeConfigurable wrap
│ wrap.setAttribute("todir", "out")
│ recurse into :children:
│ ->unknown-element(Element fileset, ...)
│ (or, if a child is a JavaChild:
│ register obj on project with a refid we
│ also push into *execute-ctx*, emit refid
│ proxy element)
│ ue.setRuntimeConfigurableWrapper(wrap)
│
▼ implicit Target ""
│ + named-targets if any
▼ project.executeTargets(["", named...])
│
▼ Ant evaluates the AST. RuntimeConfigurable
expands ${...}, resolves refids, builds the real
Task / DataType, calls .execute().
│
▼ finally:
remove BuildListener
restore prior class bindings for inline tasks
remove every refid we registered
remove every named target we addOrReplace'd
(a/from-xml "build.xml")
│
▼ clojure.xml/parse (JDK SAX)
▼ xml->element walk:
│ {:tag :attrs :content} -> ->Element
│ text content collapsed; whitespace-only dropped
▼ root :project Element with :clj-ant/source-dir attr
│
▼ pass to (a/ant ...) -- execute! special-cases :project:
│ resolve relative basedir against source-dir,
│ lift name/default into opts
│ children become elements
│ if a default target was named, run it (after the
│ implicit unnamed target, so root <property> declarations
│ fire first)
bb script JVM pod
───────── ───────
(load-pod ["clojure" "-M:pod"])
sends "describe" op ────►
builds describe payload:
- clj-ant.pod ns (execute, plan,
files, execute-stream,
files-stream, open/close/in
session)
- clj-ant.tasks ns (reflected
from JVM, one wrapper per
task + make-element)
returns bencode dict
◄──── sci eval'd into bb's runtime
(t/copy :todir ... (t/fileset ...))
builds {:tag :copy ...} map (in bb)
(a/execute-in sid [...])
sends "invoke" with edn args ────►
op-execute-in calls
core/execute! with :session.
:on-event pushes each event back
as bencode reply with status [].
Final reply is the cleaned result
wrapped in {:clj-ant/result …}.
◄──── for each event, success-handler fires
◄──── final reply is the sentinel; bb stub unwraps & returns
If you're adding a feature, here's where it goes:
| You want to... | Touch... |
|---|---|
| Plug a custom Java type as a child | extend-protocol ICoercible from your code (no fork) |
| Add a new top-level operation | core.clj |
| Add per-tag validation | spec.clj (build-schema) |
| Add a streaming pod op | pod.clj (ops + describe payload) |
| Add a CLI/tool helper | A new ns under src/clj/clj_ant/ |
| Tune the docstring format | gen.clj (task-fn-source), regen |
| Bump Ant | deps.edn + run clj -X:gen |
| Add a recipe | doc/examples.md |
| Capture an open idea | doc/roadmap.md |
The ICoercible protocol is the explicit extension seam for
data flowing INTO the runner. Default extensions cover Element,
JavaChild, ResourceCollection, Resource, File, Sequential, and
node-shaped maps. Out-of-tree types extend the protocol from
their own namespace -- no need to fork:
(extend-protocol clj-ant.core/ICoercible
my.lib.SomeFileBag
(-as-child [bag]
(clj-ant.core/lazy-resources (.-paths bag) {:size (.size bag)})))
Anywhere the runner accepts a child (any task wrapper, hand-built
elements, etc.), SomeFileBag instances now flow through cleanly.
If you're adding a task wrapper, don't write it by hand —
either it's already in tasks.clj (run clj -X:gen after an Ant
bump), or it should come in via <taskdef> at runtime which the
runner handles transparently.
If you're adding attribute coercion (e.g. accept a keyword
for some new attr type), edit attr->string in core.clj. Don't
add per-tag special cases.
If you're adding child types (e.g. accept a Java Path
object), edit as-child. Same single point.
If you're adding a new way to RUN elements (e.g. dry-run, REPL
mode, distributed), build it on top of execute! rather than
inside it.
core.clj.:replaceregexp to :regex-replace or similar — drift from the
manual is hostile.tmp-dir from the test ns.<copy>, <echo>).
Subclasses org.apache.tools.ant.Task.<fileset>, <path>). Subclasses
org.apache.tools.ant.types.DataType.Element regardless.RuntimeConfigurable until execute time, when the real
Task/DataType class is resolved.maybeConfigure is the entry point for
${...} expansion and refid resolution.<fileset refid="x"/>
resolves to whatever was registered under id "x" on the
Project.RuntimeConfigurable -- so they
work for clj-ant element trees automatically.A short list of choices that look reasonable but have known limitations or open questions. Mostly here to save the next contributor from re-discovering them.
The XML-emit approach. The previous incarnation of this repo
did this, with the Main.java fork and the URL handler. ~1500
lines of Java. The current direct-AST approach is ~150 lines of
Clojure. If you find yourself thinking "let me just generate
the XML and hand it to ProjectHelper2" — read the git log of
what got deleted to make this work.
IntrospectionHelper.setAttribute directly. Skips the
RuntimeConfigurable wrap and calls the setter immediately.
Doesn't expand ${...}, doesn't resolve refid, doesn't do
macrodef. The wrap layer is mandatory.
A third record type. Two records (Element and JavaChild)
cover the runner's dispatch. A third would mean a third branch
in ->unknown-element. Resist.
Bundling every Ant optional module. ant-jsch is bundled
because SSH is the killer use case. ant-junit / ant-jmf /
ant-commons-net etc. are not bundled — users who need them
add them to their own deps.edn. The generator picks them up
on the next clj -X:gen and emits wrappers automatically.
Lifting :keep-going / :fail-on-error / etc. to top-level
opts. These are Ant attributes on individual targets. Use
them as attributes; don't lift them into execute!.
Editing tasks.clj by hand. It's generated. Edit gen.clj
and re-run clj -X:gen.
| Symptom | Suspect |
|---|---|
${...} not expanded | Attribute coerced to non-String. Check attr->string. |
BuildException: doesn't support the foo attribute | Attribute name mismatch with Ant. (s/schema-for :tag) to see what's accepted. |
Mystery NullPointerException in JSch | jcraft jsch and mwiede jsch both on classpath. |
| bb pod returns nil for streamed values | :success handler signature mismatch. Bb pod handlers receive the decoded value directly, not {:value v}. |
| Test passes locally, fails on fresh checkout | clj -T:build javac not run. target/classes/cljant/ClojureTask.class missing. |
| Unknown task at runtime | Custom <taskdef> loaded the class but make-project didn't see it. Or the fn under that name in ClojureTask/REGISTRY was cleared. |
Stack overflow loading clj-ant.tasks in bb | apply (or concat/replace/...) self-recurses. The bb wrappers must use clojure.core/apply (fully qualified). |
| Reused session leaks references / targets | *execute-ctx* not bound or finally not run. |
;; data layer
Element user-built data form. Has :tag :attrs :children :text
JavaChild wraps a Java DataType for refid injection
element / element? construct / predicate
as-child single coercion point
->unknown-element data → Ant AST
;; execution
execute! the runner (low level)
ant user-facing wrapper around execute!
execute-async! run on a daemon Thread; returns a Run handle
cancel! / cancelled? Thread.interrupt + caller-intent flag
session / with-session long-lived Project for tight loops
prepare / run coerce+validate once, replay cheaply
watch re-run nodes on filesystem change
;; structuring
target / deftarget named targets and dependency resolution
deftask global registration of a Clojure fn as a task
task inline Clojure thunk as a one-shot task element
;; introspection
realize Element → live Java object
files / resources Element → seq of File / Resource
plan pretty-print an Element tree
describe introspect a tag (tag → attrs/nested map)
elements lazy seq of every node in a tree
transform rewrite a tree, post-order
;; XML round-trip
from-xml build.xml → Element tree
to-xml Element tree → compact XML string
;; validation (clj-ant.spec)
schema-for malli schema for a tag
validate-tree collect errors from a tree
lint / explain user-facing validation helpers
For task / type / nested-element semantics, the canonical source is Apache Ant's manual:
Generated wrappers in clj-ant.tasks carry manual-derived docstrings
where the Ant manual exposes useful prose. Wrappers with known manual
pages include direct links; (doc t/copy) gets you the copy task's
manual page without leaving the REPL.
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 |