Embedding Ant

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

Ant is a Java library with a command line on top of it. Anything the ant script does can be done from Java: load a build file, run a target, listen to the events, or skip the build file altogether and run one task as an object. IDEs and build servers do this, and so do the Groovy AntBuilder and Gradle’s ant object (see below). This page shows the API in programs that were compiled and run, and the two extension points that change how Ant starts: ProjectHelper (the build file front end) and ArgumentProcessor (the command line). It is written against Apache Ant 1.10.18; every program was compiled with javac and run on 2026-10-16 with Oracle JDK 26 on macOS, with the Ant 1.10.18 JARs from the distribution’s lib directory ($ANT_HOME below). Paths in console output were shortened to ..

The classes are documented in the Javadoc of the manual (API index). The books on Ant, and the manual page for using tasks outside Ant, predate several of the changes noted below; the code here is checked against the 1.10.18 JARs.

Which JARs

The core is two JARs in $ANT_HOME/lib:

JAR Contents

ant.jar

The API: Project, Target, Task, ProjectHelper, BuildListener, the core tasks and types, and org.apache.tools.ant.Main.

ant-launcher.jar

org.apache.tools.ant.launch.Launcher, the class the ant script runs, and the interface AntMain that Main implements.

ant-*.jar (ant-junit.jar, ant-jsch.jar, ant-junitlauncher.jar, …​)

The optional tasks, one JAR for each group, and the third-party library each needs. See Installation.

ant-launcher.jar is needed at run time even when the program never touches the launcher, because Project.init() loads AntMain. Run with ant.jar alone, the first call to init() fails:

$ java -cp out:$ANT_HOME/lib/ant.jar EmbedProps props.xml
Exception in thread "main" java.lang.NoClassDefFoundError: org/apache/tools/ant/launch/AntMain
	at java.base/java.lang.ClassLoader.defineClass1(Native Method)
	at java.base/java.lang.ClassLoader.defineClass(ClassLoader.java:974)
	...

(The stack goes on through Project.initProperties(Project.java:322) and Project.init.) This is the same JDK 26 run as every other example here and the message is real; nothing else was missing. The class path of an embedding application is therefore ant.jar and ant-launcher.jar, plus whatever the build files it runs use. An optional task without its JAR fails the way it does on the command line:

BuildException: Problem: failed to create task or type junitlauncher
Cause: the class org.apache.tools.ant.taskdefs.optional.junitlauncher.confined.JUnitLauncherTask was not found.
        This looks like one of Ant's optional components.
Action: Check that the appropriate optional JAR exists in
        -ANT_HOME/lib
        -the IDE Ant configuration dialogs

With ant-junitlauncher.jar added to the class path the same call found the class and the task ran. Compiling code that extends Main also needs ant-launcher.jar on the compile class path (javac stops with "class file for org.apache.tools.ant.launch.AntMain not found" otherwise).

Running a build file

The smallest embedding is five steps: create a Project, attach a logger, call init(), parse the build file into the project with ProjectHelper.configureProject, and execute a target. The build file is the small tidepool one used on this page:

build.xml
<project name="tidepool-embedded" default="check" basedir=".">
  <property name="stations" value="data/stations.csv"/>
  <property name="mode" value="strict"/>

  <target name="check" description="Count the stations">
    <available file="${stations}" property="stations.found"/>
    <echo message="mode=${mode}, stations file present: ${stations.found}"/>
    <echo level="verbose" message="this line is only visible at verbose level"/>
  </target>

  <target name="report" depends="check" description="Depends on check">
    <echo message="report done"/>
  </target>

  <target name="broken" description="Always fails">
    <fail message="station file is invalid"/>
  </target>
</project>

and the program:

EmbedBuild.java
import java.io.File;
import org.apache.tools.ant.DefaultLogger;
import org.apache.tools.ant.Project;
import org.apache.tools.ant.ProjectHelper;

public class EmbedBuild {
    public static void main(String[] args) {
        File buildFile = new File(args[0]);
        String target = args[1];

        Project project = new Project();

        DefaultLogger logger = new DefaultLogger();
        logger.setOutputPrintStream(System.out);
        logger.setErrorPrintStream(System.err);
        logger.setMessageOutputLevel(Project.MSG_INFO);
        project.addBuildListener(logger);

        project.init();                                   // register the default tasks and types
        project.setUserProperty("mode", "embedded");      // user properties beat build file values
        ProjectHelper.configureProject(project, buildFile);

        project.fireBuildStarted();
        Throwable failure = null;
        try {
            project.executeTarget(target);
        } catch (Throwable t) {
            failure = t;
        } finally {
            project.fireBuildFinished(failure);
        }
        System.out.println("failure = " + failure);
    }
}
$ java -cp out:$ANT_HOME/lib/ant.jar:$ANT_HOME/lib/ant-launcher.jar EmbedBuild build.xml report

check:
     [echo] mode=embedded, stations file present: true

report:
     [echo] report done

BUILD SUCCESSFUL
Total time: 0 seconds
failure = null

$ java -cp out:$ANT_HOME/lib/ant.jar:$ANT_HOME/lib/ant-launcher.jar EmbedBuild build.xml broken

broken:

BUILD FAILED
./build.xml:16: station file is invalid

Total time: 0 seconds
failure = ./build.xml:16: station file is invalid

What each step does, and why it is that way:

  • new Project() is an empty project. DefaultLogger is the same logger the command line installs; it writes the [task] prefixes, the target names and the BUILD SUCCESSFUL / BUILD FAILED footer. Without a listener nothing is printed, because a Project has no logger of its own.

  • init() registers the built-in tasks and types and sets the properties derived from the environment (ant.version among them).

  • setUserProperty is the programmatic -Dmode=embedded. The build file says mode is strict; the user property won, because a build file cannot override a user property (the output shows mode=embedded). See Properties.

  • ProjectHelper.configureProject(project, file) parses the file with the project helper that supports it (the default is the XML one) and creates the targets. Tasks are not run yet, with one exception: top-level tasks outside of any target, such as <property>, are executed while the file is parsed. The EmbedListener run below shows the property tasks starting before the targets are listed.

  • fireBuildStarted() and fireBuildFinished(Throwable) are what the command line does around executeTarget. They are what makes BuildListener.buildStarted and buildFinished fire, and the logger’s BUILD SUCCESSFUL footer comes from the second one. A program that calls executeTarget alone gets the target and task events but no start or finish events. Pass the exception, or null: the broken run above printed the failure with the file name and line from the exception.

  • executeTarget("report") runs the target and the targets it depends on, in order (check first). For several, executeTargets(Vector<String>). A failing build raises BuildException; the catch keeps it so that it can be passed to fireBuildFinished.

Other Project methods that matter to an embedder: setBaseDir(File), setKeepGoingMode(boolean) (the -keep-going switch), setInputHandler(InputHandler) and setDefaultInputStream(InputStream) (where <input> reads from; see Listeners, Loggers & Input Handlers), addReference(String, Object) (hand a Java object to the build as a reference id), getTargets(), getDefaultTarget(), getProperty(String) and replaceProperties(String). Use one Project for one build; create a new one for the next run.

flowchart LR prog["Your program"] -->|new Project, addBuildListener, init| prj["Project"] prog -->|ProjectHelper.configureProject| helper["ProjectHelper"] helper -->|creates targets and tasks| prj prog -->|fireBuildStarted, executeTarget, fireBuildFinished| prj prj -->|BuildEvents| lst["BuildListener or DefaultLogger"]

Listening to a build

A logger such as DefaultLogger is a BuildListener that also prints. Anything else (a progress bar, a test of what ran, a build-server integration) implements BuildListener directly. It has seven methods: buildStarted, buildFinished, targetStarted, targetFinished, taskStarted, taskFinished and messageLogged, each with a BuildEvent. BuildEvent gives the Project, the Target or Task the event is about, the message and its priority, and the exception on a finish event. The next program traces the structure of the same build file and reads the results from the project afterwards:

EmbedListener.java
import java.io.File;
import java.util.Vector;
import org.apache.tools.ant.BuildEvent;
import org.apache.tools.ant.BuildListener;
import org.apache.tools.ant.Project;
import org.apache.tools.ant.ProjectHelper;

public class EmbedListener {
    /** A BuildListener that only prints the structure of the build. */
    static class Trace implements BuildListener {
        public void buildStarted(BuildEvent e)  { System.out.println("buildStarted"); }
        public void buildFinished(BuildEvent e) {
            System.out.println("buildFinished, exception = " + e.getException());
        }
        public void targetStarted(BuildEvent e)  { System.out.println("  targetStarted " + e.getTarget().getName()); }
        public void targetFinished(BuildEvent e) {
            System.out.println("  targetFinished " + e.getTarget().getName() + ", exception = " + e.getException());
        }
        public void taskStarted(BuildEvent e)  { System.out.println("    taskStarted " + e.getTask().getTaskName()); }
        public void taskFinished(BuildEvent e) { System.out.println("    taskFinished " + e.getTask().getTaskName()); }
        public void messageLogged(BuildEvent e) {
            if (e.getPriority() <= Project.MSG_INFO) {
                System.out.println("      [" + e.getPriority() + "] " + e.getMessage());
            }
        }
    }

    public static void main(String[] args) {
        Project project = new Project();
        project.addBuildListener(new Trace());
        project.init();
        project.setProperty("mode", "listener-demo");
        ProjectHelper.configureProject(project, new File(args[0]));

        System.out.println("targets: " + new java.util.TreeSet<>(project.getTargets().keySet()));
        System.out.println("default target: " + project.getDefaultTarget());

        project.executeTargets(new Vector<>(java.util.List.of(args[1])));
        System.out.println("mode after build = " + project.getProperty("mode"));
        System.out.println("stations.found   = " + project.getProperty("stations.found"));
    }
}
$ java -cp ... EmbedListener build.xml report
    taskStarted property
    taskFinished property
    taskStarted property
    taskFinished property
targets: [, broken, check, report]
default target: check
  targetStarted check
    taskStarted available
    taskFinished available
    taskStarted echo
      [1] mode=listener-demo, stations file present: true
    taskFinished echo
    taskStarted echo
    taskFinished echo
  targetFinished check, exception = null
  targetStarted report
    taskStarted echo
      [1] report done
    taskFinished echo
  targetFinished report, exception = null
mode after build = listener-demo
stations.found   = true

Reading the trace:

  • The first four lines are the two <property> tasks at the top of build.xml, executed while configureProject parsed the file. They come before the program printed the target list, because tasks outside targets run at parse time.

  • The list of targets contains an empty name: the implicit target that holds those top-level tasks.

  • There is no buildStarted or buildFinished line. The program did not call fireBuildStarted() or fireBuildFinished(...); the listener only sees what is fired.

  • The listener prints a message only when its priority is MSG_INFO (2) or lower. The echo messages show [1], which is MSG_WARN: the echo task defaults to the warning level, which is why a plain <echo> is visible even with -quiet. The verbose echo of check was dropped by that test (the second echo task start and finish have no line between them).

  • The user-visible result of the build, here stations.found, is read back with project.getProperty after executeTargets.

Because BuildListener is an interface, the loggers shipped with Ant (BuildLogger subclasses) and listeners that the command line loads with -listener and -logger are written the same way; they are described on Listeners, Loggers & Input Handlers.

Using a task without a build file

A task is a Java class, and can be used as one. The manual has a page on the idea (a Project, a Target and a call to execute()). With 1.10.18 the recipe is: give the task a Project (setProject), a name (setTaskName) and an owning Target (setOwningTarget), set its attributes with the Java setters, add nested elements with addFileset and similar methods, and call execute(). The program builds a ZIP from the station CSV, unpacks it elsewhere, copies a file, prints a message and deletes everything, using Zip, Expand, Copy, Echo and Delete:

EmbedTasks.java
import java.io.File;
import org.apache.tools.ant.DefaultLogger;
import org.apache.tools.ant.Project;
import org.apache.tools.ant.Target;
import org.apache.tools.ant.taskdefs.Copy;
import org.apache.tools.ant.taskdefs.Delete;
import org.apache.tools.ant.taskdefs.Echo;
import org.apache.tools.ant.taskdefs.Expand;
import org.apache.tools.ant.taskdefs.Zip;
import org.apache.tools.ant.types.FileSet;

public class EmbedTasks {
    /** A Project with a logger and a Target, which is what a task needs to log and to find its owner. */
    static Project newProject(File baseDir) {
        Project project = new Project();
        DefaultLogger logger = new DefaultLogger();
        logger.setOutputPrintStream(System.out);
        logger.setErrorPrintStream(System.err);
        logger.setMessageOutputLevel(Project.MSG_INFO);
        project.addBuildListener(logger);
        project.init();
        project.setBaseDir(baseDir);
        return project;
    }

    static <T extends org.apache.tools.ant.Task> T bind(T task, Project project, String name) {
        task.setProject(project);
        task.setTaskName(name);
        task.setOwningTarget(new Target());
        return task;
    }

    public static void main(String[] args) {
        File work = new File(args[0]).getAbsoluteFile();
        Project project = newProject(work);

        // 1. Build an archive from the CSV with <zip> ...
        File zipFile = new File(work, "stations.zip");
        Zip zip = bind(new Zip(), project, "zip");
        zip.setDestFile(zipFile);
        FileSet data = new FileSet();
        data.setProject(project);
        data.setDir(new File(work, "data"));
        data.setIncludes("*.csv");
        zip.addFileset(data);
        zip.execute();

        // 2. ... unpack it with <unzip> into another directory ...
        Expand unzip = bind(new Expand(), project, "unzip");
        unzip.setSrc(zipFile);
        unzip.setDest(new File(work, "unpacked"));
        unzip.execute();

        // 3. ... copy it with <copy> using a property in the destination ...
        project.setProperty("backup.dir", "backup");
        Copy copy = bind(new Copy(), project, "copy");
        copy.setFile(new File(work, "unpacked/stations.csv"));
        copy.setTodir(new File(work, project.getProperty("backup.dir")));
        copy.execute();

        // 4. ... report with <echo>. Setters are called by Java, so no ${...} expansion happens by itself:
        Echo echo = bind(new Echo(), project, "echo");
        echo.setMessage("raw:      backup written to ${backup.dir}");
        echo.execute();
        echo = bind(new Echo(), project, "echo");
        echo.setMessage(project.replaceProperties("expanded: backup written to ${backup.dir}"));
        echo.execute();

        // 5. ... and clean up with <delete>.
        Delete delete = bind(new Delete(), project, "delete");
        delete.setFile(zipFile);
        delete.execute();
        Delete deleteDirs = bind(new Delete(), project, "delete");
        deleteDirs.setDir(new File(work, "unpacked"));
        deleteDirs.execute();
        deleteDirs = bind(new Delete(), project, "delete");
        deleteDirs.setDir(new File(work, "backup"));
        deleteDirs.execute();

        System.out.println("build date (Project.getBuildDate, since 1.10.18): " + project.getBuildDate());
    }
}
$ java -cp ... EmbedTasks "$PWD"
      [zip] Building zip: ./stations.zip
    [unzip] Expanding: ./stations.zip into ./unpacked
     [copy] Copying 1 file to ./backup
     [echo] raw:      backup written to ${backup.dir}
     [echo] expanded: backup written to backup
   [delete] Deleting: ./stations.zip
   [delete] Deleting directory ./unpacked
   [delete] Deleting directory ./backup
build date (Project.getBuildDate, since 1.10.18): Fri Oct 16 12:11:18 CEST 2026

The logger is what prints the [zip] and [copy] lines; each task logs under its task name. Three details are pitfalls:

  • Setters do not expand properties. The raw: line shows ${backup.dir} unexpanded, because Ant expands ${...} when it configures a task from a build file, not when Java code calls a setter. Call project.replaceProperties(String) yourself, as the expanded: line does.

  • The program sets a Project, a task name and an owning Target on every task, in the bind helper. The manual’s example subclasses Expand and sets the protected fields in its constructor, which has the same effect.

  • getBuildDate() is new in Ant 1.10.18. It is meant for tasks written in Java that need a "build date": it consults the SOURCE_DATE_EPOCH environment variable and the magic ant.tstamp.now and ant.tstamp.now.iso properties, in this order, so that such tasks honour reproducible builds the way <tstamp> does. Here it printed what looks like the current time, because none of them was set.

For more than a task or two, running a short build file from the program is less code than building the tasks by hand. The Groovy equivalent of this section is AntBuilder, covered with a run on Groovy build tools; Gradle exposes the same idea as the ant object in the build script, described on Ant Integration.

Starting Ant the way the command line does

Main is the class behind the ant script. It parses the same options (-f, -q, -D, …​) and handles the logging and listeners that a real run has, so embedding it is the shortest route to "the same as the command line". The API is:

  • public static void main(String[]) is the traditional entry point and ends the JVM with the exit status.

  • public void startAnt(String[] args, Properties additionalUserProperties, ClassLoader coreLoader) is the AntMain method the launcher calls: it runs the build and then calls exit(int).

  • public static void start(String[], Properties, ClassLoader) is the static variant of the same call.

  • protected void exit(int) is the hook: by default it ends the JVM. A subclass can override it to record the code and keep the JVM alive.

EmbedMain.java
import java.util.Properties;
import org.apache.tools.ant.Main;

public class EmbedMain {
    /** Main.exit() is protected and calls System.exit(); a subclass can keep the JVM alive and record the code. */
    static class NoExitMain extends Main {
        int code = -1;
        @Override
        protected void exit(int exitCode) {
            this.code = exitCode;
        }
    }

    public static void main(String[] args) {
        Properties extra = new Properties();
        extra.setProperty("mode", "from-startAnt");

        NoExitMain ok = new NoExitMain();
        ok.startAnt(new String[] {"-f", args[0], "-q", "report"}, extra, null);
        System.out.println("exit code of the first build: " + ok.code);

        NoExitMain bad = new NoExitMain();
        bad.startAnt(new String[] {"-f", args[0], "-q", "broken"}, extra, null);
        System.out.println("exit code of the second build: " + bad.code);

        System.out.println("JVM is still alive");
    }
}
$ java -cp ... EmbedMain build.xml
     [echo] mode=from-startAnt, stations file present: true
     [echo] report done

BUILD SUCCESSFUL
Total time: 0 seconds
exit code of the first build: 0

BUILD FAILED
./build.xml:16: station file is invalid

Total time: 0 seconds
exit code of the second build: 1
JVM is still alive

Two builds ran in one JVM; the exit codes (0 and 1) were captured by the override, and the program went on. Main.main itself, run through java -cp ant.jar:ant-launcher.jar org.apache.tools.ant.Main -q broken on a failing build, exited with status 1 in the shell. The first argument array is the command line: -q made the [echo] lines at warning level visible and nothing else, and the properties given as the second argument became user properties, so mode came from Java, not from the build file.

The third argument of startAnt is the core class loader; the program passed null. When Ant is started through Launcher instead (the class the script uses, with -Dant.home=<installation>), -lib works and the JARs of ANT_HOME/lib and ~/.ant/lib are added; with Main directly they are not, and everything must be on the class path (see Running Ant).

Properties of an embedded project

Several of the properties a build file may rely on are set by the launcher, and not by Project. The same build file, run through the script and embedded:

props.xml
<project name="props" default="show">
  <target name="show">
    <echo>ant.version=${ant.version}</echo>
    <echo>ant.home=${ant.home}</echo>
    <echo>ant.library.dir=${ant.library.dir}</echo>
    <echo>ant.core.lib=${ant.core.lib}</echo>
    <echo>ant.file=${ant.file}</echo>
    <echo>ant.project.name=${ant.project.name}</echo>
    <echo>basedir=${basedir}</echo>
  </target>
</project>
import java.io.File;
import org.apache.tools.ant.DefaultLogger;
import org.apache.tools.ant.Project;
import org.apache.tools.ant.ProjectHelper;
public class EmbedProps {
    public static void main(String[] a) {
        Project p = new Project();
        DefaultLogger l = new DefaultLogger();
        l.setOutputPrintStream(System.out); l.setErrorPrintStream(System.err);
        l.setMessageOutputLevel(Project.MSG_WARN);
        p.addBuildListener(l);
        p.init();
        ProjectHelper.configureProject(p, new File(a[0]));
        p.executeTarget("show");
    }
}
$ ant -q -f props.xml   # through the ant script
     [echo] ant.version=Apache Ant(TM) version 1.10.18 compiled on September 3 2026
     [echo] ant.home=$ANT_HOME
     [echo] ant.library.dir=$ANT_HOME/lib
     [echo] ant.core.lib=$ANT_HOME/lib/ant.jar
     [echo] ant.file=./props.xml
     [echo] ant.project.name=props
     [echo] basedir=.

$ java -cp ... EmbedProps props.xml   # embedded
     [echo] ant.version=Apache Ant(TM) version 1.10.18 compiled on September 3 2026
     [echo] ant.home=${ant.home}
     [echo] ant.library.dir=${ant.library.dir}
     [echo] ant.core.lib=$ANT_HOME/lib/ant.jar
     [echo] ant.file=${ant.file}
     [echo] ant.project.name=props
     [echo] basedir=.

$ java -Dant.home=$ANT_HOME -cp ... EmbedProps props.xml
     [echo] ant.version=Apache Ant(TM) version 1.10.18 compiled on September 3 2026
     [echo] ant.home=$ANT_HOME
     [echo] ant.library.dir=${ant.library.dir}
     [echo] ant.core.lib=$ANT_HOME/lib/ant.jar
     [echo] ant.file=${ant.file}
     [echo] ant.project.name=props
     [echo] basedir=.

ant.version, ant.project.name, basedir and ant.core.lib (the location of ant.jar, added in Ant 1.7, useful as a class path entry when compiling tasks) are present everywhere. ant.home is there only when the JVM was started with -Dant.home, ant.library.dir is set by the launcher only, and ant.file, which the command line sets, is not set by configureProject. A build file that runs embedded should not depend on those three, or the embedder should set them (setProperty("ant.file", ...)).

Embedding fixes and 1.10.x history

  • 1.10.17 fixed a regression in 1.10.16: JavaEnvUtils and FileUtils depended on each other statically, so loading JavaEnvUtils first caused a NullPointerException in programmatic use on Windows. It affected Eclipse. If an embedding application is stuck on 1.10.16, upgrade.

  • 1.10.18 added Project.getBuildDate() (above).

  • Version history of Ant itself is on Versions & Release History.

A different build file format: ProjectHelper

The ProjectHelper is the component that parses a build file and creates the Target and Task objects. The default one, ProjectHelper2, parses XML. To read another format, extend org.apache.tools.ant.ProjectHelper and implement parse(Project, Object). Ant chooses a helper per file: it asks the registered helpers in order and uses the first that returns true from canParseBuildFile(Resource); with no -f, the first helper whose getDefaultBuildFile() exists wins. The order is: the helper named by the system property org.apache.tools.ant.ProjectHelper, the helpers declared in META-INF/services/org.apache.tools.ant.ProjectHelper on the class path (searched with Ant’s own class loader and with the system class loader), then the default XML helper. The <projecthelper> task (Ant 1.8.2) installs one dynamically for <import>.

The manual’s text mentions a supportsBuildFile(File) method. In the 1.10.18 JARs the method is canParseBuildFile(Resource) (javap on ant.jar lists no supportsBuildFile), and a class that overrides the old name does not compile. The toy helper below reads a format of its own, where target starts a target, echo adds an Echo task, and default names the default target:

hello.tp
# a build file in a format of our own
default hello
target hello
echo Hello from a build file that is not XML
target bye
echo Goodbye
TpProjectHelper.java
import java.io.File;
import java.nio.file.Files;
import java.util.List;
import org.apache.tools.ant.BuildException;
import org.apache.tools.ant.Project;
import org.apache.tools.ant.ProjectHelper;
import org.apache.tools.ant.Target;
import org.apache.tools.ant.taskdefs.Echo;
import org.apache.tools.ant.types.Resource;

/**
 * A toy front end: a ".tp" file has lines such as "target hello" and "echo Some text".
 * "default hello" selects the default target.
 */
public class TpProjectHelper extends ProjectHelper {
    @Override
    public String getDefaultBuildFile() {
        return "build.tp";
    }

    @Override
    public boolean canParseBuildFile(Resource buildFile) {
        return buildFile.getName().endsWith(".tp");
    }

    @Override
    public void parse(Project project, Object source) throws BuildException {
        File file = (File) source;
        project.setUserProperty("ant.file", file.getAbsolutePath());
        project.setBaseDir(file.getAbsoluteFile().getParentFile());
        try {
            Target current = null;
            List<String> lines = Files.readAllLines(file.toPath());
            for (String line : lines) {
                line = line.strip();
                if (line.isEmpty() || line.startsWith("#")) {
                    continue;
                }
                String[] parts = line.split("\\s+", 2);
                switch (parts[0]) {
                    case "default" -> project.setDefault(parts[1]);
                    case "target" -> {
                        current = new Target();
                        current.setName(parts[1]);
                        project.addTarget(current);
                    }
                    case "echo" -> {
                        if (current == null) {
                            throw new BuildException("echo outside of a target: " + line);
                        }
                        Echo echo = new Echo();
                        echo.setProject(project);
                        echo.setTaskName("echo");
                        echo.setMessage(parts[1]);
                        current.addTask(echo);
                    }
                    default -> throw new BuildException("cannot parse: " + line);
                }
            }
        } catch (java.io.IOException e) {
            throw new BuildException(e);
        }
    }
}

It is packaged as a JAR with the service file, META-INF/services/org.apache.tools.ant.ProjectHelper, containing one line: TpProjectHelper.

$ javac -cp $ANT_HOME/lib/ant.jar -d classes src/TpProjectHelper.java
$ mkdir -p classes/META-INF/services
$ echo TpProjectHelper > classes/META-INF/services/org.apache.tools.ant.ProjectHelper
$ (cd classes && jar cf ../tp-helper.jar .)
$ ant -lib tp-helper.jar -f hello.tp
Buildfile: ./hello.tp

hello:
     [echo] Hello from a build file that is not XML

BUILD SUCCESSFUL
Total time: 0 seconds

$ ant -lib tp-helper.jar -f hello.tp bye
Buildfile: ./hello.tp

bye:
     [echo] Goodbye

BUILD SUCCESSFUL
Total time: 0 seconds

The -lib option put the JAR on Ant’s class path, the service file registered the helper, and the .tp file was parsed by it. Without the JAR, Ant’s XML parser tried the file and failed at the first character. The helper’s default build file name worked too: with a build.tp in the directory, no build.xml and no -f, Ant found it. (A build.xml in the directory also still ran with the JAR installed, because the XML helper takes .xml files.) The runs below are the failure without the JAR, the default file name, and the registration with a system property instead of the service file, using a directory plain that holds only TpProjectHelper.class:

$ ant -f hello.tp   # without the helper jar
Buildfile: ./hello.tp

BUILD FAILED
./hello.tp:1: Content is not allowed in prolog.

Total time: 0 seconds

$ ant -lib tp-helper.jar   # no -f, no build.xml in the directory
Buildfile: ./build.tp

hello:
     [echo] Hello from a build file that is not XML

BUILD SUCCESSFUL
Total time: 0 seconds

$ ant -Dorg.apache.tools.ant.ProjectHelper=TpProjectHelper -lib plain -f hello.tp
Buildfile: ./hello.tp

BUILD FAILED
./hello.tp:1: Content is not allowed in prolog.

Total time: 0 seconds

$ ANT_OPTS=-Dorg.apache.tools.ant.ProjectHelper=TpProjectHelper ant -lib plain -f hello.tp
Buildfile: ./hello.tp

hello:
     [echo] Hello from a build file that is not XML

BUILD SUCCESSFUL
Total time: 0 seconds

The helper can be registered with the system property org.apache.tools.ant.ProjectHelper instead of the service file, but it has to be a JVM system property. A -D on the ant command line sets an Ant property, not a system property, so the third run ignored it and Ant’s XML parser failed as without the JAR; passing the same -D in ANT_OPTS, which the ant script hands to the JVM, made the fourth run work. The manual notes that experiments with a Java front end and a Groovy front end exist; they are not part of Ant. Since Ant 1.8.2, <import> also picks the right helper for the imported file, so an XML build can import a file in another format.

Extending the command line: ArgumentProcessor

An ArgumentProcessor adds options to the ant command line. It is registered in META-INF/services/org.apache.tools.ant.ArgumentProcessor (a file with the class name, in a JAR on Ant’s class path) or with ArgumentProcessorRegistry.getInstance().registerArgumentProcessor(...) from embedding code. The interface has five methods: readArguments(String[], int), handleArg(List<String>), prepareConfigure(Project, List<String>), handleArg(Project, List<String>) and printUsage(PrintStream). They are called at different moments: when Ant meets an argument it does not know, after all arguments are read, just before the build file is parsed, and after it was parsed. The two handleArg methods return true to make Ant stop right away without an error (as -diagnostics and -projecthelp do).

How readArguments works is not obvious from the manual, and a probe processor that printed what it was given showed the following with ant -lib probe -probe one two:

  • readArguments is called with the whole argument array and the position of the unknown argument. Returning -1 means "not mine" (then Ant reports Unknown argument). Any other value means "handled".

  • The value Ant uses is only that it is not -1. Ant did not skip the words after -probe: one and two were still treated as targets, and the build failed with Target "one" does not exist. The words from the returned position onwards are handed to the processor later (handleArg received [one, two] when readArguments returned pos + 1).

  • So an option that needs a value should be written as a single word, -tidemode=spring, rather than -tidemode spring.

TideModeProcessor.java
import java.io.PrintStream;
import java.util.List;
import org.apache.tools.ant.ArgumentProcessor;
import org.apache.tools.ant.Project;

/** Adds "-tidemode=name" to the Ant command line; the value becomes the user property tide.mode. */
public class TideModeProcessor implements ArgumentProcessor {
    private static final String PREFIX = "-tidemode=";
    private String mode;

    @Override
    public int readArguments(String[] args, int pos) {
        if (args[pos].startsWith(PREFIX)) {
            mode = args[pos].substring(PREFIX.length());
            return pos + 1;         // handled; any value other than -1 means "this word was mine"
        }
        return -1;                  // not mine
    }

    @Override
    public boolean handleArg(List<String> args) {
        return false;               // true would make Ant stop right after the arguments are read
    }

    @Override
    public void prepareConfigure(Project project, List<String> targets) {
        if (mode != null) {
            project.setUserProperty("tide.mode", mode);
        }
    }

    @Override
    public boolean handleArg(Project project, List<String> targets) {
        return false;               // true would make Ant stop after the build file has been parsed
    }

    @Override
    public void printUsage(PrintStream out) {
        out.println("  -tidemode=<name>       sets the property tide.mode");
    }
}

With the service file and the JAR, the new option works and shows up in the help text. Without the JAR, Ant rejects it, and a value in a separate word is rejected too:

$ ant -lib tide-args.jar -tidemode=spring
Buildfile: ./build.xml

show:
     [echo] tide.mode=spring

BUILD SUCCESSFUL
Total time: 0 seconds
$ ant -tidemode=spring
Unknown argument: -tidemode=spring
ant [options] [target [target2 [target3] ...]]

$ ant -lib tide-args.jar -h
...
  -main <class>          override Ant's normal entry point
  -tidemode=<name>       sets the property tide.mode

$ ant -lib tide-args.jar -tidemode spring
Unknown argument: -tidemode
ant [options] [target [target2 [target3] ...]]

(The last two are shortened.) The processor set a user property, so ${tide.mode} is spring in the build file; a processor can equally add listeners or stop the run by returning true from a handleArg method.

Groovy, Gradle and the other tools

  • Groovy’s AntBuilder creates a Project for you and turns method calls into tasks; its run is on Groovy build tools.

  • Gradle’s ant object is an AntBuilder over a Gradle-owned Project, and ant.importBuild("build.xml") turns Ant targets into Gradle tasks; see Ant Integration.

  • Maven’s maven-antrun-plugin runs Ant tasks inside a Maven build; Gradle 9.8.1 bundles Ant (1.10.17 in its distribution).

  • The Maven Resolver Ant Tasks and Ivy are both Ant libraries loaded as antlibs; see Antlibs & Namespaces.

Where the books and older material are outdated

  • Material that shows ProjectHelper.configureProject without init() or without ant-launcher.jar on the class path no longer runs: init() needs AntMain.

  • supportsBuildFile(File) in the ProjectHelper manual is canParseBuildFile(Resource) in 1.10.18.

  • The task-outside-Ant page still says ant.jar is all the application needs. ant-launcher.jar is needed too.

  • Examples that call Main.main from application code end the JVM; use Main.start / startAnt with an exit override, or the Project API.

References