Added tasks for JDK's jmod and jlink tools.
authorVGR <vgr101@gmail.com>
Fri, 7 Dec 2018 04:32:57 +0000 (23:32 -0500)
committerStefan Bodewig <bodewig@apache.org>
Sat, 15 Dec 2018 16:13:58 +0000 (17:13 +0100)
17 files changed:
build.xml
manual/Tasks/jlink.html
manual/Tasks/jmod.html [new file with mode: 0644]
manual/Tasks/link.html [new file with mode: 0644]
manual/tasklist.html
src/etc/testcases/taskdefs/jar.xml
src/etc/testcases/taskdefs/jmod.xml [new file with mode: 0644]
src/etc/testcases/taskdefs/link.xml [new file with mode: 0644]
src/main/org/apache/tools/ant/taskdefs/defaults.properties
src/main/org/apache/tools/ant/taskdefs/modules/Jmod.java [new file with mode: 0644]
src/main/org/apache/tools/ant/taskdefs/modules/Link.java [new file with mode: 0644]
src/main/org/apache/tools/ant/taskdefs/modules/package-info.java [new file with mode: 0644]
src/main/org/apache/tools/ant/taskdefs/optional/jlink/JlinkTask.java
src/main/org/apache/tools/ant/types/ModuleVersion.java [new file with mode: 0644]
src/tests/junit/org/apache/tools/ant/taskdefs/modules/JmodTest.java [new file with mode: 0644]
src/tests/junit/org/apache/tools/ant/taskdefs/modules/LinkTest.java [new file with mode: 0644]
src/tests/junit/org/apache/tools/ant/types/ModuleVersionTest.java [new file with mode: 0644]

index b34bbca..aebcb4a 100644 (file)
--- a/build.xml
+++ b/build.xml
@@ -42,6 +42,7 @@
   <property name="ant.package" value="org/apache/tools/ant"/>
   <property name="taskdefs.package" value="${ant.package}/taskdefs"/>
   <property name="condition.package" value="${taskdefs.package}/condition"/>
+  <property name="modules.package" value="${taskdefs.package}/modules"/>
   <property name="optional.package" value="${taskdefs.package}/optional"/>
   <property name="type.package" value="${ant.package}/types"/>
   <property name="optional.type.package" value="${type.package}/optional"/>
        ===================================================================
   -->
 
+  <selector id="needs.jdk9+">
+    <filename name="${modules.package}/"/>
+  </selector>
+
   <!-- Kaffe has some JDK 1.5 features including java.lang.Readable,
        but not all of them -->
   <selector id="not.in.kaffe">
       <selector id="conditional-patterns">
         <not>
           <or>
+            <selector refid="needs.jdk9+" unless="jdk9+"/>
             <selector refid="not.in.kaffe" if="kaffe"/>
             <selector refid="needs.apache-resolver" unless="apache.resolver.present"/>
             <selector refid="needs.junit" unless="junit.present"/> <!-- TODO should perhaps use -source 1.4? -->
index 76978fb..2c3b49c 100644 (file)
 <p><em>This task has been <u>deprecated</u>. Use a <a href="../Types/zipfileset.html">zipfileset</a>
 or <a href="../Tasks/zip.html#zipgroupfileset">zipgroupfileset</a> with
 the <a href="../Tasks/jar.html">Jar task</a> or <a href="../Tasks/zip.html">Zip task</a>
-instead.</em></p>
+instead.  For a task based on the JDK's jlink tool, see
+<a href="link.html">Link</a>.</em></p>
 
 <h3>Description</h3>
+<p><strong>For a task based on the JDK's jlink tool, see
+<a href="link.html">Link</a>.  This task is for something else
+entirely.</strong></p>
+
 <p>Links entries from sub-builds and libraries.</p>
 
 <p>The <code>jlink</code> task can be used to build jar and zip files, similar to
diff --git a/manual/Tasks/jmod.html b/manual/Tasks/jmod.html
new file mode 100644 (file)
index 0000000..0540918
--- /dev/null
@@ -0,0 +1,330 @@
+<!--
+   Licensed to the Apache Software Foundation (ASF) under one or more
+   contributor license agreements.  See the NOTICE file distributed with
+   this work for additional information regarding copyright ownership.
+   The ASF licenses this file to You under the Apache License, Version 2.0
+   (the "License"); you may not use this file except in compliance with
+   the License.  You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+-->
+<html>
+
+<head>
+<meta http-equiv="Content-Language" content="en-us">
+<link rel="stylesheet" type="text/css" href="../stylesheets/style.css">
+<title>Jmod Task</title>
+</head>
+
+<body>
+
+<h2 id="jmod">Jmod</h2>
+<h3>Description</h3>
+<p>Creates a linkable jmod file from a modular jar file, and optionally from
+other application files such as native libraries and license documents.
+Equivalent to the JDK's
+<a href="https://docs.oracle.com/en/java/javase/11/tools/jmod.html">jmod</a>
+tool.</p>
+<p>Requires Java 9 or later.</p>
+
+<h3>Parameters</h3>
+<table class="attr">
+  <tr>
+    <th scope="col">Attribute</th>
+    <th scope="col">Description</th>
+    <th scope="col">Required</th>
+  </tr>
+  <tr>
+    <td>destFile</td>
+    <td>jmod file to create.</td>
+    <td>Yes</td>
+  </tr>
+  <tr>
+    <td>classpath</td>
+    <td>Files to be placed in the jmod file.  Usually a single module.</td>
+    <td rowspan="2">One of these is required, unless a nested
+                    <code>&lt;classpath&gt;</code> is present.</td>
+  </tr>
+  <tr>
+    <td>classpathref</td>
+    <td class="left">Files to be placed in the jmod file, given as a 
+        <a href="../using.html#references">reference</a>
+        to a path defined elsewhere.</td>
+  </tr>
+  <tr>
+    <td>modulepath</td>
+    <td>Locations of modules on which classpath modules depend.</td>
+    <td>No</td>
+  </tr>
+  <tr>
+    <td>modulepathref</td>
+    <td>Locations of modules on which classpath modules depend,
+        given as a <a href="../using.html#references">reference</a>
+        to a path defined elsewhere.</td>
+    <td>No</td>
+  </tr>
+  <tr>
+    <td>commandpath</td>
+    <td>Directories containing native commands to include in jmod.</td>
+    <td>No</td>
+  </tr>
+  <tr>
+    <td>commandpathref</td>
+    <td>Directories containing native commands to include in jmod,
+        given as a <a href="../using.html#references">reference</a>
+        to a path defined elsewhere.</td>
+    <td>No</td>
+  </tr>
+  <tr>
+    <td>headerpath</td>
+    <td>Directories containing header files to include in jmod.</td>
+    <td>No</td>
+  </tr>
+  <tr>
+    <td>headerpathref</td>
+    <td>Directories containing header files to include in jmod,
+        given as a <a href="../using.html#references">reference</a>
+        to a path defined elsewhere.</td>
+    <td>No</td>
+  </tr>
+  <tr>
+    <td>configpath</td>
+    <td>Directories containing user-editable configuration files
+        to include in jmod.</td>
+    <td>No</td>
+  </tr>
+  <tr>
+    <td>configpathref</td>
+    <td>Directories containing user-editable configuration files
+        to include in jmod,
+        given as a <a href="../using.html#references">reference</a>
+        to a path defined elsewhere.</td>
+    <td>No</td>
+  </tr>
+  <tr>
+    <td>legalpath</td>
+    <td>Directories containing legal licenses and notices to include in jmod.</td>
+    <td>No</td>
+  </tr>
+  <tr>
+    <td>legalpathref</td>
+    <td>Directories containing legal licenses and notices to include in jmod,
+        given as a <a href="../using.html#references">reference</a>
+        to a path defined elsewhere.</td>
+    <td>No</td>
+  </tr>
+  <tr>
+    <td>nativelibpath</td>
+    <td>Directories containing native libraries to include in jmod.</td>
+    <td>No</td>
+  </tr>
+  <tr>
+    <td>nativelibpathref</td>
+    <td>Directories containing native libraries to include in jmod,
+        given as a <a href="../using.html#references">reference</a>
+        to a path defined elsewhere.</td>
+    <td>No</td>
+  </tr>
+  <tr>
+    <td>manpath</td>
+    <td>Directories containing man pages to include in jmod.</td>
+    <td>No</td>
+  </tr>
+  <tr>
+    <td>manpathref</td>
+    <td>Directories containing man pages to include in jmod,
+        given as a <a href="../using.html#references">reference</a>
+        to a path defined elsewhere.</td>
+    <td>No</td>
+  </tr>
+  <tr>
+    <td>version</td>
+    <td><a href="https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/lang/module/ModuleDescriptor.Version.html">Module version</a> of jmod.</td>
+    <td>No</td>
+  </tr>
+  <tr>
+    <td>mainclass</td>
+    <td>Class that acts as executable entry point of module.</td>
+    <td>No</td>
+  </tr>
+  <tr>
+    <td>platform</td>
+    <td>The target platform for the jmod.  Typically takes the form
+        <var>OS</var><code>-</code><var>architecture</var>.  A particular JDK's
+        platform can be seen by running a command like
+        <samp>jmod describe $JDK_HOME/jmods/java.base.jmod | grep -i platform</samp></td>
+    <td>No</td>
+  </tr>
+  <tr>
+    <td>hashModulesPattern</td>
+    <td>Regular expression for names of modules in the module path
+        which depend on the jmod being created, and which should have
+        hashes generated for them and included in the new jmod.</td>
+    <td>No</td>
+  </tr>
+  <tr>
+    <td>resolveByDefault</td>
+    <td>Boolean indicating whether the jmod should be one of
+        the default resolved modules when it is in a module path
+        searched by tools and applications.</td>
+    <td>No.  Default is true.</td>
+  </tr>
+  <tr>
+    <td>moduleWarnings</td>
+    <td>Whether to emit warnings when resolving modules which are
+        not recommended for use.  Comma-separated list of one of more of
+        the following:
+        <dl>
+        <dt><code>deprecated</code></dt>
+        <dd>Warn if module is deprecated</dd>
+        <dt><code>leaving</code></dt>
+        <dd>Warn if module is deprecated for removal</dd>
+        <dt><code>incubating</code></dt>
+        <dd>Warn if module is an incubating (not yet official) module</dd>
+        </dl>
+    </td>
+    <td>No, default is no warnings.</td>
+  </tr>
+</table>
+
+<h3>Parameters specified as nested elements</h3>
+<h4>classpath, modulepath, commandpath, headerpath, configpath, legalpath, nativelibpath, manpath</h4>
+<p>The 
+   <code>classpath</code>,
+   <code>modulepath</code>,
+   <code>commandpath</code>,
+   <code>headerpath</code>,
+   <code>configpath</code>,
+   <code>legalpath</code>,
+   <code>nativelibpath</code>, and
+   <code>manpath</code>
+   attributes are <a href="../using.html#path">path-like structures</a>
+   and can also be set via nested
+   <code>&lt;classpath&gt;</code>,
+   <code>&lt;modulepath&gt;</code>,
+   <code>&lt;commandpath&gt;</code>,
+   <code>&lt;headerpath&gt;</code>,
+   <code>&lt;configpath&gt;</code>,
+   <code>&lt;legalpath&gt;</code>,
+   <code>&lt;nativelibpath&gt;</code>, and
+   <code>&lt;manpath&gt;</code>
+   elements, respectively.</p>
+
+<h4>version</h4>
+<p>Fine-grained alternative to the <code>version</code> attribute.  This
+nested element has these attributes:</p>
+<table class="attr">
+  <tr>
+    <th scope="col">Attribute</th>
+    <th scope="col">Description</th>
+    <th scope="col">Required</th>
+  </tr>
+  <tr>
+    <td>number</td>
+    <td>Primary version number.  Can be any text, as long as it does not
+        contain a hyphen (<code>-</code>) or plus (<code>+</code>).</td>
+    <td>Yes</td>
+  </tr>
+  <tr>
+    <td>preRelease</td>
+    <td>Pre-release version.  Can be any text, as long as it does not
+        contain a plus (<code>+</code>).</td>
+    <td>No</td>
+  </tr>
+  <tr>
+    <td>build</td>
+    <td>Build version.  Can be any text.
+    <td>No</td>
+  </tr>
+</table>
+
+<p>See the <a href="https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/lang/module/ModuleDescriptor.Version.html">ModuleDescriptor.Version documentation</a>
+for a full description of the meaning of each version component.</p>
+
+<h4>moduleWarning</h4>
+<p>Like the <code>moduleWarnings</code> attribute, but only specifies a single
+basis for emitting warnings.  This child element may appear multiple times,
+to specify multiple conditions under which warnings should be emitted by the
+jmod tool.</p>
+
+<p>Attributes:</p>
+<table class="attr">
+  <tr>
+    <th scope="col">Attribute</th>
+    <th scope="col">Description</th>
+    <th scope="col">Required</th>
+  </tr>
+  <tr>
+    <td>reason</td>
+    <td>Condition which will cause jmod tool to emit warnings.  One of:
+        <dl>
+        <dt><code>deprecated</code></dt>
+        <dd>Warn if module is deprecated</dd>
+        <dt><code>leaving</code></dt>
+        <dd>Warn if module is deprecated for removal</dd>
+        <dt><code>incubating</code></dt>
+        <dd>Warn if module is an incubating (not yet official) module</dd>
+        </dl>
+    <td>Yes</td>
+  </tr>
+</table>
+
+<h3>Examples</h3>
+
+<h4>Basic jmod</h4>
+<p>Create a jmod from a single modular jar file:</p>
+<pre>
+&lt;jmod destfile="MyApp.jmod" classpath="build/myapp.jar"/&gt;
+</pre>
+
+<h4>With dependencies</h4>
+<p>Create a jmod from a modular jar file which depends on another module:</p>
+<pre>
+&lt;jmod destfile="MyApp.jmod" classpath="build/myapp.jar"&gt;
+    &lt;modulepath&gt;
+        &lt;pathelement location="libs/thirdpartyutils.jar"/&gt;
+    &lt;/modulepath&gt;
+&lt;/jmod&gt;
+</pre>
+
+<h4>With version</h4>
+<p>Create a jmod with a module version:</p>
+<pre>
+&lt;jmod destfile="MyApp.jmod" classpath="build/myapp.jar"
+      version="1.2.1-ea+29"/&gt;
+</pre>
+
+<p>Create a versioned jmod from module version components:</p>
+<pre>
+&lt;property name="version" value="1.2.1"/&gt;
+&lt;buildnumber/&gt;
+&lt;loadfile property="buildnum" srcFile="build.number"/&gt;
+&lt;jmod destfile="MyApp.jmod" classpath="build/myapp.jar"&gt;
+    &lt;version number="${version}" build="${buildnum}"/&gt;
+&lt;/jmod&gt;
+</pre>
+
+<h4>Main class</h4>
+<p>Create a jmod with a main class:</p>
+<pre>
+&lt;jmod destfile="MyApp.jmod" classpath="build/myapp.jar"
+      mainclass="com.example.myapp.MainWindow"/&gt;
+</pre>
+
+<h4>Target platform</h4>
+<p>Create a jmod for a specific platform, possibly different from the
+current platform:</p>
+
+<pre>
+&lt;jmod destfile="MyApp.jmod" classpath="build/myapp.jar"
+      platform="windows-amd64"/&gt;
+</pre>
+
+</body>
+</html>
diff --git a/manual/Tasks/link.html b/manual/Tasks/link.html
new file mode 100644 (file)
index 0000000..078acc1
--- /dev/null
@@ -0,0 +1,579 @@
+<!--
+   Licensed to the Apache Software Foundation (ASF) under one or more
+   contributor license agreements.  See the NOTICE file distributed with
+   this work for additional information regarding copyright ownership.
+   The ASF licenses this file to You under the Apache License, Version 2.0
+   (the "License"); you may not use this file except in compliance with
+   the License.  You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+-->
+<html>
+
+<head>
+<meta http-equiv="Content-Language" content="en-us">
+<link rel="stylesheet" type="text/css" href="../stylesheets/style.css">
+<title>Link Task</title>
+</head>
+
+<body>
+
+<h2 id="link">Link</h2>
+<h3>Description</h3>
+<p>Assembles jmod files into an executable image.  Equivalent to the JDK's
+<a href="https://docs.oracle.com/en/java/javase/11/tools/jlink.html">jlink</a>
+tool.
+</p>
+<p>Requires Java 9 or later.</p>
+
+<h3>Parameters</h3>
+<table class="attr">
+  <tr>
+    <th scope="col">Attribute</th>
+    <th scope="col">Description</th>
+    <th scope="col">Required</th>
+  </tr>
+  <tr>
+    <td>destDir</td>
+    <td>Root directory of created image.</td>
+    <td>Yes</td>
+  </tr>
+  <tr>
+    <td>modulepath</td>
+    <td>Path-like sequence of jmod files to link in order to create image.</td>
+    <td rowspan="2">One of these is required, unless a nested
+        <code>&lt;modulepath&gt;</code> is present.</td>
+  </tr>
+  <tr>
+    <td>modulepathref</td>
+    <td class="left">Path-like sequence of jmod files to link in order to
+        create image, given as a <a href="../using.html#references">reference</a>
+        to a path defined elsewhere.</td>
+  </tr>
+  <tr>
+    <td>modules</td>
+    <td>Comma-separated list of modules to place in the linked image.</td>
+    <td>Yes, unless one or more nested <code>&lt;module&gt;</code> elements
+        are present.</td>
+  </tr>
+  <tr>
+    <td>observableModules</td>
+    <td>Comma-separated list of explicit modules that comprise
+        "universe" visible to link tool while linking.</td>
+    <td>No</td>
+  </tr>
+  <tr>
+    <td>launchers</td>
+    <td>Comma-separated list of commands, each of the form
+        <var>name</var><code>=</code><var>module</var> or
+        <var>name</var><code>=</code><var>module</var><code>/</code><var>mainclass</var></td>
+    <td>No</td>
+  </tr>
+  <tr>
+    <td>locales</td>
+    <td>Comma-separated list of extra locales, or wildcard patterns matching
+        multiple locale names, to include.
+        Requires <code>jdk.localedata</code> module.</td>
+    <td>No</td>
+  </tr>
+  <tr>
+    <td>excludeResources</td>
+    <td>Comma-separated list of patterns specifying resources to exclude
+        from source jmods.  Each is either a
+        <a href="https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/nio/file/FileSystem.html#getPathMatcher%28java.lang.String%29">standard PathMatcher pattern</a>
+        or <code>@</code><var>filename</var>, indicating a text file with
+        one resource name per line.</td>
+    <td>No</td>
+  </tr>
+  <tr>
+    <td>excludeFiles</td>
+    <td>Comma-separated list of patterns specifying files to exclude
+        from linked image.  Each is either a
+        <a href="https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/nio/file/FileSystem.html#getPathMatcher%28java.lang.String%29">standard PathMatcher pattern</a>
+        or <code>@</code><var>filename</var>, indicating a text file with
+        one file name per line.</td>
+    <td>No</td>
+  </tr>
+  <tr>
+    <td>resourceOrder</td>
+    <td>Comma-separated list of patterns specifying resource search order.
+        Each is either a
+        <a href="https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/nio/file/FileSystem.html#getPathMatcher%28java.lang.String%29">standard PathMatcher pattern</a>
+        or <code>@</code><var>filename</var>, indicating a text file with
+        one resource name per line.</td>
+    <td>No</td>
+  </tr>
+  <tr>
+    <td>bindServices</td>
+    <td>Boolean, whether to include in linked image any service providers
+        found in module path corresponding to service provider interfaces
+        used by explicitly linked modules.</td>
+    <td>No, default is false</td>
+  </tr>
+  <tr>
+    <td>ignoreSigning</td>
+    <td>Boolean, whether to allow signed jar files.
+        (Note: As of Java 11, this is ignored and is always treated as true.)</td>
+    <td>No, default is false</td>
+  </tr>
+  <tr>
+    <td>includeHeaders</td>
+    <td>Boolean, whether to include header files in linked image.</td>
+    <td>No, default is true</td>
+  </tr>
+  <tr>
+    <td>includeManPages</td>
+    <td>Boolean, whether to include man pages in linked image.</td>
+    <td>No, default is true</td>
+  </tr>
+  <tr>
+    <td>includeNativeCommands</td>
+    <td>Boolean, whether to include native executables in linked image.</td>
+    <td>No, default is true</td>
+  </tr>
+  <tr>
+    <td>debug</td>
+    <td>Boolean, whether to include debug information.</td>
+    <td>No, default is true</td>
+  </tr>
+  <tr>
+    <td>verboseLevel</td>
+    <td>If set, the linker will produce verbose output, which will be logged at
+        the specified Ant log level (<code>DEBUG</code>, <code>VERBOSE</code>,
+        <code>INFO</code>, <code>WARN</code>, or <code>ERR</code>).</td>
+    <td>No, default is no verbose output</td>
+  </tr>
+  <tr>
+    <td>compress</td>
+    <td>Compression level of linked image. One of:
+        <dl>
+        <dt><code>0</code> or
+            <code>none</code></dt>
+        <dd>no compression (default)</dd>
+        <dt><code>1</code> or
+            <code>strings</code></dt>
+        <dd>constant string sharing</dd>
+        <dt><code>2</code> or
+            <code>zip</code></dt>
+        <dd>zip compression</dd>
+        </dl>
+    </td>
+    <td>No, default is no compression</td>
+  </tr>
+  <tr>
+    <td>endianness</td>
+    <td>Byte order of linked image, must be <code>little</code> or <code>big</code>
+    <td>No, default is native byte order</td>
+  </tr>
+  <tr>
+    <td>checkDuplicateLegal</td>
+    <td>Boolean.  When merging legal notices from different modules
+        because they have the same name, verify that their contents
+        are identical.</td>
+    <td>No, default is false, which means any license files
+        with the same name are assumed to have the same content, and no
+        checking is done.</td>
+  </tr>
+  <tr>
+    <td>vmType</td>
+    <td>Hotspot VM in image. One of:
+        <ul>
+        <li><code>client</code>
+        <li><code>server</code>
+        <li><code>minimal</code>
+        <li><code>all</code>
+        </ul>
+    </td>
+    <td>No, default is <code>all</code></td>
+  </tr>
+</table>
+
+<h3>Parameters specified as nested elements</h3>
+
+<p><code>&lt;link&gt;</code> can have the following nested elements:</p>
+<ul>
+<li><a href="#nested-modulepath">modulepath</a></li>
+<li><a href="#nested-module">module</a></li>
+<li><a href="#nested-observableModule">observableModule</a></li>
+<li><a href="#nested-launcher">launcher</a></li>
+<li><a href="#nested-locale">locale</a></li>
+<li><a href="#nested-resourceOrder">resourceOrder</a></li>
+<li><a href="#nested-excludeResources">excludeResources</a></li>
+<li><a href="#nested-excludeFiles">excludeFiles</a></li>
+<li><a href="#nested-compress">compress</a></li>
+<li><a href="#nested-releaseInfo">releaseInfo</a></li>
+</ul>
+
+<h4 id="nested-modulepath">modulepath</h4>
+<p><a href="../using.html#path">Path-like structure</a> pointing to
+    jmod files to link into image.</p>
+
+<h4 id="nested-module">module</h4>
+<p>Names a single module to be placed in the linked image.  This may be
+specified multiple times.</p>
+<p>Attributes:</p>
+<table class="attr">
+  <tr>
+    <th scope="col">Attribute</th>
+    <th scope="col">Description</th>
+    <th scope="col">Required</th>
+  </tr>
+  <tr>
+    <td>name</td>
+    <td>Name of module to add.</td>
+    <td>Yes</td>
+  </tr>
+</table>
+
+<h4 id="nested-observableModule">observableModule</h4>
+<p>Names a module visible to the linking process, instead of every module
+in the module path being considered.  This may be specified multiple times.
+<p>Attributes:</p>
+<table class="attr">
+  <tr>
+    <th scope="col">Attribute</th>
+    <th scope="col">Description</th>
+    <th scope="col">Required</th>
+  </tr>
+  <tr>
+    <td>name</td>
+    <td>Name of module to add to list of observable modules.</td>
+    <td>Yes</td>
+  </tr>
+</table>
+
+<h4 id="nested-launcher">launcher</h4>
+<p>Specifies an executable file which will be added to the linked image,
+which executes a particular module's main class.  Attributes:</p>
+<table class="attr">
+  <tr>
+    <th scope="col">Attribute</th>
+    <th scope="col">Description</th>
+    <th scope="col">Required</th>
+  </tr>
+  <tr>
+    <td>name</td>
+    <td>Name of launcher.  This typically is used for the name of the
+        executable file.</td>
+    <td>Yes</td>
+  </tr>
+  <tr>
+    <td>module</td>
+    <td>Name of module to execute.</td>
+    <td>Yes</td>
+  </tr>
+  <tr>
+    <td>mainClass</td>
+    <td>Name of entry point class in module to execute.</td>
+    <td>Required unless module has its own main class defined.</td>
+  </tr>
+</table>
+
+<h4 id="nested-locale">locale</h4>
+<p>Specifies locales to include in linked image.  May be specified multiple
+times.  Requires <code>jdk.localedata</code> module.  Attributes:
+<table class="attr">
+  <tr>
+    <th scope="col">Attribute</th>
+    <th scope="col">Description</th>
+    <th scope="col">Required</th>
+  </tr>
+  <tr>
+    <td>name</td>
+    <td>Name of locale, or wildcard pattern with <q><code>*</code></q>
+        that matches multiple locale names.</td>
+    <td>Yes</td>
+  </tr>
+</table>
+
+<h4 id="nested-resourceOrder">resourceOrder</h4>
+<p>Explicit resource search order in linked image.  May be specified multiple 
+times.  Attributes:</p>
+<table class="attr">
+  <tr>
+    <th scope="col">Attribute</th>
+    <th scope="col">Description</th>
+    <th scope="col">Required</th>
+  </tr>
+  <tr>
+    <td>pattern</td>
+    <td>A <a href="https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/nio/file/FileSystem.html#getPathMatcher%28java.lang.String%29">standard PathMatcher pattern</a>
+        for matching resources</td>
+    <td rowspan="2">Exactly one of these</td>
+  </tr>
+  <tr>
+    <td>listFile</td>
+    <td class="left">Text file containing list of resource names (not patterns),
+        one per line</td>
+  </tr>
+</table>
+
+<p>If the <code>resourceOrder</code> attribute is also present on the task, its
+patterns are treated as if they occur before patterns in nested
+<code><resourceOrder></code> elements.</p>
+
+<h4 id="nested-excludeResources">excludeResources</h4>
+<p>Excludes files from linked image tree.  May be specified multiple times.
+   Attributes:</p>
+<table class="attr">
+  <tr>
+    <th scope="col">Attribute</th>
+    <th scope="col">Description</th>
+    <th scope="col">Required</th>
+  </tr>
+  <tr>
+    <td>pattern</td>
+    <td>A <a href="https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/nio/file/FileSystem.html#getPathMatcher%28java.lang.String%29">standard PathMatcher pattern</a>
+        for matching resources</td>
+    <td rowspan="2">Exactly one of these</td>
+  </tr>
+  <tr>
+    <td>listFile</td>
+    <td class="left">Text file containing list of resource names (not patterns),
+        one per line</td>
+  </tr>
+</table>
+
+<h4 id="nested-excludeFiles">excludeFiles</h4>
+<p>Excludes files from linked image.  May be specified multiple times.
+   Attributes:</p>
+<table class="attr">
+  <tr>
+    <th scope="col">Attribute</th>
+    <th scope="col">Description</th>
+    <th scope="col">Required</th>
+  </tr>
+  <tr>
+    <td>pattern</td>
+    <td>A <a href="https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/nio/file/FileSystem.html#getPathMatcher%28java.lang.String%29">standard PathMatcher pattern</a>
+        for matching files</td>
+    <td rowspan="2">Exactly one of these</td>
+  </tr>
+  <tr>
+    <td>listFile</td>
+    <td class="left">Text file containing list of file names (not patterns),
+        one per line</td>
+  </tr>
+</table>
+
+<h4 id="nested-compress">compress</h4>
+<p>Describes how image should be compressed.  Attributes:</p>
+<table class="attr">
+  <tr>
+    <th scope="col">Attribute</th>
+    <th scope="col">Description</th>
+    <th scope="col">Required</th>
+  </tr>
+  <tr>
+    <td>level</td>
+    <td>Compression level of linked image. One of:
+        <dl>
+        <dt><code>0</code> or
+            <code>none</code></dt>
+        <dd>no compression (default)</dd>
+        <dt><code>1</code> or
+            <code>strings</code></dt>
+        <dd>constant string sharing</dd>
+        <dt><code>2</code> or
+            <code>zip</code></dt>
+        <dd>zip compression</dd>
+        </dl>
+    </td>
+    <td>Yes</td>
+  </tr>
+  <tr>
+    <td>files</td>
+    <td>Comma-separated list of patterns matching files to compress.
+        Each pattern either a
+        <a href="https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/nio/file/FileSystem.html#getPathMatcher%28java.lang.String%29">standard PathMatcher pattern</a>
+        or <code>@</code><var>filename</var>, indicating a text file with
+        one file name per line.</td>
+    <td>No</td>
+  </tr>
+</table>
+
+<p><code>&lt;compress&gt;</code> can also have any number of nested
+<code>&lt;files&gt;</code> elements, with these attributes:</p>
+<table class="attr">
+  <tr>
+    <th scope="col">Attribute</th>
+    <th scope="col">Description</th>
+    <th scope="col">Required</th>
+  </tr>
+  <tr>
+    <td>pattern</td>
+    <td>A <a href="https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/nio/file/FileSystem.html#getPathMatcher%28java.lang.String%29">standard PathMatcher pattern</a>
+        for matching files</td>
+    <td rowspan="2">Exactly one of these</td>
+  </tr>
+  <tr>
+    <td>listFile</td>
+    <td class="left">Text file containing list of file names (not patterns),
+        one per line</td>
+  </tr>
+</table>
+
+<h4 id="nested-releaseInfo">releaseInfo</h4>
+<p>Replaces, augments, or trims the image's release info properties.
+Can be specified multiple times.  Attributes:
+<table class="attr">
+  <tr>
+    <th scope="col">Attribute</th>
+    <th scope="col">Description</th>
+    <th scope="col">Required</th>
+  </tr>
+  <tr>
+    <td>file</td>
+    <td>Java properties file containing new release info properties
+        that will entirely replace the current ones.</td>
+    <td>No</td>
+  </tr>
+  <tr>
+    <td>delete</td>
+    <td>Comma-separated property keys to remove from application's
+        release info
+    <td>No</td>
+  </tr>
+</table>
+
+<p><code>&lt;releaseInfo&gt;</code> can also have any number of these nested elements:</p>
+<h5>add</h5>
+<p>Specifies additional release info properties.  Attributes:</p>
+<table class="attr">
+  <tr>
+    <th scope="col">Attribute</th>
+    <th scope="col">Description</th>
+    <th scope="col">Required</th>
+  </tr>
+  <tr>
+    <td>key</td>
+    <td>Key of single property to add.</td>
+    <td rowspan="2">Yes, unless <code>file</code> is specified</td>
+  </tr>
+  <tr>
+    <td>value</td>
+    <td class="left">Value of single property to add.</td>
+  </tr>
+  <tr>
+    <td>file</td>
+    <td>Java property file containing any number of properties to add.</td>
+    <td>Yes, unless <code>key</code> and <code>value</code> are specified</td>
+  </tr>
+  <tr>
+    <td>charset</td>
+    <td>Character set of property file.</td>
+    <td>No, default is <code>ISO_8859_1</code>, in accordance with
+        java.util.Properties class.</td>
+  </tr>
+</table>
+
+<h5>delete</h5>
+<p>Property keys to remove from applicaiton's release info.  Attributes:</p>
+<table class="attr">
+  <tr>
+    <th scope="col">Attribute</th>
+    <th scope="col">Description</th>
+    <th scope="col">Required</th>
+  </tr>
+  <tr>
+    <td>key</td>
+    <td>Key of property to remove.</td>
+    <td>Yes</td>
+  </tr>
+</table>
+
+<h3>Examples</h3>
+<h4>Basic linking</h4>
+<pre>
+&lt;jmod destfile="MyApp.jmod" classpath="build/myapp.jar"/&gt;
+&lt;link destDir="build/image" modulepath="MyApp.jmod"
+      modules="com.example.myapp"/&gt;
+</pre>
+
+<h4>Custom binaries</h4>
+<p>This will cause a <samp>bin/MyEditor</samp> script to appear in the
+image:
+<pre>
+&lt;jmod destfile="MyApp.jmod" classpath="build/myapp.jar"/&gt;
+&lt;link destDir="build/image" modulepath="MyApp.jmod"
+      modules="com.example.myapp"
+      launchers="MyEditor=com.example.myapp/com.example.myapp.editors.EditorMain"/&gt;
+</pre>
+
+<p>Same thing, using a nested launcher element:</p>
+<pre>
+&lt;jmod destfile="MyApp.jmod" classpath="build/myapp.jar"/&gt;
+&lt;link destDir="build/image" modulepath="MyApp.jmod"
+      modules="com.example.myapp"&gt;
+
+    &lt;launcher name="MyEditor" module="com.example.myapp"
+              mainClass="com.example.myapp.editors.EditorMain"/&gt;
+
+&lt;/link&gt;
+</pre>
+
+<h4>Limiting locales</h4>
+<p>Include just the locales needed by the application from the <a href="https://docs.oracle.com/en/java/javase/11/docs/api/jdk.localedata/module-summary.html">jdk.localedata</a> module:</p>
+<pre>
+&lt;jmod destfile="MyApp.jmod" classpath="build/myapp.jar"/&gt;
+&lt;link destDir="build/image" modulepath="MyApp.jmod"
+      modules="com.example.myapp,jdk.localedata"
+      locales="zh,jp-*"/&gt;
+</pre>
+
+<h4>Compressed image</h4>
+<p>Compress entire image:</p>
+<pre>
+&lt;jmod destfile="MyApp.jmod" classpath="build/myapp.jar"/&gt;
+&lt;link destDir="build/image" modulepath="MyApp.jmod"
+      modules="com.example.myapp,jdk.localedata"
+      compress="zip"/&gt;
+</pre>
+
+<p>Compress only some files in the image:</p>
+<pre>
+&lt;jmod destfile="MyApp.jmod" classpath="build/myapp.jar"/&gt;
+&lt;link destDir="build/image" modulepath="MyApp.jmod"
+      modules="com.example.myapp,jdk.localedata"&gt;
+
+    &lt;compress level="zip" files=".*\.xml"/&gt;
+
+&lt;/link&gt;
+</pre>
+
+<h4>Cross-compiling</h4>
+<p>To create an image for a different platform:
+
+<ul>
+<li>Download the JDK for that platform, and expand the archive manually into
+a directory of your choice.  (Downloading a zip or tar.gz version of a JDK
+instead of an installer will make this easier.)</li>
+<li>Determine the foreign JDK's platform string.  This can be done with
+a command that examines the JDK's <samp>jmods/java.base.jmod</samp> file:
+<pre>
+jmod describe "$FOREIGN_JDK_HOME"/jmods/java.base.jmod | grep '^platform'
+</pre>
+</li>
+<li>Create your jmod using the foreign JDK's platform string:
+<pre>
+&lt;jmod destfile="MyApp.jmod" classpath="build/myapp.jar" platform="windows-amd64"/&gt;
+</pre>
+</li>
+<li>Link with the foreign JDK's <samp>jmods</samp> directory in the module path:
+<pre>
+&lt;link destDir="build/image"
+      modulepath="MyApp.jmod;${foreign-jdk-home}/jmods"
+      modules="com.example.myapp"/&gt;
+</pre>
+</li>
+</ul>
+<ul>
+
+</body>
+</html>
index 5325e38..8194bdb 100644 (file)
   <li><a href="Tasks/jjdoc.html">JJDoc</a></li>
   <li><a href="Tasks/jjtree.html">JJTree</a></li>
   <li><a href="Tasks/jlink.html"><em>Jlink</em></a></li>
+  <li><a href="Tasks/jmod.html">Jmod</a></li>
   <li><a href="Tasks/jspc.html"><em>JspC</em></a></li>
   <li><a href="Tasks/junit.html">JUnit</a> (3 &amp; 4)</li>
   <li><a href="Tasks/junitlauncher.html">JUnitLauncher</a> (JUnit 5)</li>
   <li><a href="Tasks/junitreport.html">JUnitReport</a></li>
   <li><a href="Tasks/length.html">Length</a><br/></li>
+  <li><a href="Tasks/link.html">Link</a><br/></li>
   <li><a href="Tasks/loadfile.html">LoadFile</a></li>
   <li><a href="Tasks/loadproperties.html">LoadProperties</a></li>
   <li><a href="Tasks/loadresource.html">LoadResource</a></li>
index 78d1abc..cb686f7 100644 (file)
          destfile="${tmp.jar}"
          basedir="."
          includes="j*.xml"
-         excludes="java.xml"
+         excludes="java.xml,jmod.xml"
          update="true"
     />
   </target>
          destfile="${tmp.jar}"
          basedir="."
          includes="j*.xml"
-         excludes="java.xml"
+         excludes="java.xml,jmod.xml"
     />
   </target>
 
           depends="makezip">
     <jar destfile="${tmp.jar}"
          update="true">
-      <zipfileset src="${tmp.zip}" excludes="java.xml"/>
+      <zipfileset src="${tmp.zip}" excludes="java.xml,jmod.xml"/>
     </jar>
   </target>
 
   <target name="testNoRecreateZipfilesetExcludesWithoutUpdate"
           depends="makezip">
     <jar destfile="${tmp.jar}">
-      <zipfileset src="${tmp.zip}" excludes="java.xml"/>
+      <zipfileset src="${tmp.zip}" excludes="java.xml,jmod.xml"/>
     </jar>
   </target>
 
diff --git a/src/etc/testcases/taskdefs/jmod.xml b/src/etc/testcases/taskdefs/jmod.xml
new file mode 100644 (file)
index 0000000..2a22998
--- /dev/null
@@ -0,0 +1,992 @@
+<?xml version="1.0"?>
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one or more
+  contributor license agreements.  See the NOTICE file distributed with
+  this work for additional information regarding copyright ownership.
+  The ASF licenses this file to You under the Apache License, Version 2.0
+  (the "License"); you may not use this file except in compliance with
+  the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+<project name="jmodtest" default="nil" >
+  <import file="../buildfiletest-base.xml"/>
+
+  <target name="nil"/>
+
+  <target name="-dirs">
+    <mkdir dir="${input}"/>
+    <mkdir dir="${output}"/>
+    <property name="jmod" value="${output}/test.jmod"/>
+    <property name="version" value="1.0.1-+99"/>
+  </target>
+
+  <target name="setUp" depends="-dirs">
+  </target>
+
+  <!-- Creates simple modular jar, with only Java SE dependencies. -->
+  <target name="-hello" depends="-dirs">
+
+    <property name="hello.root" value="${input}/hello"/>
+
+    <property name="hello.src" value="${hello.root}/src"/>
+    <property name="hello.classes" value="${hello.root}/classes"/>
+    <property name="hello.jar.dir" value="${hello.root}/jars"/>
+    <property name="hello.jar" value="${hello.jar.dir}/hello.jar"/>
+
+    <property name="hello.pkg"     value=             "org.apache.tools.ant.test.hello"/>
+    <property name="hello.pkg.dir" value="${hello.src}/org/apache/tools/ant/test/hello"/>
+
+    <property name="hello.main-class" value="${hello.pkg}.HelloWorld"/>
+
+    <mkdir dir="${hello.pkg.dir}"/>
+    <echo file="${hello.pkg.dir}/HelloWorld.java">
+package ${hello.pkg};
+
+import java.util.logging.Logger;
+
+public class HelloWorld {
+    public void run() {
+        Logger logger = Logger.getLogger(HelloWorld.class.getName());
+        logger.info("HELLO WORLD");
+    }
+
+    public static void main(String[] args) {
+        new HelloWorld().run();
+    }
+}
+    </echo>
+    <echo file="${hello.src}/module-info.java">
+module ${hello.pkg} {
+    exports ${hello.pkg};
+    requires java.logging;
+}
+    </echo>
+
+    <mkdir dir="${hello.classes}"/>
+    <javac srcdir="${hello.src}" destdir="${hello.classes}" includeAntRuntime="false"/>
+
+    <mkdir dir="${hello.jar.dir}"/>
+    <jar destfile="${hello.jar}" basedir="${hello.classes}"/>
+  </target>
+
+  <!-- Creates modular jar with dependency on hello module. -->
+  <target name="-smile" depends="-dirs,-hello">
+    <property name="smile.root" value="${input}/smile"/>
+
+    <property name="smile.src" value="${smile.root}/src"/>
+    <property name="smile.classes" value="${smile.root}/classes"/>
+    <property name="smile.jar.dir" value="${smile.root}/jars"/>
+    <property name="smile.jar" value="${smile.jar.dir}/smile.jar"/>
+
+    <property name="smile.pkg"     value=             "org.apache.tools.ant.test.smile"/>
+    <property name="smile.pkg.dir" value="${smile.src}/org/apache/tools/ant/test/smile"/>
+
+    <property name="smile.main-class" value="${smile.pkg}.Smile"/>
+
+    <mkdir dir="${smile.pkg.dir}"/>
+    <echo file="${smile.pkg.dir}/Smile.java">
+package ${smile.pkg};
+
+import java.util.logging.Logger;
+import ${hello.pkg}.HelloWorld;
+
+public class Smile {
+    public void run() {
+        Logger logger = Logger.getLogger(Smile.class.getName());
+        logger.info("\u263a\u263b\u263a\u263b");
+    }
+
+    public static void main(String[] args) {
+        new Smile().run();
+        new HelloWorld().run();
+    }
+}
+    </echo>
+    <echo file="${smile.src}/module-info.java">
+module ${smile.pkg} {
+    exports ${smile.pkg};
+    requires java.logging;
+    requires ${hello.pkg};
+}
+    </echo>
+
+    <mkdir dir="${smile.classes}"/>
+    <javac srcdir="${smile.src}" destdir="${smile.classes}"
+           includeAntRuntime="false"
+           modulepath="${hello.jar}"/>
+
+    <mkdir dir="${smile.jar.dir}"/>
+    <jar destfile="${smile.jar}" basedir="${smile.classes}"/>
+  </target>
+
+  <!-- Creates additional modular jar, with only Java SE dependencies. -->
+  <target name="-foobar" depends="-smile">
+
+    <property name="foobar.root" value="${input}/foobar"/>
+
+    <property name="foobar.src" value="${foobar.root}/src"/>
+    <property name="foobar.classes" value="${foobar.root}/classes"/>
+    <property name="foobar.jar.dir" value="${foobar.root}/jars"/>
+    <property name="foobar.jar" value="${foobar.jar.dir}/foobar.jar"/>
+
+    <property name="foobar.pkg"     value=             "org.apache.tools.ant.test.foobar"/>
+    <property name="foobar.pkg.dir" value="${foobar.src}/org/apache/tools/ant/test/foobar"/>
+
+    <property name="foobar.main-class" value="${foobar.pkg}.FooBar"/>
+
+    <mkdir dir="${foobar.pkg.dir}"/>
+    <echo file="${foobar.pkg.dir}/FooBar.java">
+package ${foobar.pkg};
+
+import ${hello.main-class};
+import ${smile.main-class};
+
+public class FooBar {
+    public void run() {
+        new HelloWorld().run();
+        new Smile().run();
+    }
+
+    public static void main(String[] args) {
+        new FooBar().run();
+    }
+}
+    </echo>
+    <echo file="${foobar.src}/module-info.java">
+module ${foobar.pkg} {
+    exports ${foobar.pkg};
+    requires java.logging;
+    requires ${hello.pkg};
+    requires ${smile.pkg};
+}
+    </echo>
+
+    <mkdir dir="${foobar.classes}"/>
+    <javac srcdir="${foobar.src}" destdir="${foobar.classes}"
+           includeAntRuntime="false" modulepath="${hello.jar};${smile.jar}"/>
+
+    <mkdir dir="${foobar.jar.dir}"/>
+    <jar destfile="${foobar.jar}" basedir="${foobar.classes}"/>
+  </target>
+
+  <target name="destAndClasspathNoJmod" depends="-hello">
+    <jmod destfile="${jmod}" classpath="${hello.jar}"/>
+  </target>
+
+  <target name="destAndClasspathOlderThanJmod" depends="-hello">
+    <property name="dateformat" value="yyyy-MM-dd HH:mm:ss.SSS"/>
+    <tstamp>
+      <format property="future" pattern="${dateformat}" offset="1" unit="hour"/>
+    </tstamp>
+    <touch file="${jmod}" datetime="${future}" pattern="${dateformat}"/>
+
+    <jmod destfile="${jmod}" classpath="${hello.jar}"/>
+  </target>
+
+  <target name="noDestFile">
+    <jmod classpath="."/>
+  </target>
+
+  <target name="noClasspath">
+    <jmod destfile="${jmod}"/>
+  </target>
+
+  <target name="emptyClasspath">
+    <jmod destfile="${jmod}" classpath=""/>
+  </target>
+
+  <target name="nonexistentClasspath">
+    <jmod destfile="${jmod}" classpath="dummy:dummy2"/>
+  </target>
+
+  <target name="classpathref" depends="-hello">
+    <path id="classpathref.testpath">
+      <pathelement location="${hello.jar}"/>
+    </path>
+
+    <jmod destfile="${jmod}" classpathref="classpathref.testpath"/>
+  </target>
+
+  <target name="classpath-nested" depends="-hello">
+    <jmod destfile="${jmod}">
+      <classpath>
+        <pathelement location="${hello.jar}"/>
+      </classpath>
+    </jmod>
+  </target>
+
+  <target name="classpath-both" depends="-smile">
+    <jmod destfile="${jmod}" classpath="${hello.jar}">
+      <classpath>
+        <pathelement location="${smile.classes}"/>
+      </classpath>
+    </jmod>
+  </target>
+
+  <!-- modulepath targets -->
+
+  <target name="modulepath" depends="-smile">
+    <jmod destfile="${jmod}" classpath="${smile.jar}"
+        modulepath="${hello.jar.dir}"/>
+  </target>
+
+  <target name="modulepathref" depends="-smile">
+    <path id="modulepathref.testpath">
+      <pathelement location="${hello.jar.dir}"/>
+    </path>
+
+    <jmod destfile="${jmod}" classpath="${smile.jar}"
+          modulepathref="modulepathref.testpath"/>
+  </target>
+
+  <target name="modulepath-nested" depends="-smile">
+    <jmod destfile="${jmod}" classpath="${smile.jar}">
+      <modulepath>
+        <pathelement location="${hello.jar.dir}"/>
+      </modulepath>
+    </jmod>
+  </target>
+
+  <target name="modulepath-both" depends="-smile,-foobar">
+    <jmod destfile="${jmod}" classpath="${foobar.jar}"
+          modulepath="${hello.jar.dir}">
+      <modulepath>
+        <pathelement location="${smile.jar.dir}"/>
+      </modulepath>
+    </jmod>
+  </target>
+
+  <target name="modulepathnondir" depends="-smile">
+    <jmod destfile="${jmod}" classpath="${smile.jar}"
+          modulepath="${hello.jar}"/>
+  </target>
+
+  <!-- commandpath targets -->
+
+  <target name="commandpath" depends="-hello">
+    <property name="commands" value="${output}/commands"/>
+    <mkdir dir="${commands}"/>
+
+    <property name="command1" value="${commands}/command1"/>
+
+    <echo file="${command1}">
+#!/bin/bash
+`dirname "$0"`/java -m ${hello.pkg}/${hello.pkg}.HelloWorld
+    </echo>
+    <setpermissions mode="777" nonPosixMode="tryDosOrPass">
+      <file file="${command1}"/>
+    </setpermissions>
+
+    <jmod destfile="${jmod}" classpath="${hello.jar}"
+          commandpath="${commands}"/>
+  </target>
+
+  <target name="commandpathref" depends="-hello">
+    <property name="commands" value="${output}/commands"/>
+    <mkdir dir="${commands}"/>
+
+    <property name="command2" value="${commands}/command2"/>
+
+    <echo file="${command2}">
+#!/bin/bash
+`dirname "$0"`/java -m ${hello.pkg}/${hello.pkg}.HelloWorld
+    </echo>
+    <setpermissions mode="777" nonPosixMode="tryDosOrPass">
+      <file file="${command2}"/>
+    </setpermissions>
+
+    <path id="commandpathref.testpath">
+      <pathelement location="${commands}"/>
+    </path>
+
+    <jmod destfile="${jmod}" classpath="${hello.jar}"
+          commandpathref="commandpathref.testpath"/>
+  </target>
+
+  <target name="commandpath-nested" depends="-hello">
+    <property name="commands" value="${output}/commands"/>
+    <mkdir dir="${commands}"/>
+
+    <property name="command3" value="${commands}/command3"/>
+
+    <echo file="${command3}">
+#!/bin/bash
+`dirname "$0"`/java -m ${hello.pkg}/${hello.pkg}.HelloWorld
+    </echo>
+    <setpermissions mode="777" nonPosixMode="tryDosOrPass">
+      <file file="${command3}"/>
+    </setpermissions>
+
+    <jmod destfile="${jmod}" classpath="${hello.jar}">
+      <commandpath>
+        <pathelement location="${commands}"/>
+      </commandpath>
+    </jmod>
+  </target>
+
+  <target name="commandpath-both" depends="-hello">
+    <property name="commands1" value="${output}/commands1"/>
+    <property name="commands2" value="${output}/commands2"/>
+    <mkdir dir="${commands1}"/>
+    <mkdir dir="${commands2}"/>
+
+    <property name="command4" value="${commands1}/command4"/>
+    <echo file="${command4}">
+#!/bin/bash
+`dirname "$0"`/java -m ${hello.pkg}/${hello.pkg}.HelloWorld
+    </echo>
+    <setpermissions mode="777" nonPosixMode="tryDosOrPass">
+      <file file="${command4}"/>
+    </setpermissions>
+
+    <property name="command5" value="${commands2}/command5"/>
+    <echo file="${command5}">
+#!/bin/bash
+`dirname "$0"`/java -m ${hello.pkg}/${hello.pkg}.HelloWorld
+    </echo>
+    <setpermissions mode="777" nonPosixMode="tryDosOrPass">
+      <file file="${command5}"/>
+    </setpermissions>
+
+    <jmod destfile="${jmod}" classpath="${hello.jar}"
+          commandpath="${commands1}">
+      <commandpath>
+        <pathelement location="${commands2}"/>
+      </commandpath>
+    </jmod>
+  </target>
+
+  <!-- headerpath targets -->
+
+  <target name="headerpath" depends="-hello">
+    <property name="headers" value="${output}/headers"/>
+    <mkdir dir="${headers}"/>
+
+    <property name="header1" value="${headers}/header1.h"/>
+
+    <echo file="${header1}">
+typedef int index_t;
+    </echo>
+
+    <jmod destfile="${jmod}" classpath="${hello.jar}"
+          headerpath="${headers}"/>
+  </target>
+
+  <target name="headerpathref" depends="-hello">
+    <property name="headers" value="${output}/headers"/>
+    <mkdir dir="${headers}"/>
+
+    <property name="header2" value="${headers}/header2.h"/>
+
+    <echo file="${header2}">
+typedef char * string_t;
+    </echo>
+
+    <path id="headerpathref.testpath">
+      <pathelement location="${headers}"/>
+    </path>
+
+    <jmod destfile="${jmod}" classpath="${hello.jar}"
+          headerpathref="headerpathref.testpath"/>
+  </target>
+
+  <target name="headerpath-nested" depends="-hello">
+    <property name="headers" value="${output}/headers"/>
+    <mkdir dir="${headers}"/>
+
+    <property name="header3" value="${headers}/header3.h"/>
+
+    <echo file="${header3}">
+typedef char * string_t;
+    </echo>
+
+    <jmod destfile="${jmod}" classpath="${hello.jar}">
+      <headerpath>
+        <pathelement location="${headers}"/>
+      </headerpath>
+    </jmod>
+  </target>
+
+  <target name="headerpath-both" depends="-hello">
+    <property name="headers4" value="${output}/headers4"/>
+    <property name="headers5" value="${output}/headers5"/>
+    <mkdir dir="${headers4}"/>
+    <mkdir dir="${headers5}"/>
+
+    <property name="header4" value="${headers4}/header4.h"/>
+    <property name="header5" value="${headers5}/header5.h"/>
+
+    <echo file="${header4}">
+typedef int index_t;
+    </echo>
+    <echo file="${header5}">
+typedef char * string_t;
+    </echo>
+
+    <jmod destfile="${jmod}" classpath="${hello.jar}" headerpath="${headers4}">
+      <headerpath>
+        <pathelement location="${headers5}"/>
+      </headerpath>
+    </jmod>
+  </target>
+
+  <!-- configpath targets -->
+
+  <target name="configpath" depends="-hello">
+    <property name="config" value="${output}/config"/>
+    <mkdir dir="${config}"/>
+
+    <property name="config1" value="${config}/config1.properties"/>
+
+    <echo file="${config1}">
+timeout=3600
+    </echo>
+
+    <jmod destfile="${jmod}" classpath="${hello.jar}" configpath="${config}"/>
+  </target>
+
+  <target name="configpathref" depends="-hello">
+    <property name="config" value="${output}/config"/>
+    <mkdir dir="${config}"/>
+
+    <property name="config2" value="${config}/config2.properties"/>
+
+    <echo file="${config2}">
+timeout=7200
+    </echo>
+
+    <path id="configpathref.testpath">
+      <pathelement location="${config}"/>
+    </path>
+
+    <jmod destfile="${jmod}" classpath="${hello.jar}"
+          configpathref="configpathref.testpath"/>
+  </target>
+
+  <target name="configpath-nested" depends="-hello">
+    <property name="config" value="${output}/config"/>
+    <mkdir dir="${config}"/>
+
+    <property name="config3" value="${config}/config3.properties"/>
+
+    <echo file="${config3}">
+timeout=7200
+    </echo>
+
+    <jmod destfile="${jmod}" classpath="${hello.jar}">
+      <configpath>
+        <pathelement location="${config}"/>
+      </configpath>
+    </jmod>
+  </target>
+
+  <target name="configpath-both" depends="-hello">
+    <property name="config4" value="${output}/config4"/>
+    <property name="config5" value="${output}/config5"/>
+    <mkdir dir="${config4}"/>
+    <mkdir dir="${config5}"/>
+
+    <property name="configfile4" value="${config4}/config4.properties"/>
+    <property name="configfile5" value="${config5}/config5.properties"/>
+
+    <echo file="${configfile4}">
+timeout=3600
+    </echo>
+    <echo file="${configfile5}">
+timeout=7200
+    </echo>
+
+    <jmod destfile="${jmod}" classpath="${hello.jar}" configpath="${config4}">
+      <configpath>
+        <pathelement location="${config5}"/>
+      </configpath>
+    </jmod>
+  </target>
+
+  <!-- legalpath targets -->
+
+  <target name="legalpath" depends="-hello">
+    <property name="legal" value="${output}/legal"/>
+    <mkdir dir="${legal}"/>
+
+    <property name="legal1" value="${legal}/legal1.txt"/>
+
+    <echo file="${legal1}">
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+    </echo>
+
+    <jmod destfile="${jmod}" classpath="${hello.jar}" legalpath="${legal}"/>
+  </target>
+
+  <target name="legalpathref" depends="-hello">
+    <property name="legal" value="${output}/legal"/>
+    <mkdir dir="${legal}"/>
+
+    <property name="legal2" value="${legal}/legal2.txt"/>
+
+    <echo file="${legal2}">
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+    </echo>
+
+    <path id="legalpathref.testpath">
+      <pathelement location="${legal}"/>
+    </path>
+
+    <jmod destfile="${jmod}" classpath="${hello.jar}"
+          legalpathref="legalpathref.testpath"/>
+  </target>
+
+  <target name="legalpath-nested" depends="-hello">
+    <property name="legal" value="${output}/legal"/>
+    <mkdir dir="${legal}"/>
+
+    <property name="legal3" value="${legal}/legal3.txt"/>
+
+    <echo file="${legal3}">
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+    </echo>
+
+    <jmod destfile="${jmod}" classpath="${hello.jar}">
+      <legalpath>
+        <pathelement location="${legal}"/>
+      </legalpath>
+    </jmod>
+  </target>
+
+  <target name="legalpath-both" depends="-hello">
+    <property name="legal4" value="${output}/legal4"/>
+    <property name="legal5" value="${output}/legal5"/>
+    <mkdir dir="${legal4}"/>
+    <mkdir dir="${legal5}"/>
+
+    <property name="legalfile4" value="${legal4}/legal4.txt"/>
+    <property name="legalfile5" value="${legal5}/legal5.txt"/>
+
+    <echo file="${legalfile4}">
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+    </echo>
+    <copy file="${legalfile4}" tofile="${legalfile5}"/>
+
+    <jmod destfile="${jmod}" classpath="${hello.jar}" legalpath="${legal4}">
+      <legalpath>
+        <pathelement location="${legal5}"/>
+      </legalpath>
+    </jmod>
+  </target>
+
+  <!-- manpath targets -->
+
+  <target name="manpath" depends="-hello">
+    <property name="manpages" value="${output}/manpages"/>
+    <mkdir dir="${manpages}"/>
+
+    <property name="man1" value="${manpages}/man1.1"/>
+
+    <echo file="${man1}"><!--
+--><![CDATA[.TH TRUE "1" "February 2017" "GNU coreutils 8.26" "User Commands"
+.SH NAME
+true \- do nothing, successfully
+.SH SYNOPSIS
+.B true
+[\fI\,ignored command line arguments\/\fR]
+.br
+.B true
+\fI\,OPTION\/\fR
+.SH DESCRIPTION
+.\" Add any additional description here
+.PP
+Exit with a status code indicating success.
+.TP
+\fB\-\-help\fR
+display this help and exit
+.TP
+\fB\-\-version\fR
+output version information and exit
+.PP
+NOTE: your shell may have its own version of true, which usually supersedes
+the version described here.  Please refer to your shell's documentation
+for details about the options it supports.
+.SH AUTHOR
+Written by Jim Meyering.
+.SH "REPORTING BUGS"
+GNU coreutils online help: <http://www.gnu.org/software/coreutils/>
+.br
+Report true translation bugs to <http://translationproject.org/team/>
+.SH COPYRIGHT
+Copyright \(co 2016 Free Software Foundation, Inc.
+License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>.
+.br
+This is free software: you are free to change and redistribute it.
+There is NO WARRANTY, to the extent permitted by law.
+.SH "SEE ALSO"
+Full documentation at: <http://www.gnu.org/software/coreutils/true>
+]]><!--
+    --></echo>
+
+    <jmod destfile="${jmod}" classpath="${hello.jar}" manpath="${manpages}"/>
+  </target>
+
+  <target name="manpathref" depends="-hello">
+    <property name="manpages" value="${output}/manpages"/>
+    <mkdir dir="${manpages}"/>
+
+    <property name="man2" value="${manpages}/man2.1"/>
+
+    <echo file="${man2}"><!--
+--><![CDATA[.TH FALSE "1" "February 2017" "GNU coreutils 8.26" "User Commands"
+.SH NAME
+false \- do nothing, unsuccessfully
+.SH SYNOPSIS
+.B false
+[\fI\,ignored command line arguments\/\fR]
+.br
+.B false
+\fI\,OPTION\/\fR
+.SH DESCRIPTION
+.\" Add any additional description here
+.PP
+Exit with a status code indicating failure.
+.TP
+\fB\-\-help\fR
+display this help and exit
+.TP
+\fB\-\-version\fR
+output version information and exit
+.PP
+NOTE: your shell may have its own version of false, which usually supersedes
+the version described here.  Please refer to your shell's documentation
+for details about the options it supports.
+.SH AUTHOR
+Written by Jim Meyering.
+.SH "REPORTING BUGS"
+GNU coreutils online help: <http://www.gnu.org/software/coreutils/>
+.br
+Report false translation bugs to <http://translationproject.org/team/>
+.SH COPYRIGHT
+Copyright \(co 2016 Free Software Foundation, Inc.
+License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>.
+.br
+This is free software: you are free to change and redistribute it.
+There is NO WARRANTY, to the extent permitted by law.
+.SH "SEE ALSO"
+Full documentation at: <http://www.gnu.org/software/coreutils/false>
+]]><!--
+    --></echo>
+
+    <path id="manpathref.testpath">
+      <pathelement location="${manpages}"/>
+    </path>
+
+    <jmod destfile="${jmod}" classpath="${hello.jar}"
+          manpathref="manpathref.testpath"/>
+  </target>
+
+  <target name="manpath-nested" depends="-hello">
+    <property name="manpages" value="${output}/manpages"/>
+    <mkdir dir="${manpages}"/>
+
+    <property name="man3" value="${manpages}/man3.1"/>
+
+    <echo file="${man3}"><!--
+--><![CDATA[.TH FALSE "1" "February 2017" "GNU coreutils 8.26" "User Commands"
+.SH NAME
+false \- do nothing, unsuccessfully
+.SH SYNOPSIS
+.B false
+[\fI\,ignored command line arguments\/\fR]
+.br
+.B false
+\fI\,OPTION\/\fR
+.SH DESCRIPTION
+.\" Add any additional description here
+.PP
+Exit with a status code indicating failure.
+.TP
+\fB\-\-help\fR
+display this help and exit
+.TP
+\fB\-\-version\fR
+output version information and exit
+.PP
+NOTE: your shell may have its own version of false, which usually supersedes
+the version described here.  Please refer to your shell's documentation
+for details about the options it supports.
+.SH AUTHOR
+Written by Jim Meyering.
+.SH "REPORTING BUGS"
+GNU coreutils online help: <http://www.gnu.org/software/coreutils/>
+.br
+Report false translation bugs to <http://translationproject.org/team/>
+.SH COPYRIGHT
+Copyright \(co 2016 Free Software Foundation, Inc.
+License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>.
+.br
+This is free software: you are free to change and redistribute it.
+There is NO WARRANTY, to the extent permitted by law.
+.SH "SEE ALSO"
+Full documentation at: <http://www.gnu.org/software/coreutils/false>
+]]><!--
+    --></echo>
+
+    <jmod destfile="${jmod}" classpath="${hello.jar}">
+      <manpath>
+        <pathelement location="${manpages}"/>
+      </manpath>
+    </jmod>
+  </target>
+
+  <target name="manpath-both" depends="-hello">
+    <property name="manpages4" value="${output}/manpages4"/>
+    <property name="manpages5" value="${output}/manpages5"/>
+    <mkdir dir="${manpages4}"/>
+    <mkdir dir="${manpages5}"/>
+
+    <property name="man4" value="${manpages4}/man4.1"/>
+    <property name="man5" value="${manpages5}/man5.1"/>
+
+    <echo file="${man4}"><!--
+--><![CDATA[.TH TRUE "1" "February 2017" "GNU coreutils 8.26" "User Commands"
+.SH NAME
+true \- do nothing, successfully
+.SH SYNOPSIS
+.B true
+[\fI\,ignored command line arguments\/\fR]
+.br
+.B true
+\fI\,OPTION\/\fR
+.SH DESCRIPTION
+.\" Add any additional description here
+.PP
+Exit with a status code indicating success.
+.TP
+\fB\-\-help\fR
+display this help and exit
+.TP
+\fB\-\-version\fR
+output version information and exit
+.PP
+NOTE: your shell may have its own version of true, which usually supersedes
+the version described here.  Please refer to your shell's documentation
+for details about the options it supports.
+.SH AUTHOR
+Written by Jim Meyering.
+.SH "REPORTING BUGS"
+GNU coreutils online help: <http://www.gnu.org/software/coreutils/>
+.br
+Report true translation bugs to <http://translationproject.org/team/>
+.SH COPYRIGHT
+Copyright \(co 2016 Free Software Foundation, Inc.
+License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>.
+.br
+This is free software: you are free to change and redistribute it.
+There is NO WARRANTY, to the extent permitted by law.
+.SH "SEE ALSO"
+Full documentation at: <http://www.gnu.org/software/coreutils/true>
+]]><!--
+    --></echo>
+    <echo file="${man5}"><!--
+--><![CDATA[.TH FALSE "1" "February 2017" "GNU coreutils 8.26" "User Commands"
+.SH NAME
+false \- do nothing, unsuccessfully
+.SH SYNOPSIS
+.B false
+[\fI\,ignored command line arguments\/\fR]
+.br
+.B false
+\fI\,OPTION\/\fR
+.SH DESCRIPTION
+.\" Add any additional description here
+.PP
+Exit with a status code indicating failure.
+.TP
+\fB\-\-help\fR
+display this help and exit
+.TP
+\fB\-\-version\fR
+output version information and exit
+.PP
+NOTE: your shell may have its own version of false, which usually supersedes
+the version described here.  Please refer to your shell's documentation
+for details about the options it supports.
+.SH AUTHOR
+Written by Jim Meyering.
+.SH "REPORTING BUGS"
+GNU coreutils online help: <http://www.gnu.org/software/coreutils/>
+.br
+Report false translation bugs to <http://translationproject.org/team/>
+.SH COPYRIGHT
+Copyright \(co 2016 Free Software Foundation, Inc.
+License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>.
+.br
+This is free software: you are free to change and redistribute it.
+There is NO WARRANTY, to the extent permitted by law.
+.SH "SEE ALSO"
+Full documentation at: <http://www.gnu.org/software/coreutils/false>
+]]><!--
+    --></echo>
+
+    <jmod destfile="${jmod}" classpath="${hello.jar}" manpath="${manpages4}">
+      <manpath>
+        <pathelement location="${manpages5}"/>
+      </manpath>
+    </jmod>
+  </target>
+
+  <!-- nativelibpath targets -->
+
+  <target name="nativelibpath" depends="-hello">
+    <property name="nativelib" location="${java.home}/lib;${java.home}/bin"/>
+    <jmod destfile="${jmod}" classpath="${hello.jar}"
+          nativelibpath="${nativelib}"/>
+  </target>
+
+  <target name="nativelibpathref" depends="-hello">
+
+    <path id="nativelibpathref.testpath">
+      <pathelement location="${java.home}/lib"/>
+      <pathelement location="${java.home}/bin"/>
+    </path>
+
+    <jmod destfile="${jmod}" classpath="${hello.jar}"
+          nativelibpathref="nativelibpathref.testpath"/>
+  </target>
+
+  <target name="nativelibpath-nested" depends="-hello">
+    <jmod destfile="${jmod}" classpath="${hello.jar}">
+      <nativelibpath>
+        <pathelement location="${java.home}/lib"/>
+        <pathelement location="${java.home}/bin"/>
+      </nativelibpath>
+    </jmod>
+  </target>
+
+  <target name="nativelibpath-both" depends="-hello">
+    <jmod destfile="${jmod}" classpath="${hello.jar}"
+          nativelibpath="${java.home}/lib;${java.home}/bin">
+      <nativelibpath>
+        <dirset dir="${java.home}">
+          <include name="lib/server"/>
+          <include name="bin/server"/>
+        </dirset>
+      </nativelibpath>
+    </jmod>
+  </target>
+
+  <!-- non-path targets -->
+
+  <target name="version" depends="-hello">
+    <jmod destfile="${jmod}" classpath="${hello.jar}" version="${version}"/>
+  </target>
+
+  <target name="version-nested" depends="-hello">
+    <jmod destfile="${jmod}" classpath="${hello.jar}">
+      <version number="1.0.1" build="99"/>
+    </jmod>
+  </target>
+
+  <target name="version-nested-number" depends="-hello">
+    <jmod destfile="${jmod}" classpath="${hello.jar}">
+      <version number="1.0.1"/>
+    </jmod>
+  </target>
+
+  <target name="version-nested-no-number" depends="-hello">
+    <jmod destfile="${jmod}" classpath="${hello.jar}">
+      <version build="99"/>
+    </jmod>
+  </target>
+
+  <target name="version-nested-invalid-number" depends="-hello">
+    <jmod destfile="${jmod}" classpath="${hello.jar}">
+      <version number="1-0-1" build="99"/>
+    </jmod>
+  </target>
+
+  <target name="version-nested-invalid-prerelease" depends="-hello">
+    <jmod destfile="${jmod}" classpath="${hello.jar}">
+      <version number="1.0.1" build="99" prerelease="unit+testing"/>
+    </jmod>
+  </target>
+
+  <target name="version-both" depends="-hello">
+    <jmod destfile="${jmod}" classpath="${hello.jar}" version="${version}">
+      <version number="1.0.1" build="99"/>
+    </jmod>
+  </target>
+
+  <target name="mainclass" depends="-hello">
+    <jmod destfile="${jmod}" classpath="${hello.jar}"
+          mainclass="${hello.main-class}"/>
+  </target>
+
+  <target name="platform" depends="-smile">
+    <property name="target-platform" value="windows-amd64"/>
+    <jmod destfile="${jmod}" classpath="${hello.jar}"
+          platform="${target-platform}"/>
+  </target>
+
+  <target name="hashing" depends="-smile">
+    <jmod destfile="${jmod}" classpath="${hello.jar}"
+          modulepath="${smile.jar.dir}"
+          hashModulesPattern=".*smile.*"/>
+  </target>
+</project>
diff --git a/src/etc/testcases/taskdefs/link.xml b/src/etc/testcases/taskdefs/link.xml
new file mode 100644 (file)
index 0000000..3212475
--- /dev/null
@@ -0,0 +1,1088 @@
+<?xml version="1.0"?>
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one or more
+  contributor license agreements.  See the NOTICE file distributed with
+  this work for additional information regarding copyright ownership.
+  The ASF licenses this file to You under the Apache License, Version 2.0
+  (the "License"); you may not use this file except in compliance with
+  the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+<project name="linktest" default="nil" xmlns:unless="ant:unless">
+  <import file="../buildfiletest-base.xml"/>
+
+  <target name="nil"/>
+
+  <target name="-dirs">
+    <mkdir dir="${input}"/>
+
+    <property name="jmods" value="${output}/jmods"/>
+    <mkdir dir="${jmods}"/>
+
+    <property name="manpages" value="${output}/manpages"/>
+    <mkdir dir="${manpages}"/>
+
+    <property name="image" value="${output}/image"/>
+
+    <!--
+      Before Java 10, JDK's jmods directory must be explicitly included
+      in module paths.
+     -->
+    <condition property="jdkmods" value=""
+               else="${java.home}/jmods${path.separator}">
+      <hasmethod classname="java.util.List" method="copyOf"/>
+    </condition>
+  </target>
+
+  <target name="setUp" depends="-dirs">
+  </target>
+
+  <!-- Creates simple modular jar, with only Java SE dependencies. -->
+  <target name="-hello" depends="-dirs">
+
+    <property name="hello.root" value="${input}/hello"/>
+
+    <property name="hello.src" value="${hello.root}/src"/>
+    <property name="hello.classes" value="${hello.root}/classes"/>
+    <property name="hello.jar.dir" value="${hello.root}/jars"/>
+    <property name="hello.jar" value="${hello.jar.dir}/hello.jar"/>
+    <property name="hello.legal" value="${hello.root}/legal"/>
+
+    <property name="hello.mod"      value="org.apache.tools.ant.test.hello"/>
+    <property name="hello.pkg"      value="org.apache.tools.ant.test.hello"/>
+    <property name="hello.pkg.path" value="org/apache/tools/ant/test/hello"/>
+    <property name="hello.pkg.dir" value="${hello.src}/${hello.pkg.path}"/>
+
+    <property name="hello.main-class" value="${hello.pkg}.HelloWorld"/>
+
+    <mkdir dir="${hello.pkg.dir}"/>
+    <echo file="${hello.pkg.dir}/HelloWorld.java">
+package ${hello.pkg};
+
+import java.util.logging.Logger;
+
+public class HelloWorld {
+    public void run(String[] resources) {
+        Logger logger = Logger.getLogger(HelloWorld.class.getName());
+        logger.info("HELLO WORLD");
+
+        for (String resource : resources) {
+            Object url = HelloWorld.class.getResource(resource);
+            logger.info(resource + " " + (url != null ? "present" : "absent"));
+        }
+    }
+
+    public static void main(String[] args) {
+        new HelloWorld().run(args);
+    }
+}
+    </echo>
+    <echo file="${hello.src}/module-info.java">
+module ${hello.mod} {
+    exports ${hello.pkg};
+    requires java.logging;
+}
+    </echo>
+
+    <mkdir dir="${hello.classes}"/>
+    <javac srcdir="${hello.src}" destdir="${hello.classes}"
+           includeAntRuntime="false" debug="true"/>
+    <echo file="${hello.classes}/${hello.pkg.path}/resource1.txt"
+          message="First HelloWorld resource."/>
+    <echo file="${hello.classes}/${hello.pkg.path}/resource2.txt"
+          message="Second HelloWorld resource."/>
+
+    <mkdir dir="${hello.jar.dir}"/>
+    <jar destfile="${hello.jar}" basedir="${hello.classes}"/>
+  </target>
+
+  <!-- Creates modular jar with dependency on hello module. -->
+  <target name="-smile" depends="-dirs,-hello">
+    <property name="smile.root" value="${input}/smile"/>
+
+    <property name="smile.src" value="${smile.root}/src"/>
+    <property name="smile.classes" value="${smile.root}/classes"/>
+    <property name="smile.jar.dir" value="${smile.root}/jars"/>
+    <property name="smile.jar" value="${smile.jar.dir}/smile.jar"/>
+    <property name="smile.legal" value="${smile.root}/legal"/>
+
+    <property name="smile.mod"      value="org.apache.tools.ant.test.smile"/>
+    <property name="smile.pkg"      value="org.apache.tools.ant.test.smile"/>
+    <property name="smile.pkg.path" value="org/apache/tools/ant/test/smile"/>
+    <property name="smile.pkg.dir" value="${smile.src}/${smile.pkg.path}"/>
+
+    <property name="smile.main-class" value="${smile.pkg}.Smile"/>
+
+    <mkdir dir="${smile.pkg.dir}"/>
+    <echo file="${smile.pkg.dir}/Smile.java">
+package ${smile.pkg};
+
+import java.util.logging.Logger;
+import ${hello.pkg}.HelloWorld;
+
+public class Smile {
+    public void run(String[] resources) {
+        Logger logger = Logger.getLogger(Smile.class.getName());
+        logger.info("\u263a\u263b\u263a\u263b");
+
+        for (String resource : resources) {
+            Object url = HelloWorld.class.getResource(resource);
+            logger.info(resource + " " + (url != null ? "present" : "absent"));
+        }
+    }
+
+    public static void main(String[] args) {
+        new Smile().run(args);
+        new HelloWorld().run(args);
+    }
+}
+    </echo>
+    <echo file="${smile.src}/module-info.java">
+module ${smile.mod} {
+    exports ${smile.pkg};
+    requires java.logging;
+    requires ${hello.mod};
+}
+    </echo>
+
+    <mkdir dir="${smile.classes}"/>
+    <javac srcdir="${smile.src}" destdir="${smile.classes}"
+           includeAntRuntime="false" debug="true"
+           modulepath="${hello.jar}"/>
+    <echo file="${smile.classes}/${smile.pkg.path}/resource1.txt"
+          message="First Smile resource."/>
+    <echo file="${smile.classes}/${smile.pkg.path}/resource2.txt"
+          message="Second Smile resource."/>
+
+    <mkdir dir="${smile.jar.dir}"/>
+    <jar destfile="${smile.jar}" basedir="${smile.classes}"/>
+  </target>
+
+  <target name="-manpages" depends="-dirs">
+
+    <property name="man1" value="${manpages}/man1.1"/>
+    <property name="man2" value="${manpages}/man2.1"/>
+
+    <echo file="${man1}"><!--
+--><![CDATA[.TH TRUE "1" "February 2017" "GNU coreutils 8.26" "User Commands"
+.SH NAME
+true \- do nothing, successfully
+.SH SYNOPSIS
+.B true
+[\fI\,ignored command line arguments\/\fR]
+.br
+.B true
+\fI\,OPTION\/\fR
+.SH DESCRIPTION
+.\" Add any additional description here
+.PP
+Exit with a status code indicating success.
+.TP
+\fB\-\-help\fR
+display this help and exit
+.TP
+\fB\-\-version\fR
+output version information and exit
+.PP
+NOTE: your shell may have its own version of true, which usually supersedes
+the version described here.  Please refer to your shell's documentation
+for details about the options it supports.
+.SH AUTHOR
+Written by Jim Meyering.
+.SH "REPORTING BUGS"
+GNU coreutils online help: <http://www.gnu.org/software/coreutils/>
+.br
+Report true translation bugs to <http://translationproject.org/team/>
+.SH COPYRIGHT
+Copyright \(co 2016 Free Software Foundation, Inc.
+License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>.
+.br
+This is free software: you are free to change and redistribute it.
+There is NO WARRANTY, to the extent permitted by law.
+.SH "SEE ALSO"
+Full documentation at: <http://www.gnu.org/software/coreutils/true>
+]]><!--
+    --></echo>
+
+    <echo file="${man2}"><!--
+--><![CDATA[.TH FALSE "1" "February 2017" "GNU coreutils 8.26" "User Commands"
+.SH NAME
+false \- do nothing, unsuccessfully
+.SH SYNOPSIS
+.B false
+[\fI\,ignored command line arguments\/\fR]
+.br
+.B false
+\fI\,OPTION\/\fR
+.SH DESCRIPTION
+.\" Add any additional description here
+.PP
+Exit with a status code indicating failure.
+.TP
+\fB\-\-help\fR
+display this help and exit
+.TP
+\fB\-\-version\fR
+output version information and exit
+.PP
+NOTE: your shell may have its own version of false, which usually supersedes
+the version described here.  Please refer to your shell's documentation
+for details about the options it supports.
+.SH AUTHOR
+Written by Jim Meyering.
+.SH "REPORTING BUGS"
+GNU coreutils online help: <http://www.gnu.org/software/coreutils/>
+.br
+Report false translation bugs to <http://translationproject.org/team/>
+.SH COPYRIGHT
+Copyright \(co 2016 Free Software Foundation, Inc.
+License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>.
+.br
+This is free software: you are free to change and redistribute it.
+There is NO WARRANTY, to the extent permitted by law.
+.SH "SEE ALSO"
+Full documentation at: <http://www.gnu.org/software/coreutils/false>
+]]><!--
+    --></echo>
+  </target>
+
+  <target name="-legal" depends="-smile">
+    <property name="sharedlicense.name" value="USELESSLICENSE"/>
+    <property name="sharedlicense.text"
+              value="This license grants no particular rights."/>
+
+    <mkdir dir="${hello.legal}"/>
+    <mkdir dir="${smile.legal}"/>
+
+    <echo file="${hello.legal}/${sharedlicense.name}"
+          message="${sharedlicense.text}"/>
+    <echo file="${smile.legal}/${sharedlicense.name}"
+          message="${sharedlicense.text}"/>
+
+    <property name="uniquelicense.name" value="UNIQUELICENSE"/>
+    <echo file="${hello.legal}/${uniquelicense.name}"
+          message="License for ${hello.mod}."/>
+    <echo file="${smile.legal}/${uniquelicense.name}"
+          message="License for ${smile.mod}."/>
+  </target>
+
+  <target name="-jmods" depends="-smile,-legal">
+    <property name="hello.jmod" value="${jmods}/hello.jmod"/>
+    <property name="smile.jmod" value="${jmods}/smile.jmod"/>
+
+    <jmod destfile="${hello.jmod}"
+          classpath="${hello.jar}"
+          legalpath="${hello.legal}"
+          manpath="${manpages}"/>
+
+    <jmod destfile="${smile.jmod}"
+          classpath="${smile.jar}"
+          legalpath="${smile.legal}"
+          manpath="${manpages}"
+          modulepath="${hello.jar.dir}"/>
+  </target>
+
+  <target name="modulepath" depends="-jmods">
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}"/>
+  </target>
+
+  <target name="imageNewerThanJmods" depends="-jmods">
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}"/>
+
+    <chmod perm="a+w">
+      <fileset dir="${image}"/>
+    </chmod>
+
+    <property name="dateformat" value="yyyy-MM-dd HH:mm:ss.SSS"/>
+    <tstamp>
+      <format property="future" pattern="${dateformat}" offset="1" unit="hour"/>
+    </tstamp>
+    <touch datetime="${future}" pattern="${dateformat}">
+      <fileset dir="${image}"/>
+    </touch>
+
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}"/>
+  </target>
+
+  <target name="nomodulepath" depends="-jmods">
+    <link destdir="${image}"
+          modules="${hello.mod},${smile.mod}"/>
+  </target>
+
+  <target name="nomodules" depends="-jmods">
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"/>
+  </target>
+
+  <target name="modulepathref" depends="-jmods">
+    <path id="modules">
+      <pathelement location="${jdkmods}" unless:blank="${jdkmods}"/>
+      <pathelement location="${jmods}"/>
+    </path>
+    <link destdir="${image}" modulepathref="modules"
+          modules="${hello.mod},${smile.mod}"/>
+  </target>
+
+  <target name="modulepath-nested" depends="-jmods">
+    <link destdir="${image}" modules="${hello.mod},${smile.mod}">
+      <modulepath>
+        <pathelement location="${jdkmods}" unless:blank="${jdkmods}"/>
+        <pathelement location="${jmods}"/>
+      </modulepath>
+    </link>
+  </target>
+
+  <target name="modulepath-both" depends="-jmods">
+    <property name="mod1" value="${output}/mod1"/>
+    <property name="mod2" value="${output}/mod2"/>
+
+    <mkdir dir="${mod1}"/>
+    <mkdir dir="${mod2}"/>
+
+    <copy file="${hello.jmod}" todir="${mod1}"/>
+    <copy file="${smile.jmod}" todir="${mod2}"/>
+
+    <link destdir="${image}" modulepath="${jdkmods}${mod1}"
+          modules="${hello.mod},${smile.mod}">
+      <modulepath>
+        <pathelement location="${mod2}"/>
+      </modulepath>
+    </link>
+  </target>
+
+  <target name="modules-nested" depends="-jmods">
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}">
+      <module name="${hello.mod}"/>
+      <module name="${smile.mod}"/>
+    </link>
+  </target>
+
+  <target name="modules-nested-missing-name" depends="-jmods">
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}">
+      <module/>
+      <module name="${smile.mod}"/>
+    </link>
+  </target>
+
+  <target name="modules-both" depends="-jmods">
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}" modules="${hello.mod}">
+      <module name="${smile.mod}"/>
+      <!--
+        Adding this launcher guarantees an error occurs unless
+        both modules are found.
+      -->
+      <launcher name="Smile" module="${smile.mod}" mainclass="${smile.main-class}"/>
+    </link>
+  </target>
+
+  <target name="observable" depends="-jmods">
+    <link destdir="${image}" modulepath="${jmods};${java.home}/jmods"
+          modules="${hello.mod},${smile.mod}"
+          observableModules="java.base"/>
+  </target>
+
+  <target name="observable-nested" depends="-jmods">
+    <link destdir="${image}" modulepath="${jmods};${java.home}/jmods"
+          modules="${hello.mod},${smile.mod}">
+        <observableModule name="java.base"/>
+    </link>
+  </target>
+
+  <target name="observable-nested-missing-name" depends="-jmods">
+    <link destdir="${image}" modulepath="${jmods};${java.home}/jmods"
+          modules="${hello.mod},${smile.mod}">
+        <observableModule/>
+    </link>
+  </target>
+
+  <target name="observable-both" depends="-jmods">
+    <link destdir="${image}" modulepath="${jmods};${java.home}/jmods"
+          modules="${hello.mod},${smile.mod}"
+          observableModules="java.base">
+        <observableModule name="java.logging"/>
+    </link>
+  </target>
+
+  <target name="launchers" depends="-jmods">
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}"
+          launchers="Hello=${hello.mod}/${hello.main-class},Smile=${smile.mod}/${smile.main-class}"/>
+  </target>
+
+  <target name="launchers-nested" depends="-jmods">
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}">
+      <launcher name="Hello" module="${hello.mod}" mainClass="${hello.main-class}"/>
+      <launcher name="Smile" module="${smile.mod}" mainClass="${smile.main-class}"/>
+    </link>
+  </target>
+
+  <target name="launchers-nested-missing-name" depends="-jmods">
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}">
+      <launcher              module="${hello.mod}" mainClass="${hello.main-class}"/>
+      <launcher name="Smile" module="${smile.mod}" mainClass="${smile.main-class}"/>
+    </link>
+  </target>
+
+  <target name="launchers-nested-missing-module" depends="-jmods">
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}">
+      <launcher name="Hello"                       mainClass="${hello.main-class}"/>
+      <launcher name="Smile" module="${smile.mod}" mainClass="${smile.main-class}"/>
+    </link>
+  </target>
+
+  <target name="launchers-both" depends="-jmods">
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}"
+          launchers="Hello=${hello.mod}/${hello.main-class}">
+      <launcher name="Smile" module="${smile.mod}" mainClass="${smile.main-class}"/>
+    </link>
+  </target>
+
+  <target name="-localefinder" depends="-dirs">
+
+    <property name="localefinder.root" value="${input}/localefinder"/>
+
+    <property name="localefinder.src" value="${localefinder.root}/src"/>
+    <property name="localefinder.classes" value="${localefinder.root}/classes"/>
+    <property name="localefinder.jar.dir" value="${localefinder.root}/jars"/>
+    <property name="localefinder.jar" value="${localefinder.jar.dir}/localefinder.jar"/>
+
+    <property name="localefinder.mod"      value="org.apache.tools.ant.test.localefinder"/>
+    <property name="localefinder.pkg"      value="org.apache.tools.ant.test.localefinder"/>
+    <property name="localefinder.pkg.path" value="org/apache/tools/ant/test/localefinder"/>
+    <property name="localefinder.pkg.dir" value="${localefinder.src}/${localefinder.pkg.path}"/>
+
+    <property name="localefinder.main-class" value="${localefinder.pkg}.LocaleFinder"/>
+
+    <mkdir dir="${localefinder.pkg.dir}"/>
+    <echo file="${localefinder.pkg.dir}/LocaleFinder.java"><![CDATA[
+package ${localefinder.pkg};
+
+import java.util.Locale;
+import java.util.Arrays;
+import java.util.Set;
+import java.util.HashSet;
+
+public class LocaleFinder {
+    public static void main(String[] languagesToFind) {
+        Set<String> languages = new HashSet<>();
+        for (Locale locale : Locale.getAvailableLocales()) {
+            languages.add(locale.getLanguage());
+        }
+
+        boolean matched = languages.containsAll(Arrays.asList(languagesToFind));
+        System.exit(matched ? 0 : 1);
+    }
+}]]>
+    </echo>
+    <echo file="${localefinder.src}/module-info.java">
+module ${localefinder.mod} {
+    exports ${localefinder.pkg};
+    requires jdk.localedata;
+}
+    </echo>
+
+    <mkdir dir="${localefinder.classes}"/>
+    <javac srcdir="${localefinder.src}"
+           destdir="${localefinder.classes}"
+           includeAntRuntime="false"
+           debug="true"/>
+
+    <mkdir dir="${localefinder.jar.dir}"/>
+    <jar destfile="${localefinder.jar}" basedir="${localefinder.classes}"/>
+
+    <property name="localefinder.jmod" value="${jmods}/localefinder.jmod"/>
+    <jmod destfile="${localefinder.jmod}" classpath="${localefinder.jar}"/>
+  </target>
+
+  <target name="locales" depends="-localefinder">
+    <link destdir="${image}" modulepath="${jmods};${java.home}/jmods"
+          modules="${localefinder.mod},jdk.localedata"
+          locales="zh,in"/>
+  </target>
+
+  <target name="locales-nested" depends="-localefinder">
+    <link destdir="${image}" modulepath="${jmods};${java.home}/jmods"
+          modules="${localefinder.mod},jdk.localedata">
+      <locale name="zh"/>
+      <locale name="in"/>
+    </link>
+  </target>
+
+  <target name="locales-nested-missing-name" depends="-localefinder">
+    <link destdir="${image}" modulepath="${jmods};${java.home}/jmods"
+          modules="${localefinder.mod},jdk.localedata">
+      <locale/>
+      <locale name="in"/>
+    </link>
+  </target>
+
+  <target name="locales-both" depends="-localefinder">
+    <link destdir="${image}" modulepath="${jmods};${java.home}/jmods"
+          modules="${localefinder.mod},jdk.localedata"
+          locales="zh">
+      <locale name="in"/>
+    </link>
+  </target>
+
+  <target name="ordering" depends="-jmods">
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}"
+          resourceOrder="**/resource1.txt"/>
+  </target>
+
+  <target name="ordering-nested" depends="-jmods">
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}">
+      <resourceOrder pattern="**/resource1.txt"/>
+    </link>
+  </target>
+
+  <target name="ordering-nested-file" depends="-jmods">
+    <tempfile property="listfile" destdir="${output}" suffix=".lst"/>
+    <echo file="${listfile}" message="**/resource1.txt"/>
+
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}">
+      <resourceOrder listfile="${listfile}"/>
+    </link>
+  </target>
+
+  <target name="ordering-nested-no-attr" depends="-jmods">
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}">
+      <resourceOrder/>
+    </link>
+  </target>
+
+  <target name="ordering-nested-both" depends="-jmods">
+    <tempfile property="listfile" destdir="${output}" suffix=".lst"/>
+    <echo file="${listfile}" message="**/resource1.txt"/>
+
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}">
+      <resourceOrder pattern="**/resource1.txt" listfile="${listfile}"/>
+    </link>
+  </target>
+
+  <target name="ordering-both" depends="-jmods">
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}"
+          resourceOrder="**/resource1.txt">
+      <resourceOrder pattern="**/resource2.txt"/>
+    </link>
+  </target>
+
+  <target name="excluderesources" depends="-jmods">
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}"
+          excludeResources="**/resource1.txt"/>
+  </target>
+
+  <target name="excluderesources-nested" depends="-jmods">
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}">
+      <excludeResources pattern="**/resource1.txt"/>
+    </link>
+  </target>
+
+  <target name="excluderesources-nested-file" depends="-jmods">
+    <tempfile property="listfile" destdir="${output}" suffix=".lst"/>
+    <echo file="${listfile}" message="**/resource1.txt"/>
+
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}">
+      <excludeResources listfile="${listfile}"/>
+    </link>
+  </target>
+
+  <target name="excluderesources-nested-no-attr" depends="-jmods">
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}">
+      <excludeResources/>
+    </link>
+  </target>
+
+  <target name="excluderesources-nested-both" depends="-jmods">
+    <tempfile property="listfile" destdir="${output}" suffix=".lst"/>
+    <echo file="${listfile}" message="**/resource1.txt"/>
+
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}">
+      <excludeResources pattern="**/resource1.txt" listfile="${listfile}"/>
+    </link>
+  </target>
+
+  <target name="excluderesources-both" depends="-jmods">
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}"
+          excludeResources="**/resource1.txt">
+      <excludeResources pattern="**/resource2.txt"/>
+    </link>
+  </target>
+
+  <target name="excludefiles" depends="-jmods">
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}"
+          excludeFiles="**/file1.txt"/>
+  </target>
+
+  <target name="excludefiles-nested" depends="-jmods">
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}">
+      <excludeFiles pattern="**/file1.txt"/>
+    </link>
+  </target>
+
+  <target name="excludefiles-nested-file" depends="-jmods">
+    <tempfile property="listfile" destdir="${output}" suffix=".lst"/>
+    <echo file="${listfile}" message="**/file1.txt"/>
+
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}">
+      <excludeFiles listfile="${listfile}"/>
+    </link>
+  </target>
+
+  <target name="excludefiles-nested-no-attr" depends="-jmods">
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}">
+      <excludeFiles/>
+    </link>
+  </target>
+
+  <target name="excludefiles-nested-both" depends="-jmods">
+    <tempfile property="listfile" destdir="${output}" suffix=".lst"/>
+    <echo file="${listfile}" message="**/file1.txt"/>
+
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}">
+      <excludeFiles pattern="**/file1.txt" listfile="${listfile}"/>
+    </link>
+  </target>
+
+  <target name="excludefiles-both" depends="-jmods">
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}"
+          excludeFiles="**/file1.txt">
+      <excludeFiles pattern="**/file2.txt"/>
+    </link>
+  </target>
+
+  <target name="includeheaders" depends="-jmods">
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}"
+          includeHeaders="false"/>
+  </target>
+
+  <target name="includemanpages" depends="-manpages,-jmods">
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}"
+          includeManPages="false"/>
+  </target>
+
+  <target name="includenativecommands" depends="-jmods">
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}"
+          includeNativeCommands="false"/>
+  </target>
+
+  <target name="compression" depends="-jmods">
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}"/>
+
+    <property name="compressed-image" value="${output}/image2"/>
+    <link destdir="${compressed-image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}"
+          compress="zip"/>
+  </target>
+
+  <target name="compression-nested" depends="-jmods">
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}"/>
+
+    <property name="compressed-image" value="${output}/image2"/>
+    <link destdir="${compressed-image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}">
+      <compress level="zip"/>
+    </link>
+  </target>
+
+  <target name="compression-nested-no-attr" depends="-jmods">
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}">
+      <compress/>
+    </link>
+  </target>
+
+  <target name="compression-both" depends="-jmods">
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}"/>
+
+    <property name="compressed-image" value="${output}/image2"/>
+    <link destdir="${compressed-image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}"
+          compress="zip">
+      <compress level="zip"/>
+    </link>
+  </target>
+
+  <target name="endian" depends="-jmods">
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}" endianness="little"/>
+  </target>
+
+  <target name="vm" depends="-jmods">
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}" vmType="server"/>
+  </target>
+
+  <target name="releaseinfo-file" depends="-jmods">
+    <property name="props" value="${output}/app.properties"/>
+    <echo file="${props}" message="test=true" encoding="ISO-8859-1"/>
+
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}">
+      <releaseinfo file="${props}"/>
+    </link>
+  </target>
+
+  <target name="releaseinfo-delete" depends="-jmods">
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}">
+      <releaseinfo>
+        <add key="test" value="true"/>
+      </releaseinfo>
+      <releaseinfo delete="test"/>
+    </link>
+  </target>
+
+  <target name="releaseinfo-nested-delete" depends="-jmods">
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}">
+      <releaseinfo>
+        <add key="test" value="true"/>
+      </releaseinfo>
+      <releaseinfo>
+        <delete key="test"/>
+      </releaseinfo>
+    </link>
+  </target>
+
+  <target name="releaseinfo-nested-delete-no-key" depends="-jmods">
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}">
+      <releaseinfo>
+        <delete/>
+      </releaseinfo>
+    </link>
+  </target>
+
+  <target name="releaseinfo-nested-delete-both" depends="-jmods">
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}">
+      <releaseinfo>
+        <add key="test" value="true"/>
+        <add key="foo" value="bar"/>
+      </releaseinfo>
+      <releaseinfo delete="test">
+        <delete key="foo"/>
+      </releaseinfo>
+    </link>
+  </target>
+
+  <target name="releaseinfo-add-file" depends="-jmods">
+    <property name="props" value="${output}/app.properties"/>
+    <echo file="${props}" message="test=s&#xed;" encoding="ISO-8859-1"/>
+
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}">
+      <releaseinfo>
+        <add file="${props}"/>
+      </releaseinfo>
+    </link>
+  </target>
+
+  <target name="releaseinfo-add-file-charset" depends="-jmods">
+    <property name="props" value="${output}/app.properties"/>
+    <echo file="${props}" message="test=s&#xed;" encoding="UTF-8"/>
+
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}">
+      <releaseinfo>
+        <add file="${props}" charset="UTF-8"/>
+      </releaseinfo>
+    </link>
+  </target>
+
+  <target name="releaseinfo-add-key" depends="-jmods">
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}">
+      <releaseinfo>
+        <add key="test" value="true"/>
+      </releaseinfo>
+    </link>
+  </target>
+
+  <target name="releaseinfo-add-no-value" depends="-jmods">
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}">
+      <releaseinfo>
+        <add key="test"/>
+      </releaseinfo>
+    </link>
+  </target>
+
+  <target name="releaseinfo-add-no-key" depends="-jmods">
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}">
+      <releaseinfo>
+        <add value="true"/>
+      </releaseinfo>
+    </link>
+  </target>
+
+  <target name="releaseinfo-add-file-and-key" depends="-jmods">
+    <property name="props" value="${output}/app.properties"/>
+    <echo file="${props}" message="test=s&#xed;" encoding="UTF-8"/>
+
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}">
+      <releaseinfo>
+        <add file="${props}" key="test"/>
+      </releaseinfo>
+    </link>
+  </target>
+
+  <target name="releaseinfo-add-file-and-value" depends="-jmods">
+    <property name="props" value="${output}/app.properties"/>
+    <echo file="${props}" message="test=s&#xed;" encoding="UTF-8"/>
+
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}">
+      <releaseinfo>
+        <add file="${props}" value="true"/>
+      </releaseinfo>
+    </link>
+  </target>
+
+  <target name="-thrower" depends="-dirs">
+
+    <property name="thrower.root" value="${input}/thrower"/>
+
+    <property name="thrower.src" value="${thrower.root}/src"/>
+    <property name="thrower.classes" value="${thrower.root}/classes"/>
+    <property name="thrower.jar.dir" value="${thrower.root}/jars"/>
+    <property name="thrower.jar" value="${thrower.jar.dir}/thrower.jar"/>
+
+    <property name="thrower.mod"      value="org.apache.tools.ant.test.thrower"/>
+    <property name="thrower.pkg"      value="org.apache.tools.ant.test.thrower"/>
+    <property name="thrower.pkg.path" value="org/apache/tools/ant/test/thrower"/>
+    <property name="thrower.pkg.dir" value="${thrower.src}/${thrower.pkg.path}"/>
+
+    <property name="thrower.main-class" value="${thrower.pkg}.Thrower"/>
+
+    <mkdir dir="${thrower.pkg.dir}"/>
+    <echo file="${thrower.pkg.dir}/Thrower.java">
+package ${thrower.pkg};
+
+public class Thrower {
+    public static void main(String[] args) {
+        throw new RuntimeException("Deliberate exception.");
+    }
+}
+    </echo>
+    <echo file="${thrower.src}/module-info.java">
+module ${thrower.mod} {
+    exports ${thrower.pkg};
+}
+    </echo>
+
+    <mkdir dir="${thrower.classes}"/>
+    <javac srcdir="${thrower.src}" destdir="${thrower.classes}"
+           includeAntRuntime="false" debug="true"/>
+
+    <mkdir dir="${thrower.jar.dir}"/>
+    <jar destfile="${thrower.jar}" basedir="${thrower.classes}"/>
+  </target>
+
+  <target name="debug" depends="-thrower">
+    <property name="thrower.jmod" value="${jmods}/thrower.jmod"/>
+    <jmod destfile="${thrower.jmod}" classpath="${thrower.jar}"/>
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${thrower.mod}" debug="false"/>
+  </target>
+
+  <target name="dedup" depends="-jmods">
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}"/>
+  </target>
+
+  <target name="dedup-identical" depends="-jmods">
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}"
+          checkDuplicateLegal="true"/>
+  </target>
+
+  <target name="-sign" depends="-smile,-legal">
+    <property name="keystore.file" value="${output}/keystore"/>
+    <property name="keystore.alias" value="test"/>
+    <property name="keystore.password" value="swordfish"/>
+
+    <condition property="execsuffix" value=".exe" else="">
+      <os family="windows"/>
+    </condition>
+    <exec executable="${java.home}/bin/keytool${execsuffix}" failonerror="true">
+      <arg value="-genkeypair"/>
+      <arg value="-keystore"/>
+      <arg file="${keystore.file}"/>
+      <arg value="-storepass"/>
+      <arg value="${keystore.password}"/>
+      <arg value="-keypass"/>
+      <arg value="${keystore.password}"/>
+      <arg value="-alias"/>
+      <arg value="${keystore.alias}"/>
+      <arg value="-dname"/>
+      <arg value="CN=Ant Unit Test, OU=Ant, O=Apache Software Foundation, L=Unknown, ST=Unknown, C=US"/>
+    </exec>
+
+    <signjar jar="${hello.jar}"
+             keystore="${keystore.file}"
+             alias="${keystore.alias}"
+             storepass="${keystore.password}"/>
+  </target>
+
+  <target name="ignoresigning" depends="-sign,-jmods">
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${hello.mod},${smile.mod}"
+          ignoreSigning="true"/>
+  </target>
+
+  <target name="bindservices" depends="-dirs">
+    <property name="inc.root" value="${input}/inc"/>
+
+    <property name="inc.src" value="${inc.root}/src"/>
+    <property name="inc.classes" value="${inc.root}/classes"/>
+    <property name="inc.jar.dir" value="${inc.root}/jars"/>
+    <property name="inc.jar" value="${inc.jar.dir}/inc.jar"/>
+
+    <property name="inc.mod"      value="org.apache.tools.ant.test.inc"/>
+    <property name="inc.pkg"      value="org.apache.tools.ant.test.inc"/>
+    <property name="inc.pkg.path" value="org/apache/tools/ant/test/inc"/>
+    <property name="inc.pkg.dir" value="${inc.src}/${inc.pkg.path}"/>
+
+    <property name="inc.main-class" value="${inc.pkg}.Incrementer"/>
+
+    <mkdir dir="${inc.pkg.dir}"/>
+    <echo file="${inc.pkg.dir}/IncrementProvider.java">
+package ${inc.pkg};
+
+public interface IncrementProvider {
+    int getIncrement();
+}
+    </echo>
+    <echo file="${inc.pkg.dir}/Incrementer.java">
+package ${inc.pkg};
+
+import java.util.ServiceLoader;
+
+public class Incrementer {
+    public static void main(String[] args) {
+        for (IncrementProvider provider : ServiceLoader.load(IncrementProvider.class)) {
+            int n = 0;
+            for (int i = 0; i &lt; 5; i++) {
+                n += provider.getIncrement();
+                System.out.println(n);
+            }
+        }
+    }
+}
+    </echo>
+    <echo file="${inc.src}/module-info.java">
+module ${inc.mod} {
+    exports ${inc.pkg};
+    uses ${inc.pkg}.IncrementProvider;
+}
+    </echo>
+
+    <mkdir dir="${inc.classes}"/>
+    <javac srcdir="${inc.src}" destdir="${inc.classes}"
+           includeAntRuntime="false" debug="true"/>
+
+    <mkdir dir="${inc.jar.dir}"/>
+    <jar destfile="${inc.jar}" basedir="${inc.classes}"/>
+
+    <property name="provider.root" value="${input}/provider"/>
+    <property name="provider.src" value="${provider.root}/src"/>
+    <property name="provider.classes" value="${provider.root}/classes"/>
+    <property name="provider.jar.dir" value="${provider.root}/jars"/>
+    <property name="provider.jar" value="${provider.jar.dir}/provider.jar"/>
+
+    <property name="provider.mod"      value="org.apache.tools.ant.test.provider"/>
+    <property name="provider.pkg"      value="org.apache.tools.ant.test.provider"/>
+    <property name="provider.pkg.path" value="org/apache/tools/ant/test/provider"/>
+    <property name="provider.pkg.dir" value="${provider.src}/${provider.pkg.path}"/>
+
+    <mkdir dir="${provider.pkg.dir}"/>
+    <echo file="${provider.pkg.dir}/ByTwoProvider.java">
+package ${provider.pkg};
+
+import ${inc.pkg}.IncrementProvider;
+
+public class ByTwoProvider
+implements IncrementProvider {
+    @Override
+    public int getIncrement() {
+        return 2;
+    }
+}
+    </echo>
+    <echo file="${provider.src}/module-info.java">
+module ${provider.mod} {
+    exports ${provider.pkg};
+    requires transitive ${inc.mod};
+    provides ${inc.pkg}.IncrementProvider with ${provider.pkg}.ByTwoProvider;
+}
+    </echo>
+
+    <mkdir dir="${provider.classes}"/>
+    <javac srcdir="${provider.src}" destdir="${provider.classes}"
+           includeAntRuntime="false" debug="true"
+           modulepath="${inc.classes}"/>
+
+    <mkdir dir="${provider.jar.dir}"/>
+    <jar destfile="${provider.jar}" basedir="${provider.classes}"/>
+
+    <property name="inc.jmod" value="${jmods}/inc.jmod"/>
+    <property name="provider.jmod" value="${jmods}/provider.jmod"/>
+
+    <jmod destfile="${inc.jmod}"
+          classpath="${inc.jar}"/>
+
+    <jmod destfile="${provider.jmod}"
+          classpath="${provider.jar}"
+          modulepath="${inc.jar.dir}"/>
+
+    <property name="image2" value="${output}/image2"/>
+
+    <link destdir="${image}" modulepath="${jdkmods}${jmods}"
+          modules="${inc.mod}"
+          bindServices="false"/>
+  
+    <link destdir="${image2}" modulepath="${jdkmods}${jmods}"
+          modules="${inc.mod}"
+          bindServices="true"/>
+  </target>
+</project>
index a78cedb..d847ae9 100644 (file)
@@ -66,6 +66,8 @@ jar=org.apache.tools.ant.taskdefs.Jar
 java=org.apache.tools.ant.taskdefs.Java
 javac=org.apache.tools.ant.taskdefs.Javac
 javadoc=org.apache.tools.ant.taskdefs.Javadoc
+jmod=org.apache.tools.ant.taskdefs.modules.Jmod
+link=org.apache.tools.ant.taskdefs.modules.Link
 length=org.apache.tools.ant.taskdefs.Length
 loadfile=org.apache.tools.ant.taskdefs.LoadFile
 loadproperties=org.apache.tools.ant.taskdefs.LoadProperties
diff --git a/src/main/org/apache/tools/ant/taskdefs/modules/Jmod.java b/src/main/org/apache/tools/ant/taskdefs/modules/Jmod.java
new file mode 100644 (file)
index 0000000..2284ed3
--- /dev/null
@@ -0,0 +1,1282 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one or more
+ *  contributor license agreements.  See the NOTICE file distributed with
+ *  this work for additional information regarding copyright ownership.
+ *  The ASF licenses this file to You under the Apache License, Version 2.0
+ *  (the "License"); you may not use this file except in compliance with
+ *  the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ *
+ */
+
+package org.apache.tools.ant.taskdefs.modules;
+
+import java.io.File;
+import java.io.ByteArrayOutputStream;
+import java.io.PrintStream;
+import java.io.IOException;
+
+import java.nio.file.Files;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.ArrayList;
+
+import java.util.Map;
+import java.util.LinkedHashMap;
+
+import java.util.Collections;
+
+import java.util.spi.ToolProvider;
+
+import org.apache.tools.ant.BuildException;
+import org.apache.tools.ant.Project;
+import org.apache.tools.ant.Task;
+
+import org.apache.tools.ant.util.MergingMapper;
+import org.apache.tools.ant.util.FileUtils;
+import org.apache.tools.ant.util.ResourceUtils;
+
+import org.apache.tools.ant.types.EnumeratedAttribute;
+import org.apache.tools.ant.types.FileSet;
+import org.apache.tools.ant.types.ModuleVersion;
+import org.apache.tools.ant.types.Path;
+import org.apache.tools.ant.types.Reference;
+import org.apache.tools.ant.types.Resource;
+import org.apache.tools.ant.types.ResourceCollection;
+
+import org.apache.tools.ant.types.resources.FileResource;
+import org.apache.tools.ant.types.resources.Union;
+
+/**
+ * Creates a linkable .jmod file from a modular jar file, and optionally from
+ * other resource files such as native libraries and documents.  Equivalent
+ * to the JDK's
+ * <a href="https://docs.oracle.com/en/java/javase/11/tools/jmod.html">jmod</a>
+ * tool.
+ * <p>
+ * Supported attributes:
+ * <dl>
+ * <dt>{@code destFile}
+ * <dd>Required, jmod file to create.
+ * <dt>{@code classpath}
+ * <dt>{@code classpathref}
+ * <dd>Where to locate files to be placed in the jmod file.
+ * <dt>{@code modulepath}
+ * <dt>{@code modulepathref}
+ * <dd>Where to locate dependencies.
+ * <dt>{@code commandpath}
+ * <dt>{@code commandpathref}
+ * <dd>Directories containing native commands to include in jmod.
+ * <dt>{@code headerpath}
+ * <dt>{@code headerpathref}
+ * <dd>Directories containing header files to include in jmod.
+ * <dt>{@code configpath}
+ * <dt>{@code configpathref}
+ * <dd>Directories containing user-editable configuration files
+ *     to include in jmod.
+ * <dt>{@code legalpath}
+ * <dt>{@code legalpathref}
+ * <dd>Directories containing legal licenses and notices to include in jmod.
+ * <dt>{@code nativelibpath}
+ * <dt>{@code nativelibpathref}
+ * <dd>Directories containing native libraries to include in jmod.
+ * <dt>{@code manpath}
+ * <dt>{@code manpathref}
+ * <dd>Directories containing man pages to include in jmod.
+ * <dt>{@code version}
+ * <dd>Module <a href="https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/lang/module/ModuleDescriptor.Version.html">version</a>.
+ * <dt>{@code mainclass}
+ * <dd>Main class of module.
+ * <dt>{@code platform}
+ * <dd>The target platform for the jmod.  A particular JDK's platform
+ * can be seen by running
+ * <code>jmod describe $JDK_HOME/jmods/java.base.jmod | grep -i platform</code>.
+ * <dt>{@code hashModulesPattern}
+ * <dd>Regular expression for names of modules in the module path
+ *     which depend on the jmod being created, and which should have
+ *     hashes generated for them and included in the new jmod.
+ * <dt>{@code resolveByDefault}
+ * <dd>Boolean indicating whether the jmod should be one of
+ *     the default resolved modules in an application.  Default is true.
+ * <dt>{@code moduleWarnings}
+ * <dd>Whether to emit warnings when resolving modules which are
+ *     not recommended for use.  Comma-separated list of one of more of
+ *     the following:
+ *     <dl>
+ *     <dt>{@code deprecated}
+ *     <dd>Warn if module is deprecated
+ *     <dt>{@code leaving}
+ *     <dd>Warn if module is deprecated for removal
+ *     <dt>{@code incubating}
+ *     <dd>Warn if module is an incubating (not yet official) module
+ *     </dl>
+ * </dl>
+ *
+ * <p>
+ * Supported nested elements:
+ * <dl>
+ * <dt>{@code <classpath>}
+ * <dd>Path indicating where to locate files to be placed in the jmod file.
+ * <dt>{@code <modulepath>}
+ * <dd>Path indicating where to locate dependencies.
+ * <dt>{@code <commandpath>}
+ * <dd>Path of directories containing native commands to include in jmod.
+ * <dt>{@code <headerpath>}
+ * <dd>Path of directories containing header files to include in jmod.
+ * <dt>{@code <configpath>}
+ * <dd>Path of directories containing user-editable configuration files
+ *     to include in jmod.
+ * <dt>{@code <legalpath>}
+ * <dd>Path of directories containing legal notices to include in jmod.
+ * <dt>{@code <nativelibpath>}
+ * <dd>Path of directories containing native libraries to include in jmod.
+ * <dt>{@code <manpath>}
+ * <dd>Path of directories containing man pages to include in jmod.
+ * <dt>{@code <version>}
+ * <dd><a href="https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/lang/module/ModuleDescriptor.Version.html">Module version</a> of jmod.
+ *     Must have a required {@code number} attribute.  May also have optional
+ *     {@code preRelease} and {@code build} attributes.
+ * <dt>{@code <moduleWarning>}
+ * <dd>Has one required attribute, {@code reason}.  See {@code moduleWarnings}
+ *     attribute above.  This element may be specified multiple times.
+ * </dl>
+ * <p>
+ * destFile and classpath are required data.
+ */
+public class Jmod
+extends Task {
+    /** Location of jmod file to be created. */
+    private File jmodFile;
+
+    /**
+     * Path of files (usually jar files or directories containing
+     * compiled classes) from which to create jmod.
+     */
+    private Path classpath;
+
+    /**
+     * Path of directories containing modules on which the modules
+     * in the classpath depend.
+     */
+    private Path modulePath;
+
+    /**
+     * Path of directories containing executable files to bundle in the
+     * created jmod.
+     */
+    private Path commandPath;
+
+    /**
+     * Path of directories containing configuration files to bundle in the
+     * created jmod.
+     */
+    private Path configPath;
+
+    /**
+     * Path of directories containing includable header files (such as for
+     * other languages) to bundle in the created jmod.
+     */
+    private Path headerPath;
+
+    /**
+     * Path of directories containing legal license files to bundle
+     * in the created jmod.
+     */
+    private Path legalPath;
+
+    /**
+     * Path of directories containing native libraries needed by classes
+     * in the modules comprising the created jmod.
+     */
+    private Path nativeLibPath;
+
+    /**
+     * Path of directories containing manual pages to bundle
+     * in the created jmod.
+     */
+    private Path manPath;
+
+    /**
+     * Module version of jmod.  Either this or {@link #moduleVersion}
+     * may be set.
+     */
+    private String version;
+
+    /** Module version of jmod.  Either this or {@link #version} may be set. */
+    private ModuleVersion moduleVersion;
+
+    /**
+     * Main class to execute, if Java attempts to execute jmod's module
+     * without specifying a main class explicitly.
+     */
+    private String mainClass;
+
+    /**
+     * Target platform of created jmod.  Examples are {@code windows-amd64}
+     * and {@code linux-amd64}.  Target platform is an attribute
+     * of each JDK, which can be seen by executing
+     * <code>jmod describe $JDK_HOME/jmods/java.base.jmod</code> and
+     * searching the output for a line starting with {@code platform}.
+     */
+    private String platform;
+
+    /**
+     * Regular expression matching names of modules which depend on the
+     * the created jmod's module, for which hashes should be added to the
+     * created jmod.
+     */
+    private String hashModulesPattern;
+
+    /**
+     * Whether the created jmod should be seen by Java when present in a
+     * module path, even if not explicitly named.  Normally true.
+     */
+    private boolean resolveByDefault = true;
+
+    /**
+     * Reasons why module resolution during jmod creation may emit warnings.
+     */
+    private final List<ResolutionWarningSpec> moduleWarnings =
+        new ArrayList<>();
+
+    /**
+     * Attribute containing the location of the jmod file to create.
+     *
+     * @return location of jmod file
+     *
+     * @see #setDestFile(File)
+     */
+    public File getDestFile() {
+        return jmodFile;
+    }
+
+    /**
+     * Sets attribute containing the location of the jmod file to create.
+     * This value is required.
+     *
+     * @param file location where jmod file will be created.
+     */
+    public void setDestFile(final File file) {
+        this.jmodFile = file;
+    }
+
+    /**
+     * Adds an unconfigured {@code <classpath>} child element which can
+     * specify the files which will comprise the created jmod.
+     *
+     * @return new, unconfigured child element
+     *
+     * @see #setClasspath(Path)
+     */
+    public Path createClasspath() {
+        if (classpath == null) {
+            classpath = new Path(getProject());
+        }
+        return classpath.createPath();
+    }
+
+    /**
+     * Attribute which specifies the files (usually modular .jar files)
+     * which will comprise the created jmod file.
+     *
+     * @return path of constituent files
+     *
+     * @see #setClasspath(Path)
+     */
+    public Path getClasspath() {
+        return classpath;
+    }
+
+    /**
+     * Sets attribute specifying the files that will comprise the created jmod
+     * file.  Usually this contains a single modular .jar file.
+     * <p>
+     * The classpath is required and must not be empty.
+     *
+     * @param path path of files that will comprise jmod
+     *
+     * @see #createClasspath()
+     */
+    public void setClasspath(final Path path) {
+        if (classpath == null) {
+            this.classpath = path;
+        } else {
+            classpath.append(path);
+        }
+    }
+
+    /**
+     * Sets {@linkplain #setClasspath(Path) classpath attribute} from a
+     * path reference.
+     *
+     * @param ref reference to path which will act as classpath
+     */
+    public void setClasspathRef(final Reference ref) {
+        createClasspath().setRefid(ref);
+    }
+
+    /**
+     * Creates a child {@code <modulePath>} element which can contain a
+     * path of directories containing modules upon which modules in the
+     * {@linkplain #setClasspath(Path) classpath} depend.
+     *
+     * @return new, unconfigured child element
+     *
+     * @see #setModulePath(Path)
+     */
+    public Path createModulePath() {
+        if (modulePath == null) {
+            modulePath = new Path(getProject());
+        }
+        return modulePath.createPath();
+    }
+
+    /**
+     * Attribute containing path of directories which contain modules on which
+     * the created jmod's {@linkplain #setClasspath(Path) constituent modules}
+     * depend.
+     *
+     * @return path of directories containing modules needed by
+     *         classpath modules
+     *
+     * @see #setModulePath(Path)
+     */
+    public Path getModulePath() {
+        return modulePath;
+    }
+
+    /**
+     * Sets attribute containing path of directories which contain modules
+     * on which the created jmod's
+     * {@linkplain #setClasspath(Path) constituent modules} depend.
+     *
+     * @param path path of directories containing modules needed by
+     *             classpath modules
+     *
+     * @see #createModulePath()
+     */
+    public void setModulePath(final Path path) {
+        if (modulePath == null) {
+            this.modulePath = path;
+        } else {
+            modulePath.append(path);
+        }
+    }
+
+    /**
+     * Sets {@linkplain #setModulePath(Path) module path}
+     * from a path reference.
+     *
+     * @param ref reference to path which will act as module path
+     */
+    public void setModulePathRef(final Reference ref) {
+        createModulePath().setRefid(ref);
+    }
+
+    /**
+     * Creates a child element which can contain a list of directories
+     * containing native executable files to include in the created jmod.
+     *
+     * @return new, unconfigured child element
+     *
+     * @see #setCommandPath(Path)
+     */
+    public Path createCommandPath() {
+        if (commandPath == null) {
+            commandPath = new Path(getProject());
+        }
+        return commandPath.createPath();
+    }
+
+    /**
+     * Attribute containing path of directories which contain native
+     * executable files to include in the created jmod.
+     *
+     * @return list of directories containing native executables
+     *
+     * @see #setCommandPath(Path)
+     */
+    public Path getCommandPath() {
+        return commandPath;
+    }
+
+    /**
+     * Sets attribute containing path of directories which contain native
+     * executable files to include in the created jmod.
+     *
+     * @param path list of directories containing native executables
+     *
+     * @see #createCommandPath()
+     */
+    public void setCommandPath(final Path path) {
+        if (commandPath == null) {
+            this.commandPath = path;
+        } else {
+            commandPath.append(path);
+        }
+    }
+
+    /**
+     * Sets {@linkplain #setCommandPath(Path) command path}
+     * from a path reference.
+     *
+     * @param ref reference to path which will act as command path
+     */
+    public void setCommandPathRef(final Reference ref) {
+        createCommandPath().setRefid(ref);
+    }
+
+    /**
+     * Creates a child element which can contain a list of directories
+     * containing user configuration files to include in the created jmod.
+     *
+     * @return new, unconfigured child element
+     *
+     * @see #setConfigPath(Path)
+     */
+    public Path createConfigPath() {
+        if (configPath == null) {
+            configPath = new Path(getProject());
+        }
+        return configPath.createPath();
+    }
+
+    /**
+     * Attribute containing list of directories which contain
+     * user configuration files.
+     *
+     * @return list of directories containing user configuration files
+     *
+     * @see #setConfigPath(Path)
+     */
+    public Path getConfigPath() {
+        return configPath;
+    }
+
+    /**
+     * Sets attribute containing list of directories which contain
+     * user configuration files.
+     *
+     * @param path list of directories containing user configuration files
+     *
+     * @see #createConfigPath()
+     */
+    public void setConfigPath(final Path path) {
+        if (configPath == null) {
+            this.configPath = path;
+        } else {
+            configPath.append(path);
+        }
+    }
+
+    /**
+     * Sets {@linkplain #setConfigPath(Path) configuration file path}
+     * from a path reference.
+     *
+     * @param ref reference to path which will act as configuration file path
+     */
+    public void setConfigPathRef(final Reference ref) {
+        createConfigPath().setRefid(ref);
+    }
+
+    /**
+     * Creates a child element which can contain a list of directories
+     * containing compile-time header files for third party use, to include
+     * in the created jmod.
+     *
+     * @return new, unconfigured child element
+     *
+     * @see #setHeaderPath(Path)
+     */
+    public Path createHeaderPath() {
+        if (headerPath == null) {
+            headerPath = new Path(getProject());
+        }
+        return headerPath.createPath();
+    }
+
+    /**
+     * Attribute containing a path of directories which hold compile-time
+     * header files for third party use, all of which will be included in the
+     * created jmod.
+     *
+     * @return path of directories containing header files
+     */
+    public Path getHeaderPath() {
+        return headerPath;
+    }
+
+    /**
+     * Sets attribute containing a path of directories which hold compile-time
+     * header files for third party use, all of which will be included in the
+     * created jmod.
+     *
+     * @param path path of directories containing header files
+     *
+     * @see #createHeaderPath()
+     */
+    public void setHeaderPath(final Path path) {
+        if (headerPath == null) {
+            this.headerPath = path;
+        } else {
+            headerPath.append(path);
+        }
+    }
+
+    /**
+     * Sets {@linkplain #setHeaderPath(Path) header path}
+     * from a path reference.
+     *
+     * @param ref reference to path which will act as header path
+     */
+    public void setHeaderPathRef(final Reference ref) {
+        createHeaderPath().setRefid(ref);
+    }
+
+    /**
+     * Creates a child element which can contain a list of directories
+     * containing license files to include in the created jmod.
+     *
+     * @return new, unconfigured child element
+     *
+     * @see #setLegalPath(Path)
+     */
+    public Path createLegalPath() {
+        if (legalPath == null) {
+            legalPath = new Path(getProject());
+        }
+        return legalPath.createPath();
+    }
+
+    /**
+     * Attribute containing list of directories which hold license files
+     * to include in the created jmod.
+     *
+     * @return path containing directories which hold license files
+     */
+    public Path getLegalPath() {
+        return legalPath;
+    }
+
+    /**
+     * Sets attribute containing list of directories which hold license files
+     * to include in the created jmod.
+     *
+     * @param path path containing directories which hold license files
+     *
+     * @see #createLegalPath()
+     */
+    public void setLegalPath(final Path path) {
+        if (legalPath == null) {
+            this.legalPath = path;
+        } else {
+            legalPath.append(path);
+        }
+    }
+
+    /**
+     * Sets {@linkplain #setLegalPath(Path) legal licenses path}
+     * from a path reference.
+     *
+     * @param ref reference to path which will act as legal path
+     */
+    public void setLegalPathRef(final Reference ref) {
+        createLegalPath().setRefid(ref);
+    }
+
+    /**
+     * Creates a child element which can contain a list of directories
+     * containing native libraries to include in the created jmod.
+     *
+     * @return new, unconfigured child element
+     *
+     * @see #setNativeLibPath(Path)
+     */
+    public Path createNativeLibPath() {
+        if (nativeLibPath == null) {
+            nativeLibPath = new Path(getProject());
+        }
+        return nativeLibPath.createPath();
+    }
+
+    /**
+     * Attribute containing list of directories which hold native libraries
+     * to include in the created jmod.
+     *
+     * @return path of directories containing native libraries
+     */
+    public Path getNativeLibPath() {
+        return nativeLibPath;
+    }
+
+    /**
+     * Sets attribute containing list of directories which hold native libraries
+     * to include in the created jmod.
+     *
+     * @param path path of directories containing native libraries
+     *
+     * @see #createNativeLibPath()
+     */
+    public void setNativeLibPath(final Path path) {
+        if (nativeLibPath == null) {
+            this.nativeLibPath = path;
+        } else {
+            nativeLibPath.append(path);
+        }
+    }
+
+    /**
+     * Sets {@linkplain #setNativeLibPath(Path) native library path}
+     * from a path reference.
+     *
+     * @param ref reference to path which will act as native library path
+     */
+    public void setNativeLibPathRef(final Reference ref) {
+        createNativeLibPath().setRefid(ref);
+    }
+
+    /**
+     * Creates a child element which can contain a list of directories
+     * containing man pages (program manuals, typically in troff format)
+     * to include in the created jmod.
+     *
+     * @return new, unconfigured child element
+     *
+     * @see #setManPath(Path)
+     */
+    public Path createManPath() {
+        if (manPath == null) {
+            manPath = new Path(getProject());
+        }
+        return manPath.createPath();
+    }
+
+    /**
+     * Attribute containing list of directories containing man pages
+     * to include in created jmod.  Man pages are textual program manuals,
+     * typically in troff format.
+     *
+     * @return path containing directories which hold man pages to include
+     *         in jmod
+     */
+    public Path getManPath() {
+        return manPath;
+    }
+
+    /**
+     * Sets attribute containing list of directories containing man pages
+     * to include in created jmod.  Man pages are textual program manuals,
+     * typically in troff format.
+     *
+     * @param path path containing directories which hold man pages to include
+     *             in jmod
+     *
+     * @see #createManPath()
+     */
+    public void setManPath(final Path path) {
+        if (manPath == null) {
+            this.manPath = path;
+        } else {
+            manPath.append(path);
+        }
+    }
+
+    /**
+     * Sets {@linkplain #setManPath(Path) man pages path}
+     * from a path reference.
+     *
+     * @param ref reference to path which will act as module path
+     */
+    public void setManPathRef(final Reference ref) {
+        createManPath().setRefid(ref);
+    }
+
+    /**
+     * Creates an uninitialized child element representing the version of
+     * the module represented by the created jmod.
+     *
+     * @return new, unconfigured child element
+     *
+     * @see #setVersion(String)
+     */
+    public ModuleVersion createVersion() {
+        if (moduleVersion != null) {
+            throw new BuildException(
+                "No more than one <moduleVersion> element is allowed.",
+                getLocation());
+        }
+        moduleVersion = new ModuleVersion();
+        return moduleVersion;
+    }
+
+    /**
+     * Attribute which specifies
+     * a <a href="https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/lang/module/ModuleDescriptor.Version.html">module version</a>
+     * for created jmod.
+     *
+     * @return module version for created jmod
+     */
+    public String getVersion() {
+        return version;
+    }
+
+    /**
+     * Sets the <a href="https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/lang/module/ModuleDescriptor.Version.html">module version</a>
+     * for the created jmod.
+     *
+     * @param version module version of created jmod
+     *
+     * @see #createVersion()
+     */
+    public void setVersion(final String version) {
+        this.version = version;
+    }
+
+    /**
+     * Attribute containing the class that acts as the executable entry point
+     * of the created jmod.
+     *
+     * @return fully-qualified name of jmod's main class
+     */
+    public String getMainClass() {
+        return mainClass;
+    }
+
+    /**
+     * Sets attribute containing the class that acts as the
+     * executable entry point of the created jmod.
+     *
+     * @param className fully-qualified name of jmod's main class
+     */
+    public void setMainClass(final String className) {
+        this.mainClass = className;
+    }
+
+    /**
+     * Attribute containing the platform for which the jmod
+     * will be built.  Platform values are defined in the
+     * {@code java.base.jmod} of JDKs, and usually take the form
+     * <var>OS</var>{@code -}<var>architecture</var>.  If unset,
+     * current platform is used.
+     *
+     * @return OS and architecture for which jmod will be built, or {@code null}
+     */
+    public String getPlatform() {
+        return platform;
+    }
+
+    /**
+     * Sets attribute containing the platform for which the jmod
+     * will be built.  Platform values are defined in the
+     * {@code java.base.jmod} of JDKs, and usually take the form
+     * <var>OS</var>{@code -}<var>architecture</var>.  If unset,
+     * current platform is used.
+     * <p>
+     * A JDK's platform can be viewed with a command like:
+     * <code>jmod describe $JDK_HOME/jmods/java.base.jmod | grep -i platform</code>.
+o    *
+     * @param platform platform for which jmod will be created, or {@code null}
+     */
+    public void setPlatform(final String platform) {
+        this.platform = platform;
+    }
+
+    /**
+     * Attribute containing a regular expression which specifies which
+     * of the modules that depend on the jmod being created should have
+     * hashes generated and added to the jmod.
+     *
+     * @return regex specifying which dependent modules should have
+     *         their generated hashes included
+     */
+    public String getHashModulesPattern() {
+        return hashModulesPattern;
+    }
+
+    /**
+     * Sets attribute containing a regular expression which specifies which
+     * of the modules that depend on the jmod being created should have
+     * hashes generated and added to the jmod.
+     *
+     * @param pattern regex specifying which dependent modules should have
+     *         their generated hashes included
+     */
+    public void setHashModulesPattern(final String pattern) {
+        this.hashModulesPattern = pattern;
+    }
+
+    /**
+     * Attribute indicating whether the created jmod should be visible
+     * in a module path, even when not specified explicitly.  True by default.
+     *
+     * @return whether jmod should be visible in module paths
+     */
+    public boolean getResolveByDefault() {
+        return resolveByDefault;
+    }
+
+    /**
+     * Sets attribute indicating whether the created jmod should be visible
+     * in a module path, even when not specified explicitly.  True by default.
+     *
+     * @param resolve whether jmod should be visible in module paths
+     */
+    public void setResolveByDefault(final boolean resolve) {
+        this.resolveByDefault = resolve;
+    }
+
+    /**
+     * Creates a child element which can specify the circumstances
+     * under which jmod creation emits warnings.
+     *
+     * @return new, unconfigured child element
+     *
+     * @see #setModuleWarnings(String)
+     */
+    public ResolutionWarningSpec createModuleWarning() {
+        ResolutionWarningSpec warningSpec = new ResolutionWarningSpec();
+        moduleWarnings.add(warningSpec);
+        return warningSpec;
+    }
+
+    /**
+     * Sets attribute containing a comma-separated list of reasons for
+     * jmod creation to emit warnings.  Valid values in list are:
+     * {@code deprecated}, {@code leaving}, {@code incubating}.
+     *
+     * @param warningList list containing one or more of the above values,
+     *                    separated by commas
+     *
+     * @see #createModuleWarning()
+     * @see Jmod.ResolutionWarningReason
+     */
+    public void setModuleWarnings(final String warningList) {
+        for (String warning : warningList.split(",")) {
+            moduleWarnings.add(new ResolutionWarningSpec(warning));
+        }
+    }
+
+    /**
+     * Permissible reasons for jmod creation to emit warnings.
+     */
+    public static class ResolutionWarningReason
+    extends EnumeratedAttribute {
+        /**
+         * String value indicating warnings are emitted for modules
+         * marked as deprecated (but not deprecated for removal).
+         */
+        public static final String DEPRECATED = "deprecated";
+
+        /**
+         * String value indicating warnings are emitted for modules
+         * marked as deprecated for removal.
+         */
+        public static final String LEAVING = "leaving";
+
+        /**
+         * String value indicating warnings are emitted for modules
+         * designated as "incubating" in the JDK.
+         */
+        public static final String INCUBATING = "incubating";
+
+        /** Maps Ant task values to jmod option values. */
+        private static final Map<String, String> VALUES_TO_OPTIONS;
+
+        static {
+            Map<String, String> map = new LinkedHashMap<>();
+            map.put(DEPRECATED, "deprecated");
+            map.put(LEAVING,    "deprecated-for-removal");
+            map.put(INCUBATING, "incubating");
+
+            VALUES_TO_OPTIONS = Collections.unmodifiableMap(map);
+        }
+
+        @Override
+        public String[] getValues() {
+            return VALUES_TO_OPTIONS.keySet().toArray(new String[0]);
+        }
+
+        /**
+         * Converts this object's current value to a jmod tool
+         * option value.
+         *
+         * @return jmod option value
+         */
+        String toCommandLineOption() {
+            return VALUES_TO_OPTIONS.get(getValue());
+        }
+
+        /**
+         * Converts a string to a {@code ResolutionWarningReason} instance.
+         *
+         * @param s string to convert
+         *
+         * @return {@code ResolutionWarningReason} instance corresponding to
+         *         string argument
+         *
+         * @throws BuildException if argument is not a valid
+         *                        {@code ResolutionWarningReason} value
+         */
+        public static ResolutionWarningReason valueOf(String s) {
+            return (ResolutionWarningReason)
+                getInstance(ResolutionWarningReason.class, s);
+        }
+    }
+
+    /**
+     * Child element which enables jmod tool warnings.  'reason' attribute
+     * is required.
+     */
+    public class ResolutionWarningSpec {
+        /** Condition which should trigger jmod warning output. */
+        private ResolutionWarningReason reason;
+
+        /**
+         * Creates an uninitialized element.
+         */
+        public ResolutionWarningSpec() {
+            // Deliberately empty.
+        }
+
+        /**
+         * Creates an element with the given reason attribute.
+         *
+         * @param reason non{@code null} {@link Jmod.ResolutionWarningReason}
+         *               value
+         *
+         * @throws BuildException if argument is not a valid
+         *                        {@code ResolutionWarningReason}
+         */
+        public ResolutionWarningSpec(String reason) {
+            setReason(ResolutionWarningReason.valueOf(reason));
+        }
+
+        /**
+         * Required attribute containing reason for emitting jmod warnings.
+         *
+         * @return condition which triggers jmod warnings
+         */
+        public ResolutionWarningReason getReason() {
+            return reason;
+        }
+
+        /**
+         * Sets attribute containing reason for emitting jmod warnings.
+         *
+         * @param reason condition which triggers jmod warnings
+         */
+        public void setReason(ResolutionWarningReason reason) {
+            this.reason = reason;
+        }
+
+        /**
+         * Verifies this object's state.
+         *
+         * @throws BuildException if this object's reason is {@code null}
+         */
+        public void validate() {
+            if (reason == null) {
+                throw new BuildException("reason attribute is required",
+                    getLocation());
+            }
+        }
+    }
+
+    /**
+     * Checks whether a resource is a directory.  Used for checking validity
+     * of jmod path arguments which have to be directories.
+     *
+     * @param resource resource to check
+     *
+     * @return true if resource exists and is not a directory,
+     *         false if it is a directory or does not exist
+     */
+    private static boolean isRegularFile(Resource resource) {
+        return resource.isExists() && !resource.isDirectory();
+    }
+
+    /**
+     * Checks that all paths which are required to be directories only,
+     * refer only to directories.
+     *
+     * @throws BuildException if any path has an existing file
+     *                        which is a non-directory
+     */
+    private void checkDirPaths() {
+        if (modulePath != null
+            && modulePath.stream().anyMatch(Jmod::isRegularFile)) {
+
+            throw new BuildException(
+                "ModulePath must contain only directories.", getLocation());
+        }
+        if (commandPath != null
+            && commandPath.stream().anyMatch(Jmod::isRegularFile)) {
+
+            throw new BuildException(
+                "CommandPath must contain only directories.", getLocation());
+        }
+        if (configPath != null
+            && configPath.stream().anyMatch(Jmod::isRegularFile)) {
+
+            throw new BuildException(
+                "ConfigPath must contain only directories.", getLocation());
+        }
+        if (headerPath != null
+            && headerPath.stream().anyMatch(Jmod::isRegularFile)) {
+
+            throw new BuildException(
+                "HeaderPath must contain only directories.", getLocation());
+        }
+        if (legalPath != null
+            && legalPath.stream().anyMatch(Jmod::isRegularFile)) {
+
+            throw new BuildException(
+                "LegalPath must contain only directories.", getLocation());
+        }
+        if (nativeLibPath != null
+            && nativeLibPath.stream().anyMatch(Jmod::isRegularFile)) {
+
+            throw new BuildException(
+                "NativeLibPath must contain only directories.", getLocation());
+        }
+        if (manPath != null
+            && manPath.stream().anyMatch(Jmod::isRegularFile)) {
+
+            throw new BuildException(
+                "ManPath must contain only directories.", getLocation());
+        }
+    }
+
+    /**
+     * Creates a jmod file according to this task's properties
+     * and child elements.
+     *
+     * @throws BuildException if destFile is not set
+     * @throws BuildException if classpath is not set or is empty
+     * @throws BuildException if any path other than classpath refers to an
+     *                        existing file which is not a directory
+     * @throws BuildException if both {@code version} attribute and
+     *                        {@code <version>} child element are present
+     * @throws BuildException if {@code hashModulesPattern} is set, but
+     *                        module path is not defined
+     */
+    @Override
+    public void execute()
+    throws BuildException {
+
+        if (jmodFile == null) {
+            throw new BuildException("Destination file is required.",
+                getLocation());
+        }
+
+        if (classpath == null) {
+            throw new BuildException("Classpath is required.",
+                getLocation());
+        }
+
+        if (classpath.stream().noneMatch(Resource::isExists)) {
+            throw new BuildException(
+                "Classpath must contain at least one entry which exists.",
+                getLocation());
+        }
+
+        if (version != null && moduleVersion != null) {
+            throw new BuildException(
+                "version attribute and nested <version> element "
+                + "cannot both be present.",
+                getLocation());
+        }
+
+        if (hashModulesPattern != null && !hashModulesPattern.isEmpty()
+            && modulePath == null) {
+
+            throw new BuildException(
+                "hashModulesPattern requires a module path, since "
+                + "it will generate hashes of the other modules which depend "
+                + "on the module being created.",
+                getLocation());
+        }
+
+        checkDirPaths();
+
+        Path[] dependentPaths = {
+            classpath,
+            modulePath,
+            commandPath,
+            configPath,
+            headerPath,
+            legalPath,
+            nativeLibPath,
+            manPath,
+        };
+        Union allResources = new Union(getProject());
+        for (Path path : dependentPaths) {
+            if (path != null) {
+                for (String entry : path.list()) {
+                    File entryFile = new File(entry);
+                    if (entryFile.isDirectory()) {
+                        log("Will compare timestamp of all files in "
+                            + "\"" + entryFile + "\" with timestamp of "
+                            + jmodFile, Project.MSG_VERBOSE);
+                        FileSet fileSet = new FileSet();
+                        fileSet.setDir(entryFile);
+                        allResources.add(fileSet);
+                    } else {
+                        log("Will compare timestamp of \"" + entryFile + "\" "
+                            + "with timestamp of " + jmodFile,
+                            Project.MSG_VERBOSE);
+                        allResources.add(new FileResource(entryFile));
+                    }
+                }
+            }
+        }
+
+        ResourceCollection outOfDate =
+            ResourceUtils.selectOutOfDateSources(this, allResources,
+                new MergingMapper(jmodFile.toString()),
+                getProject(),
+                FileUtils.getFileUtils().getFileTimestampGranularity());
+
+        if (outOfDate.isEmpty()) {
+            log("Skipping jmod creation, since \"" + jmodFile + "\" "
+                + "is already newer than all files in paths.",
+                Project.MSG_VERBOSE);
+            return;
+        }
+
+        Collection<String> args = buildJmodArgs();
+
+        try {
+            log("Deleting " + jmodFile + " if it exists.", Project.MSG_VERBOSE);
+            Files.deleteIfExists(jmodFile.toPath());
+        } catch (IOException e) {
+            throw new BuildException(
+                "Could not remove old file \"" + jmodFile + "\": " + e, e,
+                getLocation());
+        }
+
+        ToolProvider jmod = ToolProvider.findFirst("jmod").orElseThrow(
+            () -> new BuildException("jmod tool not found in JDK.",
+                getLocation()));
+
+        log("Executing: jmod " + String.join(" ", args), Project.MSG_VERBOSE);
+
+        ByteArrayOutputStream stdout = new ByteArrayOutputStream();
+        ByteArrayOutputStream stderr = new ByteArrayOutputStream();
+
+        int exitCode;
+        try (PrintStream out = new PrintStream(stdout);
+             PrintStream err = new PrintStream(stderr)) {
+
+            exitCode = jmod.run(out, err, args.toArray(new String[0]));
+        }
+
+        if (exitCode != 0) {
+            StringBuilder message = new StringBuilder();
+            message.append("jmod failed (exit code ").append(exitCode).append(")");
+            if (stdout.size() > 0) {
+                message.append(", output is: ").append(stdout);
+            }
+            if (stderr.size() > 0) {
+                message.append(", error output is: ").append(stderr);
+            }
+
+            throw new BuildException(message.toString(), getLocation());
+        }
+
+        log("Created " + jmodFile.getAbsolutePath(), Project.MSG_INFO);
+    }
+
+    /**
+     * Creates list of arguments to <code>jmod</code> tool, based on this
+     * instance's current state.
+     *
+     * @return new list of <code>jmod</code> arguments
+     */
+    private Collection<String> buildJmodArgs() {
+        Collection<String> args = new ArrayList<>();
+
+        args.add("create");
+
+        args.add("--class-path");
+        args.add(classpath.toString());
+
+        // Paths
+
+        if (modulePath != null && !modulePath.isEmpty()) {
+            args.add("--module-path");
+            args.add(modulePath.toString());
+        }
+        if (commandPath != null && !commandPath.isEmpty()) {
+            args.add("--cmds");
+            args.add(commandPath.toString());
+        }
+        if (configPath != null && !configPath.isEmpty()) {
+            args.add("--config");
+            args.add(configPath.toString());
+        }
+        if (headerPath != null && !headerPath.isEmpty()) {
+            args.add("--header-files");
+            args.add(headerPath.toString());
+        }
+        if (legalPath != null && !legalPath.isEmpty()) {
+            args.add("--legal-notices");
+            args.add(legalPath.toString());
+        }
+        if (nativeLibPath != null && !nativeLibPath.isEmpty()) {
+            args.add("--libs");
+            args.add(nativeLibPath.toString());
+        }
+        if (manPath != null && !manPath.isEmpty()) {
+            args.add("--man-pages");
+            args.add(manPath.toString());
+        }
+
+        // Strings
+
+        String versionStr =
+            (moduleVersion != null ? moduleVersion.toModuleVersionString() : version);
+        if (versionStr != null && !versionStr.isEmpty()) {
+            args.add("--module-version");
+            args.add(versionStr);
+        }
+
+        if (mainClass != null && !mainClass.isEmpty()) {
+            args.add("--main-class");
+            args.add(mainClass);
+        }
+        if (platform != null && !platform.isEmpty()) {
+            args.add("--target-platform");
+            args.add(platform);
+        }
+        if (hashModulesPattern != null && !hashModulesPattern.isEmpty()) {
+            args.add("--hash-modules");
+            args.add(hashModulesPattern);
+        }
+
+        // booleans
+
+        if (!resolveByDefault) {
+            args.add("--do-not-resolve-by-default");
+        }
+        for (ResolutionWarningSpec moduleWarning : moduleWarnings) {
+            moduleWarning.validate();
+            args.add("--warn-if-resolved");
+            args.add(moduleWarning.getReason().toCommandLineOption());
+        }
+
+        // Destination file
+
+        args.add(jmodFile.toString());
+
+        return args;
+    }
+}
diff --git a/src/main/org/apache/tools/ant/taskdefs/modules/Link.java b/src/main/org/apache/tools/ant/taskdefs/modules/Link.java
new file mode 100644 (file)
index 0000000..36fd281
--- /dev/null
@@ -0,0 +1,2120 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one or more
+ *  contributor license agreements.  See the NOTICE file distributed with
+ *  this work for additional information regarding copyright ownership.
+ *  The ASF licenses this file to You under the Apache License, Version 2.0
+ *  (the "License"); you may not use this file except in compliance with
+ *  the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ *
+ */
+
+package org.apache.tools.ant.taskdefs.modules;
+
+import java.io.File;
+import java.io.PrintStream;
+import java.io.ByteArrayOutputStream;
+import java.io.Reader;
+import java.io.IOException;
+
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+
+import java.nio.file.Files;
+import java.nio.file.FileVisitResult;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.attribute.BasicFileAttributes;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.ArrayList;
+
+import java.util.Map;
+import java.util.LinkedHashMap;
+import java.util.Properties;
+
+import java.util.Collections;
+import java.util.Objects;
+
+import java.util.spi.ToolProvider;
+
+import java.util.stream.Stream;
+import java.util.stream.Collectors;
+
+import org.apache.tools.ant.BuildException;
+import org.apache.tools.ant.Project;
+import org.apache.tools.ant.Task;
+
+import org.apache.tools.ant.types.EnumeratedAttribute;
+import org.apache.tools.ant.types.LogLevel;
+import org.apache.tools.ant.types.Path;
+import org.apache.tools.ant.types.Reference;
+import org.apache.tools.ant.types.ResourceCollection;
+
+import org.apache.tools.ant.util.CompositeMapper;
+import org.apache.tools.ant.util.MergingMapper;
+
+import org.apache.tools.ant.util.FileUtils;
+import org.apache.tools.ant.util.ResourceUtils;
+
+/**
+ * Assembles jmod files into an executable image.  Equivalent to the
+ * JDK {@code jlink} command.
+ * <p>
+ * Supported attributes:
+ * <dl>
+ * <dt>{@code destDir}
+ * <dd>Root directory of created image. (required)
+ * <dt>{@code modulePath}
+ * <dd>Path of modules.  Should be a list of .jmod files.  Required, unless
+ *     nested module path or modulepathref is present.
+ * <dt>{@code modulePathRef}
+ * <dd>Reference to path of modules.  Referenced path should be
+ *     a list of .jmod files.
+ * <dt>{@code modules}
+ * <dd>Comma-separated list of modules to assemble.  Required, unless
+ *     one or more nested {@code <module>} elements are present.
+ * <dt>{@code observableModules}
+ * <dd>Comma-separated list of explicit modules that comprise
+ *     "universe" visible to tool while linking.
+ * <dt>{@code launchers}
+ * <dd>Comma-separated list of commands, each of the form
+ *     <var>name</var>{@code =}<var>module</var> or
+ *     <var>name</var>{@code =}<var>module</var>{@code /}<var>mainclass</var>
+ * <dt>{@code excludeFiles}
+ * <dd>Comma-separated list of patterns specifying files to exclude from
+ *     linked image.
+ *     Each is either a <a href="https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/nio/file/FileSystem.html#getPathMatcher%28java.lang.String%29">standard PathMatcher pattern</a>
+ *     or {@code @}<var>filename</var>.
+ * <dt>{@code excludeResources}
+ * <dd>Comma-separated list of patterns specifying resources to exclude from jmods.
+ *     Each is either a <a href="https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/nio/file/FileSystem.html#getPathMatcher%28java.lang.String%29">standard PathMatcher pattern</a>
+ *     or {@code @}<var>filename</var>.
+ * <dt>{@code locales}
+ * <dd>Comma-separated list of extra locales to include,
+ *     requires {@code jdk.localedata} module
+ * <dt>{@code resourceOrder}
+ * <dt>Comma-separated list of patterns specifying resource search order.
+ *     Each is either a <a href="https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/nio/file/FileSystem.html#getPathMatcher%28java.lang.String%29">standard PathMatcher pattern</a>
+ *     or {@code @}<var>filename</var>.
+ * <dt>{@code bindServices}
+ * <dd>boolean, whether to link service providers; default is false
+ * <dt>{@code ignoreSigning}
+ * <dd>boolean, whether to allow signed jar files; default is false
+ * <dt>{@code includeHeaders}
+ * <dd>boolean, whether to include header files; default is true
+ * <dt>{@code includeManPages}
+ * <dd>boolean, whether to include man pages; default is true
+ * <dt>{@code includeNativeCommands}
+ * <dd>boolean, whether to include native executables normally generated
+ *     for image; default is true
+ * <dt>{@code debug}
+ * <dd>boolean, whether to include debug information; default is true
+ * <dt>{@code verboseLevel}
+ * <dd>If set, jlink will produce verbose output, which will be logged at
+ *     the specified Ant log level ({@code DEBUG}, {@code VERBOSE},
+ *     {@code INFO}}, {@code WARN}, or {@code ERR}).
+ * <dt>{@code compress}
+ * <dd>compression level, one of:
+ *     <dl>
+ *     <dt>{@code 0}
+ *     <dt>{@code none}
+ *     <dd>no compression (default)
+ *     <dt>{@code 1}
+ *     <dt>{@code strings}
+ *     <dd>constant string sharing
+ *     <dt>{@code 2}
+ *     <dt>{@code zip}
+ *     <dd>zip compression
+ *     </dl>
+ * <dt>{@code endianness}
+ * <dd>Must be {@code little} or {@code big}, default is native endianness
+ * <dt>{@code checkDuplicateLegal}
+ * <dd>Boolean.  When merging legal notices from different modules
+ *     because they have the same name, verify that their contents
+ *     are identical.  Default is false, which means any license files
+ *     with the same name are assumed to have the same content, and no
+ *     checking is done.
+ * <dt>{@code vmType}
+ * <dd>Hotspot VM in image, one of:
+ *     <ul>
+ *     <li>{@code client}
+ *     <li>{@code server}
+ *     <li>{@code minimal}
+ *     <li>{@code all} (default)
+ *     </ul>
+ * </dl>
+ *
+ * <p>
+ * Supported nested elements
+ * <dl>
+ * <dt>{@code <modulepath>}
+ * <dd>path element
+ * <dt>{@code <module>}
+ * <dd>May be specified multiple times.
+ *     Only attribute is required {@code name} attribute.
+ * <dt>{@code <observableModule>}
+ * <dd>May be specified multiple times.
+ *     Only attribute is required {@code name} attribute.
+ * <dt>{@code <launcher>}
+ * <dd>May be specified multiple times.  Attributes:
+ *     <ul>
+ *     <li>{@code name} (required)
+ *     <li>{@code module} (required)
+ *     <li>{@code mainClass} (optional)
+ *     </ul>
+ * <dt>{@code <locale>}
+ * <dd>May be specified multiple times.
+ *     Only attribute is required {@code name} attribute.
+ * <dt>{@code <resourceOrder>}
+ * <dd>Explicit resource search order in image.  May be specified multiple
+ *     times.  Exactly one of these attributes must be specified:
+ *     <dl>
+ *     <dt>{@code pattern}
+ *     <dd>A <a href="https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/nio/file/FileSystem.html#getPathMatcher%28java.lang.String%29">standard PathMatcher pattern</a>
+ *     <dt>{@code listFile}
+ *     <dd>Text file containing list of resource names (not patterns),
+ *         one per line
+ *     </dl>
+ *     If the {@code resourceOrder} attribute is also present on the task, its
+ *     patterns are treated as if they occur before patterns in nested
+ *     {@code <resourceOrder>} elements.
+ * <dt>{@code <excludeFiles>}
+ * <dd>Excludes files from linked image tree.  May be specified multiple times.
+ *     Exactly one of these attributes is required:
+ *     <dl>
+ *     <dt>{@code pattern}
+ *     <dd>A <a href="https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/nio/file/FileSystem.html#getPathMatcher%28java.lang.String%29">standard PathMatcher pattern</a>
+ *     <dt>{@code listFile}
+ *     <dd>Text file containing list of file names (not patterns),
+ *         one per line
+ *     </dl>
+ * <dt>{@code <excludeResources>}
+ * <dd>Excludes resources from jmods.  May be specified multiple times.
+ *     Exactly one of these attributes is required:
+ *     <dl>
+ *     <dt>{@code pattern}
+ *     <dd>A <a href="https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/nio/file/FileSystem.html#getPathMatcher%28java.lang.String%29">standard PathMatcher pattern</a>
+ *     <dt>{@code listFile}
+ *     <dd>Text file containing list of resource names (not patterns),
+ *         one per line
+ *     </dl>
+ * <dt>{@code <compress>}
+ * <dd>Must have {@code level} attribute, whose permitted values are the same
+ *     as the {@code compress} task attribute described above.
+ *     May also have a {@code files} attribute, which is a comma-separated
+ *     list of patterns, and/or nested {@code <files>} elements, each with
+ *     either a {@code pattern} attribute or {@code listFile} attribute.
+ * <dt>{@code <releaseInfo>}
+ * <dd>Replaces, augments, or trims the image's release info properties.
+ *     This may specify any of the following:
+ *     <ul>
+ *     <li>A {@code file} attribute, pointing to a Java properties file
+ *         containing new release info properties that will entirely replace
+ *         the current ones.
+ *     <li>A {@code delete} attribute, containing comma-separated property keys
+ *         to remove from application's release info, and/or any number of
+ *         nested {@code <delete>} elements, each with a required {@code key}
+ *         attribute.
+ *     <li>One or more nested {@code <add>} elements, containing either
+ *         {@code key} and {@code value} attributes, or a {@code file}
+ *         attribute and an optional {@code charset} attribute.
+ *     </ul>
+ * </dl>
+ *
+ * @see <a href="https://docs.oracle.com/en/java/javase/11/tools/jlink.html"><code>jlink</code> tool reference</a>
+ */
+public class Link
+extends Task {
+    /**
+     * Error message for improperly formatted launcher attribute.
+     */
+    private static final String INVALID_LAUNCHER_STRING =
+        "Launcher command must take the form name=module "
+        + "or name=module/mainclass";
+
+    /** Path of directories containing linkable modules. */
+    private Path modulePath;
+
+    /** Modules to include in linked image. */
+    private final List<ModuleSpec> modules = new ArrayList<>();
+
+    /** If non-empty, list of all modules linker is permitted to know about. */
+    private final List<ModuleSpec> observableModules = new ArrayList<>();
+
+    /**
+     * Additional runnable programs which linker will place in image's
+     * <code>bin</code> directory.
+     */
+    private final List<Launcher> launchers = new ArrayList<>();
+
+    /**
+     * Locales to explicitly include from {@code jdk.localdata} module.
+     * If empty, all locales are included.
+     */
+    private final List<LocaleSpec> locales = new ArrayList<>();
+
+    /** Resource ordering. */
+    private final List<PatternListEntry> ordering = new ArrayList<>();
+
+    /** Files to exclude from linked image. */
+    private final List<PatternListEntry> excludedFiles = new ArrayList<>();
+
+    /**
+     * Resources in linked modules which should be excluded from linked image.
+     */
+    private final List<PatternListEntry> excludedResources = new ArrayList<>();
+
+    /**
+     * Whether to include all service provides in linked image which are
+     * present in the module path and which are needed by modules explicitly
+     * linked.
+     */
+    private boolean bindServices;
+
+    /**
+     * Whether to ignore signed jars (and jmods based on signed jars) when
+     * linking, instead of emitting an error.
+     */
+    private boolean ignoreSigning;
+
+    /** Whether to include header files from linked modules in image. */
+    private boolean includeHeaders = true;
+
+    /** Whether to include man pages from linked modules in image. */
+    private boolean includeManPages = true;
+
+    /** Whether to include native commands from linked modules in image. */
+    private boolean includeNativeCommands = true;
+
+    /** Whether to include classes' debug information or strip it. */
+    private boolean debug = true;
+
+    /**
+     * The Ant logging level at which verbose output of linked should be
+     * emitted.  If null, verbose output is disabled.
+     */
+    private LogLevel verboseLevel;
+
+    /** Directory into which linked image will be placed. */
+    private File outputDir;
+
+    /** Endianness of some files (?) in linked image. */
+    private Endianness endianness;
+
+    /**
+     * Simple compression level applied to linked image.
+     * This or {@link #compression} may be set, but not both.
+     */
+    private CompressionLevel compressionLevel;
+
+    /**
+     * Describes which files in image to compress, and how to compress them.
+     * This or {@link #compressionLevel} may be set, but not both.
+     */
+    private Compression compression;
+
+    /**
+     * Whether to check duplicate legal notices from different modules
+     * actually have identical content, not just indentical names,
+     * before merging them.
+     * <a href="https://github.com/AdoptOpenJDK/openjdk-jdk11/blob/master/src/jdk.jlink/share/classes/jdk/tools/jlink/internal/JlinkTask.java#L80">Forced to true as of Java 11.</a>
+     */
+    private boolean checkDuplicateLegal;
+
+    /** Type of VM in linked image. */
+    private VMType vmType;
+
+    /** Changes to linked image's default release info. */
+    private final List<ReleaseInfo> releaseInfo = new ArrayList<>();
+
+    /**
+     * Adds child {@code <modulePath>} element.
+     *
+     * @return new, empty child element
+     *
+     * @see #setModulePath(Path)
+     */
+    public Path createModulePath() {
+        if (modulePath == null) {
+            modulePath = new Path(getProject());
+        }
+        return modulePath.createPath();
+    }
+
+    /**
+     * Attribute containing path of directories containing linkable modules.
+     *
+     * @return current module path, possibly {@code null}
+     *
+     * @see #setModulePath(Path)
+     * @see #createModulePath()
+     */
+    public Path getModulePath() {
+        return modulePath;
+    }
+
+    /**
+     * Sets attribute containing path of directories containing
+     * linkable modules.
+     *
+     * @param path new module path
+     *
+     * @see #getModulePath()
+     * @see #setModulePathRef(Reference)
+     * @see #createModulePath()
+     */
+    public void setModulePath(final Path path) {
+        if (modulePath == null) {
+            this.modulePath = path;
+        } else {
+            modulePath.append(path);
+        }
+    }
+
+    /**
+     * Sets module path as a reference.
+     *
+     * @param ref path reference
+     *
+     * @see #setModulePath(Path)
+     * @see #createModulePath()
+     */
+    public void setModulePathRef(final Reference ref) {
+        createModulePath().setRefid(ref);
+    }
+
+    /**
+     * Adds child {@code <module>} element, specifying a module to link.
+     *
+     * @return new, unconfigured child element
+     *
+     * @see #setModules(String)
+     */
+    public ModuleSpec createModule() {
+        ModuleSpec module = new ModuleSpec();
+        modules.add(module);
+        return module;
+    }
+
+    /**
+     * Sets attribute containing list of modules to link.
+     *
+     * @param moduleList comma-separated list of module names
+     */
+    public void setModules(final String moduleList) {
+        for (String moduleName : moduleList.split(",")) {
+            modules.add(new ModuleSpec(moduleName));
+        }
+    }
+
+    /**
+     * Creates child {@code <observableModule>} element that represents
+     * one of the modules the linker is permitted to know about.
+     *
+     * @return new, unconfigured child element
+     */
+    public ModuleSpec createObservableModule() {
+        ModuleSpec module = new ModuleSpec();
+        observableModules.add(module);
+        return module;
+    }
+
+    /**
+     * Sets attribute containing modules linker is permitted to know about.
+     *
+     * @param moduleList comma-separated list of module names
+     */
+    public void setObservableModules(final String moduleList) {
+        for (String moduleName : moduleList.split(",")) {
+            observableModules.add(new ModuleSpec(moduleName));
+        }
+    }
+
+    /**
+     * Creates child {@code <launcher>} element that can contain information
+     * on additional executable in the linked image.
+     *
+     * @return new, unconfigured child element
+     *
+     * @see #setLaunchers(String)
+     */
+    public Launcher createLauncher() {
+        Launcher command = new Launcher();
+        launchers.add(command);
+        return command;
+    }
+
+    /**
+     * Sets attribute containing comma-separated list of information needed for
+     * additional executables in the linked image.  Each item must be of the
+     * form * <var>name</var>{@code =}<var>module</var> or
+     * <var>name</var>{@code =}<var>module</var>{@code /}<var>mainclass</var>.
+     *
+     * @param launcherList comma-separated list of launcher data
+     */
+    public void setLaunchers(final String launcherList) {
+        for (String launcherSpec : launcherList.split(",")) {
+            launchers.add(new Launcher(launcherSpec));
+        }
+    }
+
+    /**
+     * Creates child {@code <locale>} element that specifies a Java locale,
+     * or set of locales, to include from the {@code jdk.localedata} module
+     * in the linked image.
+     *
+     * @return new, unconfigured child element
+     */
+    public LocaleSpec createLocale() {
+        LocaleSpec locale = new LocaleSpec();
+        locales.add(locale);
+        return locale;
+    }
+
+    /**
+     * Sets attribute containing a list of locale patterns, to specify
+     * Java locales to include from {@code jdk.localedata} module in
+     * linked image.  Asterisks ({@code *}) are permitted for wildcard
+     * matches.
+     *
+     * @param localeList comma-separated list of locale patterns
+     */
+    public void setLocales(final String localeList) {
+        for (String localeName : localeList.split(",")) {
+            locales.add(new LocaleSpec(localeName));
+        }
+    }
+
+    /**
+     * Creates child {@code <excludeFiles>} element that specifies
+     * files to exclude from linked modules when assembling linked image.
+     *
+     * @return new, unconfigured child element
+     *
+     * @see #setExcludeFiles(String)
+     */
+    public PatternListEntry createExcludeFiles() {
+        PatternListEntry entry = new PatternListEntry();
+        excludedFiles.add(entry);
+        return entry;
+    }
+
+    /**
+     * Sets attribute containing a list of patterns denoting files
+     * to exclude from linked modules when assembling linked image.
+     *
+     * @param patternList comman-separated list of patterns
+     *
+     * @see Link.PatternListEntry
+     */
+    public void setExcludeFiles(String patternList) {
+        for (String pattern : patternList.split(",")) {
+            excludedFiles.add(new PatternListEntry(pattern));
+        }
+    }
+
+    /**
+     * Creates child {@code <excludeResources>} element that specifies
+     * resources in linked modules that will be excluded from linked image.
+     *
+     * @return new, unconfigured child element
+     *
+     * @see #setExcludeResources(String)
+     */
+    public PatternListEntry createExcludeResources() {
+        PatternListEntry entry = new PatternListEntry();
+        excludedResources.add(entry);
+        return entry;
+    }
+
+    /**
+     * Sets attribute containing a list of patterns denoting resources
+     * to exclude from linked modules in linked image.
+     *
+     * @param patternList comma-separated list of patterns
+     *
+     * @see #createExcludeResources()
+     * @see Link.PatternListEntry
+     */
+    public void setExcludeResources(String patternList) {
+        for (String pattern : patternList.split(",")) {
+            excludedResources.add(new PatternListEntry(pattern));
+        }
+    }
+
+    /**
+     * Creates child {@code <resourceOrder} element that specifies
+     * explicit ordering of resources in linked image.
+     *
+     * @return new, unconfigured child element
+     *
+     * @see #setResourceOrder(String)
+     */
+    public PatternListEntry createResourceOrder() {
+        PatternListEntry order = new PatternListEntry();
+        ordering.add(order);
+        return order;
+    }
+
+    /**
+     * Sets attribute containing a list of patterns that explicitly
+     * order resources in the linked image.  Any patterns specified here
+     * will be placed before any patterns specified as
+     * {@linkplain #createResourceOrder() child elements}.
+     *
+     * @param patternList comma-separated list of patterns
+     *
+     * @see #createResourceOrder()
+     * @see Link.PatternListEntry
+     */
+    public void setResourceOrder(final String patternList) {
+        List<PatternListEntry> orderList = new ArrayList<>();
+
+        for (String pattern : patternList.split(",")) {
+            orderList.add(new PatternListEntry(pattern));
+        }
+
+        // Attribute value comes before nested elements.
+        ordering.addAll(0, orderList);
+    }
+
+    /**
+     * Attribute indicating whether linked image should pull in providers
+     * in the module path of services used by explicitly linked modules.
+     *
+     * @return true if linked will pull in service provides, false if not
+     *
+     * @see #setBindServices(boolean)
+     */
+    public boolean getBindServices() {
+        return bindServices;
+    }
+
+    /**
+     * Sets attribute indicating whether linked image should pull in providers
+     * in the module path of services used by explicitly linked modules.
+     *
+     * @param bind whether to include service providers
+     *
+     * @see #getBindServices()
+     */
+    public void setBindServices(final boolean bind) {
+        this.bindServices = bind;
+    }
+
+    /**
+     * Attribute indicating whether linker should allow modules made from
+     * signed jars.
+     *
+     * @return true if signed jars are allowed, false if modules based on
+     *         signed jars cause an error
+     *
+     * @see #setIgnoreSigning(boolean)
+     */
+    public boolean getIgnoreSigning() {
+        return ignoreSigning;
+    }
+
+    /**
+     * Sets attribute indicating whether linker should allow modules made from
+     * signed jars.
+     * <p>
+     * Note: As of Java 11, this attribute is internally forced to true.  See
+     * <a href="https://github.com/AdoptOpenJDK/openjdk-jdk11/blob/master/src/jdk.jlink/share/classes/jdk/tools/jlink/internal/JlinkTask.java#L80">the source</a>.
+     *
+     * @param ignore true to have linker allow signed jars,
+     *               false to have linker emit an error for signed jars
+     *
+     *
+     * @see #getIgnoreSigning()
+     */
+    public void setIgnoreSigning(final boolean ignore) {
+        this.ignoreSigning = ignore;
+    }
+
+    /**
+     * Attribute indicating whether to include header files from linked modules
+     * in image.
+     *
+     * @return true if header files should be included, false to exclude them
+     *
+     * @see #setIncludeHeaders(boolean)
+     */
+    public boolean getIncludeHeaders() {
+        return includeHeaders;
+    }
+
+    /**
+     * Sets attribute indicating whether to include header files from
+     * linked modules in image.
+     *
+     * @param include true if header files should be included,
+     *                false to exclude them
+     *
+     * @see #getIncludeHeaders()
+     */
+    public void setIncludeHeaders(final boolean include) {
+        this.includeHeaders = include;
+    }
+
+    /**
+     * Attribute indicating whether to include man pages from linked modules
+     * in image.
+     *
+     * @return true if man pages should be included, false to exclude them
+     *
+     * @see #setIncludeManPages(boolean)
+     */
+    public boolean getIncludeManPages() {
+        return includeManPages;
+    }
+
+    /**
+     * Sets attribute indicating whether to include man pages from
+     * linked modules in image.
+     *
+     * @param include true if man pages should be included,
+     *                false to exclude them
+     *
+     * @see #getIncludeManPages()
+     */
+    public void setIncludeManPages(final boolean include) {
+        this.includeManPages = include;
+    }
+
+    /**
+     * Attribute indicating whether to include generated native commands,
+     * and native commands from linked modules, in image.
+     *
+     * @return true if native commands should be included, false to exclude them
+     *
+     * @see #setIncludeNativeCommands(boolean)
+     */
+    public boolean getIncludeNativeCommands() {
+        return includeNativeCommands;
+    }
+
+    /**
+     * Sets attribute indicating whether to include generated native commands,
+     * and native commands from linked modules, in image.
+     *
+     * @param include true if native commands should be included,
+     *                false to exclude them
+     *
+     * @see #getIncludeNativeCommands()
+     */
+    public void setIncludeNativeCommands(final boolean include) {
+        this.includeNativeCommands = include;
+    }
+
+    /**
+     * Attribute indicating whether linker should keep or strip
+     * debug information in classes.
+     *
+     * @return true if debug information will be retained,
+     *         false if it will be stripped
+     *
+     * @see #setDebug(boolean)
+     */
+    public boolean getDebug() {
+        return debug;
+    }
+
+    /**
+     * Sets attribute indicating whether linker should keep or strip
+     * debug information in classes.
+     *
+     * @param debug true if debug information should be retained,
+     *              false if it should be stripped
+     *
+     * @see #getDebug()
+     */
+    public void setDebug(final boolean debug) {
+        this.debug = debug;
+    }
+
+    /**
+     * Attribute indicating whether linker should produce verbose output,
+     * and at what logging level that output should be shown.
+     *
+     * @return logging level at which to show linker's verbose output,
+     *         or {@code null} to disable verbose output
+     *
+     * @see #setVerboseLevel(LogLevel)
+     */
+    public LogLevel getVerboseLevel() {
+        return verboseLevel;
+    }
+
+    /**
+     * Sets attribute indicating whether linker should produce verbose output,
+     * and at what logging level that output should be shown.
+     *
+     * @param level level logging level at which to show linker's
+     *              verbose output, or {@code null} to disable verbose output
+     *
+     * @see #getVerboseLevel()
+     */
+    public void setVerboseLevel(final LogLevel level) {
+        this.verboseLevel = level;
+    }
+
+    /**
+     * Required attribute containing directory where linked image will be
+     * created.
+     *
+     * @return directory where linked image will reside
+     *
+     * @see #setDestDir(File)
+     */
+    public File getDestDir() {
+        return outputDir;
+    }
+
+    /**
+     * Sets attribute indicating directory where linked image will be created.
+     *
+     * @param dir directory in which image will be created by linker
+     *
+     * @see #getDestDir()
+     */
+    public void setDestDir(final File dir) {
+        this.outputDir = dir;
+    }
+
+    /**
+     * Attribute indicating level of compression linker will apply to image.
+     * This is exclusive with regard to {@link #createCompress()}:  only one
+     * of the two may be specified.
+     *
+     * @return compression level to apply, or {@code null} for none
+     *
+     * @see #setCompress(Link.CompressionLevel)
+     * @see #createCompress()
+     */
+    public CompressionLevel getCompress() {
+        return compressionLevel;
+    }
+
+    /**
+     * Sets attribute indicating level of compression linker will apply
+     * to image. This is exclusive with regard to {@link #createCompress()}:
+     * only one of the two may be specified.
+     *
+     * @param level compression level to apply, or {@code null} for none
+     *
+     * @see #getCompress()
+     * @see #createCompress()
+     */
+    public void setCompress(final CompressionLevel level) {
+        this.compressionLevel = level;
+    }
+
+    /**
+     * Creates child {@code <compress>} element that specifies the level of
+     * compression the linker will apply, and optionally, which files in the
+     * image will be compressed.  This is exclusive with regard to the
+     * {@link #setCompress compress} attribute:  only one of the two may be
+     * specified.
+     *
+     * @return new, unconfigured child element
+     *
+     * @see #setCompress(Link.CompressionLevel)
+     */
+    public Compression createCompress() {
+        if (compression != null) {
+            throw new BuildException(
+                "Only one nested compression element is permitted.",
+                getLocation());
+        }
+        compression = new Compression();
+        return compression;
+    }
+
+    /**
+     * Attribute which indicates whether certain files in the linked image
+     * will be big-endian or little-endian.  If {@code null}, the underlying
+     * platform's endianness is used.
+     *
+     * @return endianness to apply, or {@code null} to platform default
+     *
+     * @see #setEndianness(Link.Endianness)
+     */
+    public Endianness getEndianness() {
+        return endianness;
+    }
+
+    /**
+     * Sets attribute which indicates whether certain files in the linked image
+     * will be big-endian or little-endian.  If {@code null}, the underlying
+     * platform's endianness is used.
+     *
+     * @param endianness endianness to apply, or {@code null} to use
+     *                   platform default
+     *
+     * @see #getEndianness()
+     */
+    public void setEndianness(final Endianness endianness) {
+        this.endianness = endianness;
+    }
+
+    /**
+     * Attribute indicating whether linker should check legal notices with
+     * duplicate names, and refuse to merge them (usually using symbolic links)
+     * if their respective content is not identical.
+     *
+     * @return true if legal notice files with same name should be checked
+     *         for identical content, false to suppress check
+     *
+     * @see #setCheckDuplicateLegal(boolean)
+     */
+    public boolean getCheckDuplicateLegal() {
+        return checkDuplicateLegal;
+    }
+
+    /**
+     * Sets attribute indicating whether linker should check legal notices with
+     * duplicate names, and refuse to merge them (usually using symbolic links)
+     * if their respective content is not identical.
+     *
+     * @param check true if legal notice files with same name should be checked
+     *         for identical content, false to suppress check
+     *
+     * @see #getCheckDuplicateLegal()
+     */
+    public void setCheckDuplicateLegal(final boolean check) {
+        this.checkDuplicateLegal = check;
+    }
+
+    /**
+     * Attribute indicating what type of JVM the linked image should have.
+     * If {@code null}, all JVM types are included.
+     *
+     * @return type of JVM linked image will have
+     *
+     * @see #setVmType(Link.VMType)
+     */
+    public VMType getVmType() {
+        return vmType;
+    }
+
+    /**
+     * Set attribute indicating what type of JVM the linked image should have.
+     * If {@code null}, all JVM types are included.
+     *
+     * @param type type of JVM linked image will have
+     *
+     * @see #getVmType()
+     */
+    public void setVmType(final VMType type) {
+        this.vmType = type;
+    }
+
+    /**
+     * Creates child {@code <releaseInfo>} element that modifies the default
+     * release properties of the linked image.
+     *
+     * @return new, unconfigured child element
+     */
+    public ReleaseInfo createReleaseInfo() {
+        ReleaseInfo info = new ReleaseInfo();
+        releaseInfo.add(info);
+        return info;
+    }
+
+    /**
+     * Child element that explicitly names a Java module.
+     */
+    public class ModuleSpec {
+        /** Module's name.  Required. */
+        private String name;
+
+        /** Creates an unconfigured element. */
+        public ModuleSpec() {
+            // Deliberately empty.
+        }
+
+        /**
+         * Creates an element with the given module name.
+         *
+         * @param name module's name
+         */
+        public ModuleSpec(final String name) {
+            setName(name);
+        }
+
+        /**
+         * Attribute containing name of module this element represents.
+         *
+         * @return name of module
+         */
+        public String getName() {
+            return name;
+        }
+
+        /**
+         * Sets attribute representing the name of this module this element
+         * represents.
+         *
+         * @param name module's name
+         */
+        public void setName(final String name) {
+            this.name = name;
+        }
+
+        /**
+         * Verifies this element's state.
+         *
+         * @throws BuildException if name is not set
+         */
+        public void validate() {
+            if (name == null) {
+                throw new BuildException("name is required for module.",
+                    getLocation());
+            }
+        }
+    }
+
+    /**
+     * Child element that contains a pattern matching Java locales.
+     */
+    public class LocaleSpec {
+        /** Pattern of locale names to match. */
+        private String name;
+
+        /** Creates an unconfigured element. */
+        public LocaleSpec() {
+            // Deliberately empty.
+        }
+
+        /**
+         * Creates an element with the given name pattern.
+         *
+         * @param name pattern of locale names to match
+         */
+        public LocaleSpec(final String name) {
+            setName(name);
+        }
+
+        /**
+         * Attribute containing a pattern which matches Java locale names.
+         * May be an explicit Java locale, or may contain an asterisk
+         * ({@code *)} for wildcard matching.
+         *
+         * @return this element's locale name pattern
+         */
+        public String getName() {
+            return name;
+        }
+
+        /**
+         * Sets attribute containing a pattern which matches Java locale names.
+         * May be an explicit Java locale, or may contain an asterisk
+         * ({@code *)} for wildcard matching.
+         *
+         * @param name new locale name or pattern matching locale names
+         */
+        public void setName(final String name) {
+            this.name = name;
+        }
+
+        /**
+         * Verifies this element's state.
+         *
+         * @throws BuildException if name is not set
+         */
+        public void validate() {
+            if (name == null) {
+                throw new BuildException("name is required for locale.",
+                    getLocation());
+            }
+        }
+    }
+
+    /**
+     * Child element type which specifies a jlink files pattern.  Each
+     * instance may specify a string
+     * <a href="https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/nio/file/FileSystem.html#getPathMatcher%28java.lang.String%29">PathMatcher pattern</a>
+     * or a text file containing a list of such patterns, one per line.
+     */
+    public class PatternListEntry {
+        /** PathMatcher pattern of files to match. */
+        private String pattern;
+
+        /** Plain text list file with one PathMatcher pattern per line. */
+        private File file;
+
+        /** Creates an unconfigured element. */
+        public PatternListEntry() {
+            // Deliberately empty.
+        }
+
+        /**
+         * Creates a new element from either a pattern or listing file.
+         * If the argument starts with "{@code @}", the remainder of it
+         * is assumed to be a listing file;  otherwise, it is treated as
+         * a PathMatcher pattern.
+         *
+         * @param pattern a PathMatcher pattern or {@code @}-filename
+         */
+        public PatternListEntry(final String pattern) {
+            if (pattern.startsWith("@")) {
+                setListFile(new File(pattern.substring(1)));
+            } else {
+                setPattern(pattern);
+            }
+        }
+
+        /**
+         * Returns this element's PathMatcher pattern attribute, if set.
+         *
+         * @return this element's files pattern
+         */
+        public String getPattern() {
+            return pattern;
+        }
+
+        /**
+         * Sets this element's
+         * <a href="https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/nio/file/FileSystem.html#getPathMatcher%28java.lang.String%29">PathMatcher pattern</a>
+         * attribute for matching files.
+         *
+         * @param pattern new files pattern
+         */
+        public void setPattern(final String pattern) {
+            this.pattern = pattern;
+        }
+
+        /**
+         * Returns this element's list file attribute, if set.
+         *
+         * @return this element's list file
+         *
+         * @see #setListFile(File)
+         */
+        public File getListFile() {
+            return file;
+        }
+
+        /**
+         * Sets this element's list file attribute.  The file must be a
+         * plain text file with one PathMatcher pattern per line.
+         *
+         * @param file list file containing patterns
+         *
+         * @see #getListFile()
+         */
+        public void setListFile(final File file) {
+            this.file = file;
+        }
+
+        /**
+         * Verifies this element's state.
+         *
+         * @throws BuildException if both pattern and file are set
+         * @throws BuildException if neither pattern nor file is set
+         */
+        public void validate() {
+            if ((pattern == null && file == null)
+                || (pattern != null && file != null)) {
+
+                throw new BuildException(
+                    "Each entry in a pattern list must specify "
+                    + "exactly one of pattern or file.", getLocation());
+            }
+        }
+
+        /**
+         * Converts this element to a jlink command line attribute,
+         * either this element's bare pattern, or its list file
+         * preceded by "{@code @}".
+         *
+         * @return this element's information converted to a command line value
+         */
+        public String toOptionValue() {
+            return pattern != null ? pattern : ("@" + file);
+        }
+    }
+
+    /**
+     * Child element representing a custom launcher command in a linked image.
+     * A launcher has a name, which is typically used as a file name for an
+     * executable file, a Java module name, and optionally a class within
+     * that module which can act as a standard Java main class.
+     */
+    public class Launcher {
+        /** This launcher's name, usually used to create an executable file. */
+        private String name;
+
+        /** The name of the Java module this launcher launches. */
+        private String module;
+
+        /**
+         * The class within this element's {@link #module} to run.
+         * Optional if the Java module specifies its own main class.
+         */
+        private String mainClass;
+
+        /** Creates a new, unconfigured element. */
+        public Launcher() {
+            // Deliberately empty.
+        }
+
+        /**
+         * Creates a new element from a {@code jlink}-compatible string
+         * specifier, which must take the form
+         * <var>name</var>{@code =}<var>module</var> or
+         * <var>name</var>{@code =}<var>module</var>{@code /}<var>mainclass</var>.
+         *
+         * @param textSpec name, module, and optional main class, as described
+         *                 above
+         *
+         * @throws NullPointerException if argument is {@code null}
+         * @throws BuildException if argument does not conform to above
+         *                        requirements
+         */
+        public Launcher(final String textSpec) {
+            Objects.requireNonNull(textSpec, "Text cannot be null");
+
+            int equals = textSpec.lastIndexOf('=');
+            if (equals < 1) {
+                throw new BuildException(INVALID_LAUNCHER_STRING);
+            }
+
+            setName(textSpec.substring(0, equals));
+
+            int slash = textSpec.indexOf('/', equals);
+            if (slash < 0) {
+                setModule(textSpec.substring(equals + 1));
+            } else if (slash > equals + 1 && slash < textSpec.length() - 1) {
+                setModule(textSpec.substring(equals + 1, slash));
+                setMainClass(textSpec.substring(slash + 1));
+            } else {
+                throw new BuildException(INVALID_LAUNCHER_STRING);
+            }
+        }
+
+        /**
+         * Returns this element's name attribute, typically used as the basis
+         * of an executable file name.
+         *
+         * @return this element's name
+         *
+         * @see #setName(String)
+         */
+        public String getName() {
+            return name;
+        }
+
+        /**
+         * Sets this element's name attribute, which is typically used by the
+         * linker to create an executable file with a similar name.  Thus,
+         * the name should contain only characters safe for file names.
+         *
+         * @param name name of launcher
+         */
+        public void setName(final String name) {
+            this.name = name;
+        }
+
+        /**
+         * Returns the attribute of this element which contains the
+         * name of the Java module to execute.
+         *
+         * @return this element's module name
+         */
+        public String getModule() {
+            return module;
+        }
+
+        /**
+         * Sets the attribute of this element which contains the name of
+         * a Java module to execute.
+         *
+         * @param module name of module to execute
+         */
+        public void setModule(final String module) {
+            this.module = module;
+        }
+
+        /**
+         * Returns the attribute of this element which contains the main class
+         * to execute in this element's {@linkplain #getModule() module}, if
+         * that module doesn't define its main class.
+         *
+         * @return name of main class to execute
+         */
+        public String getMainClass() {
+            return mainClass;
+        }
+
+        /**
+         * Sets the attribute which contains the main class to execute in
+         * this element's {@linkplain #getModule() module}, if that module
+         * doesn't define its main class.
+         *
+         * @param className name of class to execute
+         */
+        public void setMainClass(final String className) {
+            this.mainClass = className;
+        }
+
+        /**
+         * Verifies this element's state.
+         *
+         * @throws BuildException if name or module is not set
+         */
+        public void validate() {
+            if (name == null || name.isEmpty()) {
+                throw new BuildException("Launcher must have a name",
+                    getLocation());
+            }
+            if (module == null || module.isEmpty()) {
+                throw new BuildException("Launcher must have specify a module",
+                    getLocation());
+            }
+        }
+
+        /**
+         * Returns this element's information in jlink launcher format:
+         * <var>name</var>{@code =}<var>module</var> or
+         * <var>name</var>{@code =}<var>module</var>{@code /}<var>mainclass</var>.
+         *
+         * @return name, module and optional main class in jlink format
+         */
+        @Override
+        public String toString() {
+            if (mainClass != null) {
+                return name + "=" + module + "/" + mainClass;
+            } else {
+                return name + "=" + module;
+            }
+        }
+    }
+
+    /**
+     * Possible values for linked image endianness:
+     * {@code little} and {@code big}.
+     */
+    public static class Endianness
+    extends EnumeratedAttribute {
+        @Override
+        public String[] getValues() {
+            return new String[] {
+                "little", "big"
+            };
+        }
+    }
+
+    /**
+     * Possible values for JVM type in linked image:
+     * {@code client}, {@code server}, {@code minimal}, or {@code all}.
+     */
+    public static class VMType
+    extends EnumeratedAttribute {
+        @Override
+        public String[] getValues() {
+            return new String[] {
+                "client", "server", "minimal", "all"
+            };
+        }
+    }
+
+    /**
+     * Possible attribute values for compression level of a linked image:
+     * <dl>
+     * <dt>{@code 0}
+     * <dt>{@code none}
+     * <dd>no compression (default)
+     * <dt>{@code 1}
+     * <dt>{@code strings}
+     * <dd>constant string sharing
+     * <dt>{@code 2}
+     * <dt>{@code zip}
+     * <dd>zip compression
+     * </dl>
+     */
+    public static class CompressionLevel
+    extends EnumeratedAttribute {
+        private static final Map<String, String> KEYWORDS;
+
+        static {
+            Map<String, String> map = new LinkedHashMap<>();
+            map.put("0", "0");
+            map.put("1", "1");
+            map.put("2", "2");
+            map.put("none", "0");
+            map.put("strings", "1");
+            map.put("zip", "2");
+
+            KEYWORDS = Collections.unmodifiableMap(map);
+        }
+
+        @Override
+        public String[] getValues() {
+            return KEYWORDS.keySet().toArray(new String[0]);
+        }
+
+        /**
+         * Converts this value to a string suitable for use in a
+         * jlink command.
+         *
+         * @return jlink keyword corresponding to this value
+         */
+        String toCommandLineOption() {
+            return KEYWORDS.get(getValue());
+        }
+    }
+
+    /**
+     * Child element fully describing compression of a linked image.
+     * This includes the level, and optionally, the names of files to compress.
+     */
+    public class Compression {
+        /** Compression level.  Required attribute. */
+        private CompressionLevel level;
+
+        /**
+         * Patterns specifying files to compress.  If empty, all files are
+         * compressed.
+         */
+        private final List<PatternListEntry> patterns = new ArrayList<>();
+
+        /**
+         * Required attribute containing level of compression.
+         *
+         * @return compression level
+         */
+        public CompressionLevel getLevel() {
+            return level;
+        }
+
+        /**
+         * Sets attribute indicating level of compression.
+         *
+         * @param level type of compression to apply to linked image
+         */
+        public void setLevel(final CompressionLevel level) {
+            this.level = level;
+        }
+
+        /**
+         * Creates a nested element which can specify a pattern of files
+         * to compress.
+         *
+         * @return new, unconfigured child element
+         */
+        public PatternListEntry createFiles() {
+            PatternListEntry pattern = new PatternListEntry();
+            patterns.add(pattern);
+            return pattern;
+        }
+
+        /**
+         * Sets an attribute that represents a list of file patterns to
+         * compress in the linked image, as a comma-separated list of
+         * PathMatcher patterns or pattern list files.
+         *
+         * @param patternList comma-separated list of patterns and/or file names
+         *
+         * @see Link.PatternListEntry
+         */
+        public void setFiles(final String patternList) {
+            patterns.clear();
+            for (String pattern : patternList.split(",")) {
+                patterns.add(new PatternListEntry(pattern));
+            }
+        }
+
+        /**
+         * Verifies this element's state.
+         *
+         * @throws BuildException if compression level is not set
+         * @throws BuildException if any nested patterns are invalid
+         */
+        public void validate() {
+            if (level == null) {
+                throw new BuildException("Compression level must be specified.",
+                     getLocation());
+            }
+            patterns.forEach(PatternListEntry::validate);
+        }
+
+        /**
+         * Converts this element to a single jlink option value.
+         *
+         * @return command line option representing this element's state
+         */
+        public String toCommandLineOption() {
+            StringBuilder option =
+                new StringBuilder(level.toCommandLineOption());
+
+            if (!patterns.isEmpty()) {
+                String separator = ":filter=";
+                for (PatternListEntry entry : patterns) {
+                    option.append(separator).append(entry.toOptionValue());
+                    separator = ",";
+                }
+            }
+
+            return option.toString();
+        }
+    }
+
+    /**
+     * Grandchild element representing deletable key in a linked image's
+     * release properties.
+     */
+    public class ReleaseInfoKey {
+        /** Required attribute holding property key to delete. */
+        private String key;
+
+        /** Creates a new, unconfigured element. */
+        public ReleaseInfoKey() {
+            // Deliberately empty.
+        }
+
+        /**
+         * Creates a new element with the specified key.
+         *
+         * @param key property key to delete from release info
+         */
+        public ReleaseInfoKey(final String key) {
+            setKey(key);
+        }
+
+        /**
+         * Attribute holding the release info property key to delete.
+         *
+         * @return property key to be deleted
+         */
+        public String getKey() {
+            return key;
+        }
+
+        /**
+         * Sets attribute containing property key to delete from
+         * linked image's release info.
+         *
+         * @param key propert key to be deleted
+         */
+        public void setKey(final String key) {
+            this.key = key;
+        }
+
+        /**
+         * Verifies this element's state is valid.
+         *
+         * @throws BuildException if key is not set
+         */
+        public void validate() {
+            if (key == null) {
+                throw new BuildException(
+                    "Release info key must define a 'key' attribute.",
+                    getLocation());
+            }
+        }
+    }
+
+    /**
+     * Grandchild element describing additional release info properties for a
+     * linked image.  To be valid, an instance must have either a non-null
+     * key and value, or a non-null file.
+     */
+    public class ReleaseInfoEntry {
+        /** New release property's key. */
+        private String key;
+
+        /** New release property's value. */
+        private String value;
+
+        /** File containing additional release properties. */
+        private File file;
+
+        /** Charset of {@link #file}. */
+        private String charset = StandardCharsets.ISO_8859_1.name();
+
+        /** Creates a new, unconfigured element. */
+        public ReleaseInfoEntry() {
+            // Deliberately empty.
+        }
+
+        /**
+         * Creates a new element which specifies a single additional property.
+         *
+         * @param key new property's key
+         * @param value new property's value
+         */
+        public ReleaseInfoEntry(final String key,
+                                final String value) {
+            setKey(key);
+            setValue(value);
+        }
+
+        /**
+         * Attribute containing the key of this element's additional property.
+         *
+         * @return additional property's key
+         *
+         * @see #getValue()
+         */
+        public String getKey() {
+            return key;
+        }
+
+        /**
+         * Sets attribute containing the key of this element's
+         * additional property.
+         *
+         * @param key additional property's key
+         *
+         * @see #setValue(String)
+         */
+        public void setKey(final String key) {
+            this.key = key;
+        }
+
+        /**
+         * Attribute containing the value of this element's additional property.
+         *
+         * @return additional property's value
+         *
+         * @see #getKey()
+         */
+        public String getValue() {
+            return value;
+        }
+
+        /**
+         * Sets attributes containing the value of this element's
+         * additional property.
+         *
+         * @param value additional property's value
+         *
+         * @see #setKey(String)
+         */
+        public void setValue(final String value) {
+            this.value = value;
+        }
+
+        /**
+         * Attribute containing a Java properties file which contains
+         * additional release info properties.  This is exclusive with
+         * respect to the {@linkplain #getKey() key} and
+         * {@linkplain #getValue() value} of this instance:  either the
+         * file must be set, or the key and value must be set.
+         *
+         * @return this element's properties file
+         */
+        public File getFile() {
+            return file;
+        }
+
+        /**
+         * Sets attribute containing a Java properties file which contains
+         * additional release info properties.  This is exclusive with
+         * respect to the {@linkplain #setKey(String) key} and
+         * {@linkplain #setValue(String) value} of this instance:  either the
+         * file must be set, or the key and value must be set.
+         *
+         * @param file this element's properties file
+         */
+        public void setFile(final File file) {
+            this.file = file;
+        }
+
+        /**
+         * Attribute containing the character set of this object's
+         * {@linkplain #getFile() file}.  This is {@code ISO_8859_1}
+         * by default, in accordance with the java.util.Properties default.
+         *
+         * @return character set of this element's file
+         */
+        public String getCharset() {
+            return charset;
+        }
+
+        /**
+         * Sets attribute containing the character set of this object's
+         * {@linkplain #setFile(File) file}.  If not set, this is
+         * {@code ISO_8859_1} by default, in accordance with the
+         * java.util.Properties default.
+         *
+         * @param charset character set of this element's file
+         */
+        public void setCharset(final String charset) {
+            this.charset = charset;
+        }
+
+        /**
+         * Verifies the state of this element.
+         *
+         * @throws BuildException if file is set, and key and/or value are set
+         * @throws BuildException if file is not set, and key and value are not both set
+         * @throws BuildException if charset is not a valid Java Charset name
+         */
+        public void validate() {
+            if (file == null && (key == null || value == null)) {
+                throw new BuildException(
+                    "Release info must define 'key' and 'value' attributes, "
+                    + "or a 'file' attribute.", getLocation());
+            }
+            if (file != null && (key != null || value != null)) {
+                throw new BuildException(
+                    "Release info cannot define both a file attribute and "
+                    + "key/value attributes.", getLocation());
+            }
+
+            // This can't happen from a build file, but can theoretically
+            // happen if called from Java code.
+            if (charset == null) {
+                throw new BuildException("Charset cannot be null.",
+                    getLocation());
+            }
+
+            try {
+                Charset.forName(charset);
+            } catch (IllegalArgumentException e) {
+                throw new BuildException(e, getLocation());
+            }
+        }
+
+        /**
+         * Converts this element to a Java properties object containing
+         * the additional properties this element represents.  If this
+         * element's file is set, it is read;  otherwise, a Properties
+         * object containing just one property, consisting of this element's
+         * key and value, is returned.
+         *
+         * @return new Properties object obtained from this element's file or
+         *         its key and value
+         *
+         * @throws BuildException if file is set, but cannot be read
+         */
+        public Properties toProperties() {
+            Properties props = new Properties();
+            if (file != null) {
+                try (Reader reader = Files.newBufferedReader(
+                    file.toPath(), Charset.forName(charset))) {
+
+                    props.load(reader);
+                } catch (IOException e) {
+                    throw new BuildException(
+                        "Cannot read release info file \"" + file + "\": " + e,
+                        e, getLocation());
+                }
+            } else {
+                props.setProperty(key, value);
+            }
+
+            return props;
+        }
+    }
+
+    /**
+     * Child element describing changes to the default release properties
+     * of a linked image.
+     */
+    public class ReleaseInfo {
+        /**
+         * File that contains replacement release properties for linked image.
+         */
+        private File file;
+
+        /**
+         * Properties to add to default release properties of linked image.
+         */
+        private final List<ReleaseInfoEntry> propertiesToAdd = new ArrayList<>();
+
+        /**
+         * Property keys to remove from release properties of linked image.
+         */
+        private final List<ReleaseInfoKey> propertiesToDelete = new ArrayList<>();
+
+        /**
+         * Attribute specifying Java properties file which will replace the
+         * default release info properties for the linked image.
+         *
+         * @return release properties file
+         */
+        public File getFile() {
+            return file;
+        }
+
+        /**
+         * Sets attribute specifying Java properties file which will replace
+         * the default release info properties for the linked image.
+         *
+         * @param file replacement release properties file
+         */
+        public void setFile(final File file) {
+            this.file = file;
+        }
+
+        /**
+         * Creates an uninitialized child element which can represent properties
+         * to add to the default release properties of a linked image.
+         *
+         * @return new, unconfigured child element
+         */
+        public ReleaseInfoEntry createAdd() {
+            ReleaseInfoEntry property = new ReleaseInfoEntry();
+            propertiesToAdd.add(property);
+            return property;
+        }
+
+        /**
+         * Creates an uninitialized child element which can represent
+         * a property key to delete from the release properties of
+         * a linked image.
+         *
+         * @return new, unconfigured child element
+         */
+        public ReleaseInfoKey createDelete() {
+            ReleaseInfoKey key = new ReleaseInfoKey();
+            propertiesToDelete.add(key);
+            return key;
+        }
+
+        /**
+         * Sets attribute which contains a comma-separated list of
+         * property keys to delete from the release properties of
+         * a linked image.
+         *
+         * @param keyList comma-separated list of property keys
+         *
+         * @see #createDelete()
+         */
+        public void setDelete(final String keyList) {
+            for (String key : keyList.split(",")) {
+                propertiesToDelete.add(new ReleaseInfoKey(key));
+            }
+        }
+
+        /**
+         * Verifies the state of this element.
+         *
+         * @throws BuildException if any child element is invalid
+         *
+         * @see Link.ReleaseInfoEntry#validate()
+         * @see Link.ReleaseInfoKey#validate()
+         */
+        public void validate() {
+            propertiesToAdd.forEach(ReleaseInfoEntry::validate);
+            propertiesToDelete.forEach(ReleaseInfoKey::validate);
+        }
+
+        /**
+         * Converts all of this element's state to a series of
+         * <code>jlink</code> options.
+         *
+         * @return new collection of jlink options based on this element's
+         *         attributes and child elements
+         */
+        public Collection<String> toCommandLineOptions() {
+            Collection<String> options = new ArrayList<>();
+
+            if (file != null) {
+                options.add("--release-info=" + file);
+            }
+            if (!propertiesToAdd.isEmpty()) {
+                StringBuilder option = new StringBuilder("--release-info=add");
+
+                for (ReleaseInfoEntry entry : propertiesToAdd) {
+                    Properties props = entry.toProperties();
+                    for (String key : props.stringPropertyNames()) {
+                        option.append(":").append(key).append("=");
+                        option.append(props.getProperty(key));
+                    }
+                }
+
+                options.add(option.toString());
+            }
+            if (!propertiesToDelete.isEmpty()) {
+                StringBuilder option =
+                    new StringBuilder("--release-info=del:keys=");
+
+                String separator = "";
+                for (ReleaseInfoKey key : propertiesToDelete) {
+                    option.append(separator).append(key.getKey());
+                    // jlink docs aren't clear on whether property keys
+                    // to delete should be separated by commas or colons.
+                    separator = ",";
+                }
+
+                options.add(option.toString());
+            }
+
+            return options;
+        }
+    }
+
+    /**
+     * Invokes the jlink tool to create a new linked image, unless the
+     * output directory exists and all of its files are files are newer
+     * than all files in the module path.
+     *
+     * @throws BuildException if destDir is not set
+     * @throws BuildException if module path is unset or empty
+     * @throws BuildException if module list is empty
+     * @throws BuildException if compressionLevel attribute and compression
+     *                        child element are both specified
+     */
+    @Override
+    public void execute()
+    throws BuildException {
+        if (outputDir == null) {
+            throw new BuildException("Destination directory is required.",
+                getLocation());
+        }
+
+        if (modulePath == null || modulePath.isEmpty()) {
+            throw new BuildException("Module path is required.", getLocation());
+        }
+
+        if (modules.isEmpty()) {
+            throw new BuildException("At least one module must be specified.",
+                getLocation());
+        }
+
+        if (outputDir.exists()) {
+            CompositeMapper imageMapper = new CompositeMapper();
+            try (Stream<java.nio.file.Path> imageTree =
+                Files.walk(outputDir.toPath())) {
+
+                /*
+                 * Is this sufficient?  What if part of the image tree was
+                 * deleted or altered?  Should we check for standard
+                 * files and directories, like 'bin', 'lib', 'conf', 'legal',
+                 * and 'release'?  (Some, like 'include', may not be present,
+                 * if the image was previously built with options that
+                 * omitted them.)
+                 */
+                imageTree.forEach(
+                    p -> imageMapper.add(new MergingMapper(p.toString())));
+
+                ResourceCollection outOfDate =
+                    ResourceUtils.selectOutOfDateSources(this, modulePath,
+                        imageMapper, getProject(),
+                        FileUtils.getFileUtils().getFileTimestampGranularity());
+                if (outOfDate.isEmpty()) {
+                    log("Skipping image creation, since "
+                        + "\"" + outputDir + "\" is already newer than "
+                        + "all constituent modules.", Project.MSG_VERBOSE);
+                    return;
+                }
+            } catch (IOException e) {
+                throw new BuildException(
+                    "Could not scan \"" + outputDir + "\" "
+                    + "for being up-to-date: " + e, e, getLocation());
+            }
+        }
+
+        modules.forEach(ModuleSpec::validate);
+        observableModules.forEach(ModuleSpec::validate);
+        launchers.forEach(Launcher::validate);
+        locales.forEach(LocaleSpec::validate);
+        ordering.forEach(PatternListEntry::validate);
+        excludedFiles.forEach(PatternListEntry::validate);
+        excludedResources.forEach(PatternListEntry::validate);
+
+        Collection<String> args = buildJlinkArgs();
+
+        ToolProvider jlink = ToolProvider.findFirst("jlink").orElseThrow(
+            () -> new BuildException("jlink tool not found in JDK.",
+                getLocation()));
+
+        if (outputDir.exists()) {
+            log("Deleting existing " + outputDir, Project.MSG_VERBOSE);
+            deleteTree(outputDir.toPath());
+        }
+
+        log("Executing: jlink " + String.join(" ", args), Project.MSG_VERBOSE);
+
+        ByteArrayOutputStream stdout = new ByteArrayOutputStream();
+        ByteArrayOutputStream stderr = new ByteArrayOutputStream();
+
+        int exitCode;
+        try (PrintStream out = new PrintStream(stdout);
+             PrintStream err = new PrintStream(stderr)) {
+
+            exitCode = jlink.run(out, err, args.toArray(new String[0]));
+        }
+
+        if (exitCode != 0) {
+            StringBuilder message = new StringBuilder();
+            message.append("jlink failed (exit code ").append(exitCode).append(")");
+            if (stdout.size() > 0) {
+                message.append(", output is: ").append(stdout);
+            }
+            if (stderr.size() > 0) {
+                message.append(", error output is: ").append(stderr);
+            }
+
+            throw new BuildException(message.toString(), getLocation());
+        }
+
+        if (verboseLevel != null) {
+            int level = verboseLevel.getLevel();
+
+            if (stdout.size() > 0) {
+                log(stdout.toString(), level);
+            }
+            if (stderr.size() > 0) {
+                log(stderr.toString(), level);
+            }
+        }
+
+        log("Created " + outputDir.getAbsolutePath(), Project.MSG_INFO);
+    }
+
+    /**
+     * Recursively deletes a file tree.
+     *
+     * @param dir root of tree to delete
+     *
+     * @throws BuildException if deletion fails
+     */
+    private void deleteTree(java.nio.file.Path dir) {
+        try {
+            Files.walkFileTree(dir, new SimpleFileVisitor<java.nio.file.Path>() {
+                @Override
+                public FileVisitResult visitFile(final java.nio.file.Path file,
+                                                 final BasicFileAttributes attr)
+                throws IOException {
+                    Files.delete(file);
+                    return FileVisitResult.CONTINUE;
+                }
+
+                @Override
+                public FileVisitResult postVisitDirectory(final java.nio.file.Path dir,
+                                                          IOException e)
+                throws IOException {
+                    if (e == null) {
+                        Files.delete(dir);
+                    }
+                    return super.postVisitDirectory(dir, e);
+                }
+            });
+        } catch (IOException e) {
+            throw new BuildException(
+                "Could not delete \"" + dir + "\": " + e, e, getLocation());
+        }
+    }
+
+    /**
+     * Creates list of arguments to <code>jlink</code> tool, based on this
+     * instance's current state.
+     *
+     * @return new list of <code>jlink</code> arguments
+     *
+     * @throws BuildException if any inconsistencies attributes/elements
+     *                        is found
+     */
+    private Collection<String> buildJlinkArgs() {
+        Collection<String> args = new ArrayList<>();
+
+        args.add("--output");
+        args.add(outputDir.toString());
+
+        args.add("--module-path");
+        args.add(modulePath.toString());
+
+        args.add("--add-modules");
+        args.add(modules.stream().map(ModuleSpec::getName).collect(
+            Collectors.joining(",")));
+
+        if (!observableModules.isEmpty()) {
+            args.add("--limit-modules");
+            args.add(observableModules.stream().map(ModuleSpec::getName).collect(
+                Collectors.joining(",")));
+        }
+
+        if (!locales.isEmpty()) {
+            args.add("--include-locales="
+                + locales.stream().map(LocaleSpec::getName).collect(
+                    Collectors.joining(",")));
+        }
+
+        for (Launcher launcher : launchers) {
+            args.add("--launcher");
+            args.add(launcher.toString());
+        }
+
+        if (!ordering.isEmpty()) {
+            args.add("--order-resources="
+                + ordering.stream().map(PatternListEntry::toOptionValue).collect(
+                    Collectors.joining(",")));
+        }
+        if (!excludedFiles.isEmpty()) {
+            args.add("--exclude-files="
+                + excludedFiles.stream().map(PatternListEntry::toOptionValue).collect(
+                    Collectors.joining(",")));
+        }
+        if (!excludedResources.isEmpty()) {
+            args.add("--exclude-resources="
+                + excludedResources.stream().map(PatternListEntry::toOptionValue).collect(
+                    Collectors.joining(",")));
+        }
+
+        if (bindServices) {
+            args.add("--bind-services");
+        }
+        if (ignoreSigning) {
+            args.add("--ignore-signing-information");
+        }
+        if (!includeHeaders) {
+            args.add("--no-header-files");
+        }
+        if (!includeManPages) {
+            args.add("--no-man-pages");
+        }
+        if (!includeNativeCommands) {
+            args.add("--strip-native-commands");
+        }
+        if (!debug) {
+            args.add("--strip-debug");
+        }
+        if (verboseLevel != null) {
+            args.add("--verbose");
+        }
+
+        if (endianness != null) {
+            args.add("--endian");
+            args.add(endianness.getValue());
+        }
+
+        if (compressionLevel != null) {
+            if (compression != null) {
+                throw new BuildException("compressionLevel attribute "
+                    + "and <compression> child element cannot both be present.",
+                    getLocation());
+            }
+            args.add("--compress=" + compressionLevel.toCommandLineOption());
+        }
+        if (compression != null) {
+            compression.validate();
+            args.add("--compress=" + compression.toCommandLineOption());
+        }
+        if (vmType != null) {
+            args.add("--vm=" + vmType.getValue());
+        }
+        if (checkDuplicateLegal) {
+            args.add("--dedup-legal-notices=error-if-not-same-content");
+        }
+        for (ReleaseInfo info : releaseInfo) {
+            info.validate();
+            args.addAll(info.toCommandLineOptions());
+        }
+
+        return args;
+    }
+}
diff --git a/src/main/org/apache/tools/ant/taskdefs/modules/package-info.java b/src/main/org/apache/tools/ant/taskdefs/modules/package-info.java
new file mode 100644 (file)
index 0000000..99b11e5
--- /dev/null
@@ -0,0 +1,23 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one or more
+ *  contributor license agreements.  See the NOTICE file distributed with
+ *  this work for additional information regarding copyright ownership.
+ *  The ASF licenses this file to You under the Apache License, Version 2.0
+ *  (the "License"); you may not use this file except in compliance with
+ *  the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ *
+ */
+
+/**
+ * Tasks for dealing with Java modules, which are supported starting with
+ * Java 9.
+ */
+package org.apache.tools.ant.taskdefs.modules;
index db8b3a3..be27284 100644 (file)
@@ -25,8 +25,10 @@ import org.apache.tools.ant.taskdefs.MatchingTask;
 import org.apache.tools.ant.types.Path;
 
 /**
- * This class defines objects that can link together various jar and
- * zip files.
+ * This task defines objects that can link together various jar and
+ * zip files.  It is not related to the {@code jlink} tool present in
+ * Java 9 and later;  for that, see
+ * {@link org.apache.tools.ant.taskdefs.modules.Link}.
  *
  * <p>It is basically a wrapper for the jlink code written originally
  * by <a href="mailto:beard@netscape.com">Patrick Beard</a>.  The
diff --git a/src/main/org/apache/tools/ant/types/ModuleVersion.java b/src/main/org/apache/tools/ant/types/ModuleVersion.java
new file mode 100644 (file)
index 0000000..0a43420
--- /dev/null
@@ -0,0 +1,147 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one or more
+ *  contributor license agreements.  See the NOTICE file distributed with
+ *  this work for additional information regarding copyright ownership.
+ *  The ASF licenses this file to You under the Apache License, Version 2.0
+ *  (the "License"); you may not use this file except in compliance with
+ *  the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ *
+ */
+
+package org.apache.tools.ant.types;
+
+import java.util.Objects;
+
+/**
+ * Element describing the parts of a Java
+ * <a href="https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/lang/module/ModuleDescriptor.Version.html">module version</a>.
+ * The version number is required;  all other parts are optional.
+ */
+public class ModuleVersion {
+    /** Module version's required <em>version number</em>. */
+    private String number;
+
+    /** Module version's optional <em>pre-release version</em>. */
+    private String preRelease;
+
+    /** Module version's optional <em>build version</em>. */
+    private String build;
+
+    /**
+     * Returns this element's version number.
+     *
+     * @return version number
+     */
+    public String getNumber() {
+        return number;
+    }
+
+    /**
+     * Sets this element's required version number.  This cannot contain
+     * an ASCII hyphen ({@code -}) or plus ({@code +}), as those characters
+     * are used as delimiters in a complete module version string.
+     *
+     * @throws NullPointerException if argument is {@code null}
+     * @throws IllegalArgumentException if argument contains {@code '-'}
+     *                                  or {@code '+'}
+     */
+    public void setNumber(final String number) {
+        Objects.requireNonNull(number, "Version number cannot be null.");
+        if (number.indexOf('-') >= 0 || number.indexOf('+') >= 0) {
+            throw new IllegalArgumentException(
+                "Version number cannot contain '-' or '+'.");
+        }
+        this.number = number;
+    }
+
+    /**
+     * Returns this element's pre-release version, if set.
+     *
+     * @return pre-release value, or {@code null}
+     */
+    public String getPreRelease() {
+        return preRelease;
+    }
+
+    /**
+     * Sets this element's pre-release version.  This can be any value
+     * which doesn't contain an ASCII plus ({@code +}).
+     *
+     * @param pre pre-release version, or {@code null}
+     *
+     * @throws IllegalArgumentException if argument contains "{@code +}"
+     */
+    public void setPreRelease(final String pre) {
+        if (pre != null && pre.indexOf('+') >= 0) {
+            throw new IllegalArgumentException(
+                "Version's pre-release cannot contain '+'.");
+        }
+        this.preRelease = pre;
+    }
+
+    /**
+     * Returns this element's build version, if set.
+     *
+     * @return build value, or {@code null}
+     */
+    public String getBuild() {
+        return build;
+    }
+
+    /**
+     * Sets this element's build version.  This can be any value, including
+     * {@code null}.
+     *
+     * @param build build version, or {@code null}
+     */
+    public void setBuild(final String build) {
+        this.build = build;
+    }
+
+    /**
+     * Snapshots this element's state and converts it to a string compliant
+     * with {@code ModuleDescriptor.Version}.
+     *
+     * @return Java module version string built from this object's properties
+     *
+     * @throws IllegalStateException if {@linkplain #getNumber() number}
+     *                               is {@code null}
+     */
+    public String toModuleVersionString() {
+        if (number == null) {
+            throw new IllegalStateException("Version number cannot be null.");
+        }
+
+        StringBuilder version = new StringBuilder(number);
+        if (preRelease != null || build != null) {
+            version.append('-').append(Objects.toString(preRelease, ""));
+        }
+        if (build != null) {
+            version.append('+').append(build);
+        }
+
+        return version.toString();
+    }
+
+    /**
+     * Returns a summary of this object's state, suitable for debugging.
+     *
+     * @return string form of this instance
+     */
+    @Override
+    public String toString() {
+        return getClass().getName() +
+            "[number=" + number +
+            ", preRelease=" + preRelease +
+            ", build=" + build +
+            "]";
+    }
+}
diff --git a/src/tests/junit/org/apache/tools/ant/taskdefs/modules/JmodTest.java b/src/tests/junit/org/apache/tools/ant/taskdefs/modules/JmodTest.java
new file mode 100644 (file)
index 0000000..b8cecb9
--- /dev/null
@@ -0,0 +1,690 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one or more
+ *  contributor license agreements.  See the NOTICE file distributed with
+ *  this work for additional information regarding copyright ownership.
+ *  The ASF licenses this file to You under the Apache License, Version 2.0
+ *  (the "License"); you may not use this file except in compliance with
+ *  the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ *
+ */
+
+package org.apache.tools.ant.taskdefs.modules;
+
+import java.io.BufferedReader;
+import java.io.StringReader;
+
+import java.io.ByteArrayOutputStream;
+import java.io.PrintStream;
+
+import java.io.File;
+import java.io.IOException;
+
+import java.nio.file.Files;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+
+import java.util.function.Predicate;
+import java.util.regex.Pattern;
+
+import java.util.spi.ToolProvider;
+
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+import org.apache.tools.ant.BuildException;
+import org.apache.tools.ant.BuildFileRule;
+
+/**
+ * Tests the {@link Jmod} task.
+ */
+public class JmodTest {
+    @Rule
+    public final BuildFileRule buildRule = new BuildFileRule();
+
+    @Rule
+    public final ExpectedException expected = ExpectedException.none();
+
+    @Before
+    public void setUp() {
+        buildRule.configureProject("src/etc/testcases/taskdefs/jmod.xml");
+        buildRule.executeTarget("setUp");
+    }
+
+    @Test
+    public void testDestAndClasspathNoJmod() {
+        buildRule.executeTarget("destAndClasspathNoJmod");
+
+        File jmod = new File(buildRule.getProject().getProperty("jmod"));
+        Assert.assertTrue("Checking that jmod was successfully created.",
+            jmod.exists());
+    }
+
+    @Test
+    public void testDestAndNestedClasspath() {
+        buildRule.executeTarget("classpath-nested");
+
+        File jmod = new File(buildRule.getProject().getProperty("jmod"));
+        Assert.assertTrue("Checking that jmod was successfully created.",
+            jmod.exists());
+    }
+
+    @Test
+    public void testDestAndClasspathOlderThanJmod()
+    throws IOException {
+        buildRule.executeTarget("destAndClasspathOlderThanJmod");
+
+        File jmod = new File(buildRule.getProject().getProperty("jmod"));
+        File jar = new File(buildRule.getProject().getProperty("hello.jar"));
+        Assert.assertTrue("Checking that newer jmod was not written "
+            + "when source files are older.",
+            Files.getLastModifiedTime(jmod.toPath()).toInstant().isAfter(
+                Instant.now().plus(30, ChronoUnit.MINUTES)));
+    }
+
+    @Test
+    public void testNoDestFile() {
+        expected.expect(BuildException.class);
+        buildRule.executeTarget("noDestFile");
+    }
+
+    @Test
+    public void testNoClasspath() {
+        expected.expect(BuildException.class);
+        buildRule.executeTarget("noClasspath");
+    }
+
+    @Test
+    public void testEmptyClasspath() {
+        expected.expect(BuildException.class);
+        buildRule.executeTarget("emptyClasspath");
+    }
+
+    @Test
+    public void testClasspathEntirelyNonexistent() {
+        expected.expect(BuildException.class);
+        buildRule.executeTarget("nonexistentClasspath");
+    }
+
+    @Test
+    public void testClasspathref() {
+        buildRule.executeTarget("classpathref");
+
+        File jmod = new File(buildRule.getProject().getProperty("jmod"));
+        Assert.assertTrue("Checking that jmod was successfully created.",
+            jmod.exists());
+    }
+
+    @Test
+    public void testClasspathAttributeAndChildElement() {
+        buildRule.executeTarget("classpath-both");
+
+        File jmod = new File(buildRule.getProject().getProperty("jmod"));
+        Assert.assertTrue("Checking that jmod was successfully created.",
+            jmod.exists());
+    }
+
+    @Test
+    public void testModulepath() {
+        buildRule.executeTarget("modulepath");
+
+        File jmod = new File(buildRule.getProject().getProperty("jmod"));
+        Assert.assertTrue("Checking that jmod was successfully created.",
+            jmod.exists());
+    }
+
+    @Test
+    public void testModulepathref() {
+        buildRule.executeTarget("modulepathref");
+
+        File jmod = new File(buildRule.getProject().getProperty("jmod"));
+        Assert.assertTrue("Checking that jmod was successfully created.",
+            jmod.exists());
+    }
+
+    @Test
+    public void testModulepathNested() {
+        buildRule.executeTarget("modulepath-nested");
+
+        File jmod = new File(buildRule.getProject().getProperty("jmod"));
+        Assert.assertTrue("Checking that jmod was successfully created.",
+            jmod.exists());
+    }
+
+    @Test
+    public void testModulepathNonDir() {
+        expected.expect(BuildException.class);
+        buildRule.executeTarget("modulepathnondir");
+    }
+
+    @Test
+    public void testModulepathAttributeAndChildElement() {
+        buildRule.executeTarget("modulepath-both");
+
+        File jmod = new File(buildRule.getProject().getProperty("jmod"));
+        Assert.assertTrue("Checking that jmod was successfully created.",
+            jmod.exists());
+    }
+
+    @Test
+    public void testCommandPath() {
+        buildRule.executeTarget("commandpath");
+
+        File jmod = new File(buildRule.getProject().getProperty("jmod"));
+        Assert.assertTrue("Checking that jmod was successfully created.",
+            jmod.exists());
+
+        String output = runJmod("list", jmod.toString());
+        Assert.assertTrue("Checking that jmod contains command.",
+            containsLine(output, l -> l.equals("bin/command1")));
+    }
+
+    @Test
+    public void testCommandPathref() {
+        buildRule.executeTarget("commandpathref");
+
+        File jmod = new File(buildRule.getProject().getProperty("jmod"));
+        Assert.assertTrue("Checking that jmod was successfully created.",
+            jmod.exists());
+
+        String output = runJmod("list", jmod.toString());
+        Assert.assertTrue("Checking that jmod contains command.",
+            containsLine(output, l -> l.equals("bin/command2")));
+    }
+
+    @Test
+    public void testCommandPathNested() {
+        buildRule.executeTarget("commandpath-nested");
+
+        File jmod = new File(buildRule.getProject().getProperty("jmod"));
+        Assert.assertTrue("Checking that jmod was successfully created.",
+            jmod.exists());
+
+        String output = runJmod("list", jmod.toString());
+        Assert.assertTrue("Checking that jmod contains command.",
+            containsLine(output, l -> l.equals("bin/command3")));
+    }
+
+    @Test
+    public void testCommandPathAttributeAndChildElement() {
+        buildRule.executeTarget("commandpath-both");
+
+        File jmod = new File(buildRule.getProject().getProperty("jmod"));
+        Assert.assertTrue("Checking that jmod was successfully created.",
+            jmod.exists());
+
+        String output = runJmod("list", jmod.toString());
+        Assert.assertTrue("Checking that jmod contains commands "
+            + "from both attribute and child element.",
+            containsAll(output,
+                l -> l.equals("bin/command4"),
+                l -> l.equals("bin/command5")));
+    }
+
+    @Test
+    public void testHeaderPath() {
+        buildRule.executeTarget("headerpath");
+
+        File jmod = new File(buildRule.getProject().getProperty("jmod"));
+        Assert.assertTrue("Checking that jmod was successfully created.",
+            jmod.exists());
+
+        String output = runJmod("list", jmod.toString());
+        Assert.assertTrue("Checking that jmod contains header file.",
+            containsLine(output, l -> l.equals("include/header1.h")));
+    }
+
+    @Test
+    public void testHeaderPathref() {
+        buildRule.executeTarget("headerpathref");
+
+        File jmod = new File(buildRule.getProject().getProperty("jmod"));
+        Assert.assertTrue("Checking that jmod was successfully created.",
+            jmod.exists());
+
+        String output = runJmod("list", jmod.toString());
+        Assert.assertTrue("Checking that jmod contains header file.",
+            containsLine(output, l -> l.equals("include/header2.h")));
+    }
+
+    @Test
+    public void testHeaderPathNested() {
+        buildRule.executeTarget("headerpath-nested");
+
+        File jmod = new File(buildRule.getProject().getProperty("jmod"));
+        Assert.assertTrue("Checking that jmod was successfully created.",
+            jmod.exists());
+
+        String output = runJmod("list", jmod.toString());
+        Assert.assertTrue("Checking that jmod contains header file.",
+            containsLine(output, l -> l.equals("include/header3.h")));
+    }
+
+    @Test
+    public void testHeaderPathAttributeAndChildElement() {
+        buildRule.executeTarget("headerpath-both");
+
+        File jmod = new File(buildRule.getProject().getProperty("jmod"));
+        Assert.assertTrue("Checking that jmod was successfully created.",
+            jmod.exists());
+
+        String output = runJmod("list", jmod.toString());
+        Assert.assertTrue("Checking that jmod contains header files "
+            + "from both attribute and child element.",
+            containsAll(output,
+                l -> l.equals("include/header4.h"),
+                l -> l.equals("include/header5.h")));
+    }
+
+    @Test
+    public void testConfigPath() {
+        buildRule.executeTarget("configpath");
+
+        File jmod = new File(buildRule.getProject().getProperty("jmod"));
+        Assert.assertTrue("Checking that jmod was successfully created.",
+            jmod.exists());
+
+        String output = runJmod("list", jmod.toString());
+        Assert.assertTrue("Checking that jmod contains config file.",
+            containsLine(output, l -> l.equals("conf/config1.properties")));
+    }
+
+    @Test
+    public void testConfigPathref() {
+        buildRule.executeTarget("configpathref");
+
+        File jmod = new File(buildRule.getProject().getProperty("jmod"));
+        Assert.assertTrue("Checking that jmod was successfully created.",
+            jmod.exists());
+
+        String output = runJmod("list", jmod.toString());
+        Assert.assertTrue("Checking that jmod contains config file.",
+            containsLine(output, l -> l.equals("conf/config2.properties")));
+    }
+
+    @Test
+    public void testConfigPathNested() {
+        buildRule.executeTarget("configpath-nested");
+
+        File jmod = new File(buildRule.getProject().getProperty("jmod"));
+        Assert.assertTrue("Checking that jmod was successfully created.",
+            jmod.exists());
+
+        String output = runJmod("list", jmod.toString());
+        Assert.assertTrue("Checking that jmod contains config file.",
+            containsLine(output, l -> l.equals("conf/config3.properties")));
+    }
+
+    @Test
+    public void testConfigPathAttributeAndChildElement() {
+        buildRule.executeTarget("configpath-both");
+
+        File jmod = new File(buildRule.getProject().getProperty("jmod"));
+        Assert.assertTrue("Checking that jmod was successfully created.",
+            jmod.exists());
+
+        String output = runJmod("list", jmod.toString());
+        Assert.assertTrue("Checking that jmod contains config files "
+            + "from both attribute and child element.",
+            containsAll(output,
+                l -> l.equals("conf/config4.properties"),
+                l -> l.equals("conf/config5.properties")));
+    }
+
+    @Test
+    public void testLegalPath() {
+        buildRule.executeTarget("legalpath");
+
+        File jmod = new File(buildRule.getProject().getProperty("jmod"));
+        Assert.assertTrue("Checking that jmod was successfully created.",
+            jmod.exists());
+
+        String output = runJmod("list", jmod.toString());
+        Assert.assertTrue("Checking that jmod contains license file.",
+            containsLine(output, l -> l.equals("legal/legal1.txt")));
+    }
+
+    @Test
+    public void testLegalPathref() {
+        buildRule.executeTarget("legalpathref");
+
+        File jmod = new File(buildRule.getProject().getProperty("jmod"));
+        Assert.assertTrue("Checking that jmod was successfully created.",
+            jmod.exists());
+
+        String output = runJmod("list", jmod.toString());
+        Assert.assertTrue("Checking that jmod contains license file.",
+            containsLine(output, l -> l.equals("legal/legal2.txt")));
+    }
+
+    @Test
+    public void testLegalPathNested() {
+        buildRule.executeTarget("legalpath-nested");
+
+        File jmod = new File(buildRule.getProject().getProperty("jmod"));
+        Assert.assertTrue("Checking that jmod was successfully created.",
+            jmod.exists());
+
+        String output = runJmod("list", jmod.toString());
+        Assert.assertTrue("Checking that jmod contains license file.",
+            containsLine(output, l -> l.equals("legal/legal3.txt")));
+    }
+
+    @Test
+    public void testLegalPathAttributeAndChildElement() {
+        buildRule.executeTarget("legalpath-both");
+
+        File jmod = new File(buildRule.getProject().getProperty("jmod"));
+        Assert.assertTrue("Checking that jmod was successfully created.",
+            jmod.exists());
+
+        String output = runJmod("list", jmod.toString());
+        Assert.assertTrue("Checking that jmod contains legal files "
+            + "from both attribute and child element.",
+            containsAll(output,
+                l -> l.equals("legal/legal4.txt"),
+                l -> l.equals("legal/legal5.txt")));
+    }
+
+    @Test
+    public void testManPath() {
+        buildRule.executeTarget("manpath");
+
+        File jmod = new File(buildRule.getProject().getProperty("jmod"));
+        Assert.assertTrue("Checking that jmod was successfully created.",
+            jmod.exists());
+
+        String output = runJmod("list", jmod.toString());
+        Assert.assertTrue("Checking that jmod contains man page.",
+            containsLine(output, l -> l.equals("man/man1.1")));
+    }
+
+    @Test
+    public void testManPathref() {
+        buildRule.executeTarget("manpathref");
+
+        File jmod = new File(buildRule.getProject().getProperty("jmod"));
+        Assert.assertTrue("Checking that jmod was successfully created.",
+            jmod.exists());
+
+        String output = runJmod("list", jmod.toString());
+        Assert.assertTrue("Checking that jmod contains man page.",
+            containsLine(output, l -> l.equals("man/man2.1")));
+    }
+
+    @Test
+    public void testManPathNested() {
+        buildRule.executeTarget("manpath-nested");
+
+        File jmod = new File(buildRule.getProject().getProperty("jmod"));
+        Assert.assertTrue("Checking that jmod was successfully created.",
+            jmod.exists());
+
+        String output = runJmod("list", jmod.toString());
+        Assert.assertTrue("Checking that jmod contains man page.",
+            containsLine(output, l -> l.equals("man/man3.1")));
+    }
+
+    @Test
+    public void testManPathAttributeAndChildElement() {
+        buildRule.executeTarget("manpath-both");
+
+        File jmod = new File(buildRule.getProject().getProperty("jmod"));
+        Assert.assertTrue("Checking that jmod was successfully created.",
+            jmod.exists());
+
+        String output = runJmod("list", jmod.toString());
+        Assert.assertTrue("Checking that jmod contains man pages "
+            + "from both attribute and child element.",
+            containsAll(output,
+                l -> l.equals("man/man4.1"),
+                l -> l.equals("man/man5.1")));
+    }
+
+    @Test
+    public void testNativeLibPath() {
+        buildRule.executeTarget("nativelibpath");
+
+        File jmod = new File(buildRule.getProject().getProperty("jmod"));
+        Assert.assertTrue("Checking that jmod was successfully created.",
+            jmod.exists());
+
+        String output = runJmod("list", jmod.toString());
+        Assert.assertTrue("Checking that jmod contains native library.",
+            containsLine(output, l -> l.matches("lib/[^/]+\\.(dll|dylib|so)")));
+    }
+
+    @Test
+    public void testNativeLibPathref() {
+        buildRule.executeTarget("nativelibpathref");
+
+        File jmod = new File(buildRule.getProject().getProperty("jmod"));
+        Assert.assertTrue("Checking that jmod was successfully created.",
+            jmod.exists());
+
+        String output = runJmod("list", jmod.toString());
+        Assert.assertTrue("Checking that jmod contains native library.",
+            containsLine(output, l -> l.matches("lib/[^/]+\\.(dll|dylib|so)")));
+    }
+
+    @Test
+    public void testNativeLibPathNested() {
+        buildRule.executeTarget("nativelibpath-nested");
+
+        File jmod = new File(buildRule.getProject().getProperty("jmod"));
+        Assert.assertTrue("Checking that jmod was successfully created.",
+            jmod.exists());
+
+        String output = runJmod("list", jmod.toString());
+        Assert.assertTrue("Checking that jmod contains native library.",
+            containsLine(output, l -> l.matches("lib/[^/]+\\.(dll|dylib|so)")));
+    }
+
+    @Test
+    public void testNativeLibPathAttributeAndChildElement() {
+        buildRule.executeTarget("nativelibpath-both");
+
+        File jmod = new File(buildRule.getProject().getProperty("jmod"));
+        Assert.assertTrue("Checking that jmod was successfully created.",
+            jmod.exists());
+
+        String output = runJmod("list", jmod.toString());
+        Assert.assertTrue("Checking that jmod contains native libraries "
+            + "from both attribute and child element.",
+            containsAll(output,
+                l -> l.matches("lib/(lib)?zip\\.(dll|dylib|so)"),
+                l -> l.matches("lib/(lib)?jvm\\.(dll|dylib|so)")));
+    }
+
+    @Test
+    public void testVersion() {
+        buildRule.executeTarget("version");
+
+        File jmod = new File(buildRule.getProject().getProperty("jmod"));
+        Assert.assertTrue("Checking that jmod was successfully created.",
+            jmod.exists());
+
+        String version = buildRule.getProject().getProperty("version");
+        Assert.assertNotNull("Checking that 'version' property is set",
+            version);
+        Assert.assertFalse("Checking that 'version' property is not empty",
+            version.isEmpty());
+
+        String output = runJmod("describe", jmod.toString());
+        Assert.assertTrue("Checking that jmod has correct version.",
+            containsLine(output, l -> l.endsWith("@" + version)));
+    }
+
+    @Test
+    public void testNestedVersion() {
+        buildRule.executeTarget("version-nested");
+
+        File jmod = new File(buildRule.getProject().getProperty("jmod"));
+        Assert.assertTrue("Checking that jmod was successfully created.",
+            jmod.exists());
+
+        String output = runJmod("describe", jmod.toString());
+        Assert.assertTrue("Checking that jmod has correct version.",
+            containsLine(output, l -> l.matches(".*@1\\.0\\.1[-+]+99")));
+    }
+
+    @Test
+    public void testNestedVersionNumberOnly() {
+        buildRule.executeTarget("version-nested-number");
+
+        File jmod = new File(buildRule.getProject().getProperty("jmod"));
+        Assert.assertTrue("Checking that jmod was successfully created.",
+            jmod.exists());
+
+        String output = runJmod("describe", jmod.toString());
+        Assert.assertTrue("Checking that jmod has correct version.",
+            containsLine(output, l -> l.endsWith("@1.0.1")));
+    }
+
+    @Test
+    public void testNestedVersionNoNumber() {
+        expected.expect(BuildException.class);
+        buildRule.executeTarget("version-nested-no-number");
+    }
+
+    @Test
+    public void testNestedVersionInvalidNumber() {
+        expected.expect(BuildException.class);
+        buildRule.executeTarget("version-nested-invalid-number");
+    }
+
+    @Test
+    public void testNestedVersionInvalidPreRelease() {
+        expected.expect(BuildException.class);
+        buildRule.executeTarget("version-nested-invalid-prerelease");
+    }
+
+    @Test
+    public void testVersionAttributeAndChildElement() {
+        expected.expect(BuildException.class);
+        buildRule.executeTarget("version-both");
+    }
+
+    @Test
+    public void testMainClass() {
+        buildRule.executeTarget("mainclass");
+
+        File jmod = new File(buildRule.getProject().getProperty("jmod"));
+        Assert.assertTrue("Checking that jmod was successfully created.",
+            jmod.exists());
+
+        String mainClass =
+            buildRule.getProject().getProperty("hello.main-class");
+        Assert.assertNotNull("Checking that 'main-class' property is set",
+            mainClass);
+        Assert.assertFalse("Checking that 'main-class' property is not empty",
+            mainClass.isEmpty());
+
+        String output = runJmod("describe", jmod.toString());
+
+        String mainClassPattern = "main-class\\s+" + Pattern.quote(mainClass);
+        Assert.assertTrue("Checking that jmod has correct main class.",
+            containsLine(output, l -> l.matches(mainClassPattern)));
+    }
+
+    @Test
+    public void testPlatform() {
+        buildRule.executeTarget("platform");
+
+        File jmod = new File(buildRule.getProject().getProperty("jmod"));
+        Assert.assertTrue("Checking that jmod was successfully created.",
+            jmod.exists());
+
+        String platform = buildRule.getProject().getProperty("target-platform");
+        Assert.assertNotNull("Checking that 'target-platform' property is set",
+            platform);
+        Assert.assertFalse("Checking that 'target-platform' property "
+            + "is not empty", platform.isEmpty());
+
+        String output = runJmod("describe", jmod.toString());
+
+        String platformPattern = "platform\\s+" + Pattern.quote(platform);
+        Assert.assertTrue("Checking that jmod has correct main class.",
+            containsLine(output, l -> l.matches(platformPattern)));
+    }
+
+    @Test
+    public void testHashing() {
+        buildRule.executeTarget("hashing");
+
+        File jmod = new File(buildRule.getProject().getProperty("jmod"));
+        Assert.assertTrue("Checking that jmod was successfully created.",
+            jmod.exists());
+
+        String output = runJmod("describe", jmod.toString());
+
+        Assert.assertTrue("Checking that jmod has module hashes.",
+            containsLine(output, l -> l.startsWith("hashes")));
+    }
+
+    private String runJmod(final String... args) {
+        ToolProvider jmod = ToolProvider.findFirst("jmod").orElseThrow(
+            () -> new RuntimeException("jmod tool not found in JDK."));
+
+        ByteArrayOutputStream stdout = new ByteArrayOutputStream();
+        ByteArrayOutputStream stderr = new ByteArrayOutputStream();
+
+        int exitCode;
+        try (PrintStream out = new PrintStream(stdout);
+             PrintStream err = new PrintStream(stderr)) {
+
+            exitCode = jmod.run(out, err, args);
+        }
+
+        if (exitCode != 0) {
+            throw new RuntimeException(
+                "jmod failed, output is: " + stdout + ", error is: " + stderr);
+        }
+
+        return stdout.toString();
+    }
+
+    private boolean containsLine(final String lines,
+                                 final Predicate<? super String> test) {
+        try (BufferedReader reader =
+            new BufferedReader(new StringReader(lines))) {
+
+            return reader.lines().anyMatch(test);
+        } catch (IOException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    private boolean containsAll(final String lines,
+                                final Predicate<? super String> test1,
+                                final Predicate<? super String> test2) {
+
+        try (BufferedReader reader =
+            new BufferedReader(new StringReader(lines))) {
+
+            boolean test1Matched = false;
+            boolean test2Matched = false;
+
+            String line;
+            while ((line = reader.readLine()) != null) {
+                test1Matched |= test1.test(line);
+                test2Matched |= test2.test(line);
+            }
+
+            return test1Matched && test2Matched;
+        } catch (IOException e) {
+            throw new RuntimeException(e);
+        }
+    }
+}
diff --git a/src/tests/junit/org/apache/tools/ant/taskdefs/modules/LinkTest.java b/src/tests/junit/org/apache/tools/ant/taskdefs/modules/LinkTest.java
new file mode 100644 (file)
index 0000000..9d89b77
--- /dev/null
@@ -0,0 +1,984 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one or more
+ *  contributor license agreements.  See the NOTICE file distributed with
+ *  this work for additional information regarding copyright ownership.
+ *  The ASF licenses this file to You under the Apache License, Version 2.0
+ *  (the "License"); you may not use this file except in compliance with
+ *  the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ *
+ */
+
+package org.apache.tools.ant.taskdefs.modules;
+
+import java.io.ByteArrayOutputStream;
+import java.io.PrintStream;
+
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.io.FileReader;
+import java.io.StringReader;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+
+import java.util.Collection;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import java.util.spi.ToolProvider;
+
+import org.junit.Assert;
+import org.junit.Assume;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+import org.apache.tools.ant.BuildException;
+import org.apache.tools.ant.BuildFileRule;
+
+/**
+ * Tests the {@link Link} task.
+ */
+public class LinkTest {
+    /*
+     * TODO:
+     * Test --order-resources (how?)
+     * Test --exclude-files (what does this actually do?)
+     * Test --endian (how?)
+     * Test --vm (how?)
+     */
+
+    @Rule
+    public final BuildFileRule buildRule = new BuildFileRule();
+
+    @Rule
+    public final ExpectedException expected = ExpectedException.none();
+
+    @Before
+    public void setUp() {
+        buildRule.configureProject("src/etc/testcases/taskdefs/link.xml");
+        buildRule.executeTarget("setUp");
+    }
+
+    private static boolean isWindows() {
+        return System.getProperty("os.name").contains("Windows");
+    }
+
+    private static boolean isEarlierThan(final Instant time,
+                                         final Path path) {
+        try {
+            return Files.getLastModifiedTime(path).toInstant().isBefore(time);
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+    }
+
+    private static class ImageStructure {
+        final File root;
+        final File bin;
+        final File java;
+
+        ImageStructure(final File root) {
+            this.root = root;
+
+            bin = new File(root, "bin");
+            java = new File(bin, isWindows() ? "java.exe" : "java");
+        }
+    }
+
+    private ImageStructure verifyImageBuiltNormally() {
+        ImageStructure image = new ImageStructure(
+            new File(buildRule.getProject().getProperty("image")));
+
+        Assert.assertTrue("Checking that image was successfully created.",
+            image.root.exists());
+
+        Assert.assertTrue("Checking that image has java executable.",
+            image.java.exists());
+
+        return image;
+    }
+
+    @Test
+    public void testModulepath() {
+        buildRule.executeTarget("modulepath");
+        verifyImageBuiltNormally();
+    }
+
+    @Test
+    public void testImageNotRecreatedFromStaleJmods()
+    throws IOException {
+        buildRule.executeTarget("imageNewerThanJmods");
+        ImageStructure image = verifyImageBuiltNormally();
+
+        Instant future = Instant.now().plus(30, ChronoUnit.MINUTES);
+        try (Stream<Path> imageFiles = Files.walk(image.root.toPath())) {
+
+            Assert.assertTrue("Checking that newer image was not written "
+                + "when source files are older.",
+                imageFiles.noneMatch(i -> isEarlierThan(future, i)));
+        }
+    }
+
+    @Test
+    public void testNoModulePath() {
+        expected.expect(BuildException.class);
+        buildRule.executeTarget("nomodulepath");
+    }
+
+    @Test
+    public void testNoModules() {
+        expected.expect(BuildException.class);
+        buildRule.executeTarget("nomodules");
+    }
+
+    @Test
+    public void testModulePathRef() {
+        buildRule.executeTarget("modulepathref");
+        verifyImageBuiltNormally();
+    }
+
+    @Test
+    public void testNestedModulePath() {
+        buildRule.executeTarget("modulepath-nested");
+        verifyImageBuiltNormally();
+    }
+
+    @Test
+    public void testModulePathInAttributeAndNested() {
+        buildRule.executeTarget("modulepath-both");
+        verifyImageBuiltNormally();
+    }
+
+    @Test
+    public void testNestedModules()
+    throws IOException,
+           InterruptedException {
+
+        buildRule.executeTarget("modules-nested");
+
+        ImageStructure image = verifyImageBuiltNormally();
+
+        ProcessBuilder builder = new ProcessBuilder(
+            image.java.toString(),
+            buildRule.getProject().getProperty("hello.main-class"));
+        builder.inheritIO();
+        int exitCode = builder.start().waitFor();
+        Assert.assertEquals(
+            "Checking that execution of first module succeeded.", 0, exitCode);
+
+        builder.command(
+            image.java.toString(),
+            buildRule.getProject().getProperty("smile.main-class"));
+        exitCode = builder.start().waitFor();
+        Assert.assertEquals(
+            "Checking that execution of second module succeeded.", 0, exitCode);
+    }
+
+    @Test
+    public void testNestedModuleMissingName() {
+        expected.expect(BuildException.class);
+        buildRule.executeTarget("modules-nested-missing-name");
+    }
+
+    @Test
+    public void testModulesInAttributeAndNested() {
+        buildRule.executeTarget("modules-both");
+        verifyImageBuiltNormally();
+    }
+
+    @Test
+    public void testObservableModules() {
+        expected.expect(BuildException.class);
+        buildRule.executeTarget("observable");
+    }
+
+    @Test
+    public void testNestedObservableModules() {
+        expected.expect(BuildException.class);
+        buildRule.executeTarget("observable-nested");
+    }
+
+    @Test
+    public void testNestedObservableModuleMissingName() {
+        expected.expect(BuildException.class);
+        buildRule.executeTarget("observable-nested-missing-name");
+    }
+
+    @Test
+    public void testObservableModulesInAttributeAndNested() {
+        buildRule.executeTarget("observable-both");
+        verifyImageBuiltNormally();
+    }
+
+    private void verifyLaunchersExist() {
+        ImageStructure image = verifyImageBuiltNormally();
+
+        File launcher1 =
+            new File(image.bin, isWindows() ? "Hello.bat" : "Hello");
+        Assert.assertTrue("Checking that image has 'Hello' launcher.",
+            launcher1.exists());
+
+        File launcher2 =
+            new File(image.bin, isWindows() ? "Smile.bat" : "Smile");
+        Assert.assertTrue("Checking that image has 'Smile' launcher.",
+            launcher2.exists());
+    }
+
+    @Test
+    public void testLaunchers() {
+        buildRule.executeTarget("launchers");
+        verifyLaunchersExist();
+    }
+
+    @Test
+    public void testNestedLaunchers() {
+        buildRule.executeTarget("launchers-nested");
+        verifyLaunchersExist();
+    }
+
+    @Test
+    public void testNestedLauncherMissingName() {
+        expected.expect(BuildException.class);
+        buildRule.executeTarget("launchers-nested-missing-name");
+    }
+
+    @Test
+    public void testNestedLauncherMissingModule() {
+        expected.expect(BuildException.class);
+        buildRule.executeTarget("launchers-nested-missing-module");
+    }
+
+    @Test
+    public void testLaunchersInAttributeAndNested() {
+        buildRule.executeTarget("launchers-both");
+        verifyLaunchersExist();
+    }
+
+    private void verifyLocales()
+    throws IOException,
+           InterruptedException {
+
+        ImageStructure image = verifyImageBuiltNormally();
+
+        String mainClass =
+            buildRule.getProject().getProperty("localefinder.main-class");
+        Assert.assertNotNull("Checking that main-class property exists",
+            mainClass);
+
+        ProcessBuilder builder =
+            new ProcessBuilder(image.java.toString(), mainClass, "zh", "in");
+        builder.inheritIO();
+        int exitCode = builder.start().waitFor();
+
+        Assert.assertEquals("Verifying that image has access to locales "
+            + "specified during linking.", 0, exitCode);
+
+        builder.command(image.java.toString(), mainClass, "ja");
+        exitCode = builder.start().waitFor();
+
+        Assert.assertNotEquals(
+            "Verifying that image does not have access to locales "
+            + "not specified during linking.", 0, exitCode);
+    }
+
+    @Test
+    public void testLocales()
+    throws IOException,
+           InterruptedException {
+
+        buildRule.executeTarget("locales");
+        verifyLocales();
+    }
+
+    @Test
+    public void testNestedLocales()
+    throws IOException,
+           InterruptedException {
+
+        buildRule.executeTarget("locales-nested");
+        verifyLocales();
+    }
+
+    @Test
+    public void testNestedLocaleMissingName() {
+        expected.expect(BuildException.class);
+        buildRule.executeTarget("locales-nested-missing-name");
+    }
+
+    @Test
+    public void testLocalesInAttributeAndNested()
+    throws IOException,
+           InterruptedException {
+
+        buildRule.executeTarget("locales-both");
+        verifyLocales();
+    }
+
+    @Test
+    public void testExcludeResources()
+    throws IOException {
+        buildRule.executeTarget("excluderesources");
+        ImageStructure image = verifyImageBuiltNormally();
+
+        String mainClass =
+            buildRule.getProject().getProperty("hello.main-class");
+        Assert.assertNotNull("Checking that main-class property exists",
+            mainClass);
+
+        ProcessBuilder builder =
+            new ProcessBuilder(image.java.toString(), mainClass,
+                "resource1.txt", "resource2.txt");
+        builder.redirectInput(ProcessBuilder.Redirect.INHERIT);
+        builder.redirectErrorStream(true);
+
+        Collection<String> outputLines;
+        Process process = builder.start();
+        try (BufferedReader reader = new BufferedReader(
+            new InputStreamReader(process.getInputStream()))) {
+
+            outputLines = reader.lines().collect(Collectors.toList());
+        }
+
+        Assert.assertTrue(
+            "Checking that excluded resource is actually excluded.",
+            outputLines.stream().anyMatch(
+                l -> l.endsWith("resource1.txt absent")));
+
+        Assert.assertTrue(
+            "Checking that resource not excluded is present.",
+            outputLines.stream().anyMatch(
+                l -> l.endsWith("resource2.txt present")));
+    }
+
+    @Test
+    public void testNestedExcludeResources()
+    throws IOException {
+        buildRule.executeTarget("excluderesources-nested");
+        ImageStructure image = verifyImageBuiltNormally();
+
+        String mainClass =
+            buildRule.getProject().getProperty("hello.main-class");
+        Assert.assertNotNull("Checking that main-class property exists",
+            mainClass);
+
+        ProcessBuilder builder =
+            new ProcessBuilder(image.java.toString(), mainClass,
+                "resource1.txt", "resource2.txt");
+        builder.redirectInput(ProcessBuilder.Redirect.INHERIT);
+        builder.redirectErrorStream(true);
+
+        Collection<String> outputLines;
+        Process process = builder.start();
+        try (BufferedReader reader = new BufferedReader(
+            new InputStreamReader(process.getInputStream()))) {
+
+            outputLines = reader.lines().collect(Collectors.toList());
+        }
+
+        Assert.assertTrue(
+            "Checking that excluded resource is actually excluded.",
+            outputLines.stream().anyMatch(
+                l -> l.endsWith("resource1.txt absent")));
+
+        Assert.assertTrue(
+            "Checking that resource not excluded is present.",
+            outputLines.stream().anyMatch(
+                l -> l.endsWith("resource2.txt present")));
+    }
+
+    @Test
+    public void testNestedExcludeResourcesFile()
+    throws IOException {
+        buildRule.executeTarget("excluderesources-nested-file");
+        ImageStructure image = verifyImageBuiltNormally();
+
+        String mainClass =
+            buildRule.getProject().getProperty("hello.main-class");
+        Assert.assertNotNull("Checking that main-class property exists",
+            mainClass);
+
+        ProcessBuilder builder =
+            new ProcessBuilder(image.java.toString(), mainClass,
+                "resource1.txt", "resource2.txt");
+        builder.redirectInput(ProcessBuilder.Redirect.INHERIT);
+        builder.redirectErrorStream(true);
+
+        Collection<String> outputLines;
+        Process process = builder.start();
+        try (BufferedReader reader = new BufferedReader(
+            new InputStreamReader(process.getInputStream()))) {
+
+            outputLines = reader.lines().collect(Collectors.toList());
+        }
+
+        Assert.assertTrue(
+            "Checking that excluded resource is actually excluded.",
+            outputLines.stream().anyMatch(
+                l -> l.endsWith("resource1.txt absent")));
+
+        Assert.assertTrue(
+            "Checking that resource not excluded is present.",
+            outputLines.stream().anyMatch(
+                l -> l.endsWith("resource2.txt present")));
+    }
+
+    @Test
+    public void testNestedExcludeResourcesNoAttributes() {
+        expected.expect(BuildException.class);
+        buildRule.executeTarget("excluderesources-nested-no-attr");
+    }
+
+    @Test
+    public void testNestedExcludeResourcesFileAndPattern() {
+        expected.expect(BuildException.class);
+        buildRule.executeTarget("excluderesources-nested-both");
+    }
+
+    @Test
+    public void testExcludeResourcesAttributeAndNested()
+    throws IOException {
+        buildRule.executeTarget("excluderesources-both");
+        ImageStructure image = verifyImageBuiltNormally();
+
+        String mainClass =
+            buildRule.getProject().getProperty("hello.main-class");
+        Assert.assertNotNull("Checking that main-class property exists",
+            mainClass);
+
+        ProcessBuilder builder =
+            new ProcessBuilder(image.java.toString(), mainClass,
+                "resource1.txt", "resource2.txt");
+        builder.redirectInput(ProcessBuilder.Redirect.INHERIT);
+        builder.redirectErrorStream(true);
+
+        Collection<String> outputLines;
+        Process process = builder.start();
+        try (BufferedReader reader = new BufferedReader(
+            new InputStreamReader(process.getInputStream()))) {
+
+            outputLines = reader.lines().collect(Collectors.toList());
+        }
+
+        Assert.assertTrue(
+            "Checking that first excluded resource is actually excluded.",
+            outputLines.stream().anyMatch(
+                l -> l.endsWith("resource1.txt absent")));
+
+        Assert.assertTrue(
+            "Checking that second excluded resource is actually excluded.",
+            outputLines.stream().anyMatch(
+                l -> l.endsWith("resource2.txt absent")));
+    }
+
+    @Test
+    public void testExcludeFiles()
+    throws IOException {
+        buildRule.executeTarget("excludefiles");
+        verifyImageBuiltNormally();
+        // TODO: Test created image (what does --exclude-files actually do?)
+    }
+
+    @Test
+    public void testNestedExcludeFiles()
+    throws IOException {
+        buildRule.executeTarget("excludefiles-nested");
+        verifyImageBuiltNormally();
+        // TODO: Test created image (what does --exclude-files actually do?)
+    }
+
+    @Test
+    public void testNestedExcludeFilesFile()
+    throws IOException {
+        buildRule.executeTarget("excludefiles-nested-file");
+        ImageStructure image = verifyImageBuiltNormally();
+        // TODO: Test created image (what does --exclude-files actually do?)
+    }
+
+    @Test
+    public void testNestedExcludeFilesNoAttributes() {
+        expected.expect(BuildException.class);
+        buildRule.executeTarget("excludefiles-nested-no-attr");
+    }
+
+    @Test
+    public void testNestedExcludeFilesFileAndPattern() {
+        expected.expect(BuildException.class);
+        buildRule.executeTarget("excludefiles-nested-both");
+    }
+
+    @Test
+    public void testExcludeFilesAttributeAndNested()
+    throws IOException {
+        buildRule.executeTarget("excludefiles-both");
+        verifyImageBuiltNormally();
+        // TODO: Test created image (what does --exclude-files actually do?)
+    }
+
+    @Test
+    public void testOrdering()
+    throws IOException {
+        buildRule.executeTarget("ordering");
+        verifyImageBuiltNormally();
+        // TODO: Test resource order in created image (how?)
+    }
+
+    @Test
+    public void testNestedOrdering()
+    throws IOException {
+        buildRule.executeTarget("ordering-nested");
+        verifyImageBuiltNormally();
+        // TODO: Test resource order in created image (how?)
+    }
+
+    @Test
+    public void testNestedOrderingListFile()
+    throws IOException {
+        buildRule.executeTarget("ordering-nested-file");
+        ImageStructure image = verifyImageBuiltNormally();
+        // TODO: Test resource order in created image (how?)
+    }
+
+    @Test
+    public void testNestedOrderingNoAttributes() {
+        expected.expect(BuildException.class);
+        buildRule.executeTarget("ordering-nested-no-attr");
+    }
+
+    @Test
+    public void testNestedOrderingFileAndPattern() {
+        expected.expect(BuildException.class);
+        buildRule.executeTarget("ordering-nested-both");
+    }
+
+    @Test
+    public void testOrderingAttributeAndNested()
+    throws IOException {
+        buildRule.executeTarget("ordering-both");
+        verifyImageBuiltNormally();
+        // TODO: Test resource order in created image (how?)
+    }
+
+    @Test
+    public void testIncludeHeaders() {
+        buildRule.executeTarget("includeheaders");
+        ImageStructure image = verifyImageBuiltNormally();
+
+        File[] headers = new File(image.root, "include").listFiles();
+        Assert.assertTrue("Checking that include files were omitted.",
+            headers == null || headers.length == 0);
+    }
+
+    @Test
+    public void testIncludeManPages() {
+        buildRule.executeTarget("includemanpages");
+        ImageStructure image = verifyImageBuiltNormally();
+
+        File[] manPages = new File(image.root, "man").listFiles();
+        Assert.assertTrue("Checking that man pages were omitted.",
+            manPages == null || manPages.length == 0);
+    }
+
+    @Test
+    public void testIncludeNativeCommands() {
+        buildRule.executeTarget("includenativecommands");
+        ImageStructure image = new ImageStructure(
+            new File(buildRule.getProject().getProperty("image")));
+
+        Assert.assertTrue("Checking that image was successfully created.",
+            image.root.exists());
+
+        Assert.assertFalse(
+            "Checking that image was stripped of java executable.",
+            image.java.exists());
+    }
+
+    private long totalSizeOf(final Path path)
+    throws IOException {
+        if (Files.isDirectory(path)) {
+            long size = 0;
+            try (DirectoryStream<Path> children = Files.newDirectoryStream(path)) {
+                for (Path child : children) {
+                    size += totalSizeOf(child);
+                }
+            }
+            return size;
+        }
+
+        if (Files.isRegularFile(path)) {
+            return Files.size(path);
+        }
+
+        return 0;
+    }
+
+    @Test
+    public void testCompression()
+    throws IOException {
+        buildRule.executeTarget("compression");
+        ImageStructure image = verifyImageBuiltNormally();
+
+        File compressedImageRoot =
+            new File(buildRule.getProject().getProperty("compressed-image"));
+
+        long size = totalSizeOf(image.root.toPath());
+        long compressedSize = totalSizeOf(compressedImageRoot.toPath());
+
+        Assert.assertTrue("Checking that compression resulted in smaller image.",
+            compressedSize < size);
+    }
+
+    @Test
+    public void testNestedCompression()
+    throws IOException {
+        buildRule.executeTarget("compression-nested");
+        ImageStructure image = verifyImageBuiltNormally();
+
+        File compressedImageRoot =
+            new File(buildRule.getProject().getProperty("compressed-image"));
+
+        long size = totalSizeOf(image.root.toPath());
+        long compressedSize = totalSizeOf(compressedImageRoot.toPath());
+
+        Assert.assertTrue("Checking that compression resulted in smaller image.",
+            compressedSize < size);
+    }
+
+    @Test
+    public void testNestedCompressionNoAttributes() {
+        expected.expect(BuildException.class);
+        buildRule.executeTarget("compression-nested-no-attr");
+    }
+
+    @Test
+    public void testNestedCompressionAttributeAndNested() {
+        expected.expect(BuildException.class);
+        buildRule.executeTarget("compression-both");
+    }
+
+    @Test
+    public void testEndian() {
+        buildRule.executeTarget("endian");
+        verifyImageBuiltNormally();
+        // TODO: How can we test the created image?  Which files does --endian
+        // affect?
+    }
+
+    @Test
+    public void testVMType() {
+        buildRule.executeTarget("vm");
+        verifyImageBuiltNormally();
+        // TODO: How can we test the created image?  Which files does --vm
+        // affect?
+    }
+
+    @Test
+    public void testReleaseInfoFile()
+    throws IOException {
+        buildRule.executeTarget("releaseinfo-file");
+        ImageStructure image = verifyImageBuiltNormally();
+
+        File release = new File(image.root, "release");
+        try (BufferedReader reader =
+            Files.newBufferedReader(release.toPath())) {
+
+            Assert.assertTrue("Checking for 'test=true' in image release info.",
+                reader.lines().anyMatch(l -> l.equals("test=true")));
+        }
+    }
+
+    @Test
+    public void testReleaseInfoDelete()
+    throws IOException {
+        buildRule.executeTarget("releaseinfo-delete");
+        ImageStructure image = verifyImageBuiltNormally();
+
+        File release = new File(image.root, "release");
+        try (BufferedReader reader =
+            Files.newBufferedReader(release.toPath())) {
+
+            Assert.assertFalse("Checking that 'test' was deleted "
+                + "from image release info.",
+                reader.lines().anyMatch(l -> l.startsWith("test=")));
+        }
+    }
+
+    @Test
+    public void testReleaseInfoNestedDelete()
+    throws IOException {
+        buildRule.executeTarget("releaseinfo-nested-delete");
+        ImageStructure image = verifyImageBuiltNormally();
+
+        File release = new File(image.root, "release");
+        try (BufferedReader reader =
+            Files.newBufferedReader(release.toPath())) {
+
+            Assert.assertFalse("Checking that 'test' was deleted "
+                + "from image release info.",
+                reader.lines().anyMatch(l -> l.startsWith("test=")));
+        }
+    }
+
+    @Test
+    public void testReleaseInfoNestedDeleteNoKey() {
+        expected.expect(BuildException.class);
+        buildRule.executeTarget("releaseinfo-nested-delete-no-key");
+    }
+
+    @Test
+    public void testReleaseInfoDeleteAttributeAndNested()
+    throws IOException {
+        buildRule.executeTarget("releaseinfo-nested-delete-both");
+        ImageStructure image = verifyImageBuiltNormally();
+
+        File release = new File(image.root, "release");
+        try (BufferedReader reader =
+            Files.newBufferedReader(release.toPath())) {
+
+            Assert.assertTrue(
+                "Checking that 'test' and 'foo' were deleted "
+                + "from image release info.",
+                reader.lines().noneMatch(l ->
+                    l.startsWith("test=") || l.startsWith("foo=")));
+        }
+    }
+
+    @Test
+    public void testReleaseInfoAddFile()
+    throws IOException {
+        buildRule.executeTarget("releaseinfo-add-file");
+        ImageStructure image = verifyImageBuiltNormally();
+
+        File release = new File(image.root, "release");
+        try (BufferedReader reader = new BufferedReader(
+            new FileReader(release))) {
+
+            Assert.assertTrue("Checking that 'test=s\u00ed' was added "
+                + "to image release info.",
+                reader.lines().anyMatch(l -> l.equals("test=s\u00ed")));
+        }
+    }
+
+    @Test
+    public void testReleaseInfoAddFileWithCharset()
+    throws IOException {
+        buildRule.executeTarget("releaseinfo-add-file-charset");
+        ImageStructure image = verifyImageBuiltNormally();
+
+        File release = new File(image.root, "release");
+        // Using FileReader here since 'release' file is in platform's charset.
+        try (BufferedReader reader = new BufferedReader(
+            new FileReader(release))) {
+
+            Assert.assertTrue("Checking that 'test=s\u00ed' was added "
+                + "to image release info.",
+                reader.lines().anyMatch(l -> l.equals("test=s\u00ed")));
+        }
+    }
+
+    @Test
+    public void testReleaseInfoAddKeyAndValue()
+    throws IOException {
+        buildRule.executeTarget("releaseinfo-add-key");
+        ImageStructure image = verifyImageBuiltNormally();
+
+        File release = new File(image.root, "release");
+        try (BufferedReader reader =
+            Files.newBufferedReader(release.toPath())) {
+
+            Assert.assertTrue("Checking that 'test=true' was added "
+                + "to image release info.",
+                reader.lines().anyMatch(l -> l.equals("test=true")));
+        }
+    }
+
+    @Test
+    public void testReleaseInfoAddNoValue() {
+        expected.expect(BuildException.class);
+        buildRule.executeTarget("releaseinfo-add-no-value");
+    }
+
+    @Test
+    public void testReleaseInfoAddNoKey() {
+        expected.expect(BuildException.class);
+        buildRule.executeTarget("releaseinfo-add-no-key");
+    }
+
+    @Test
+    public void testReleaseInfoAddFileAndKey() {
+        expected.expect(BuildException.class);
+        buildRule.executeTarget("releaseinfo-add-file-and-key");
+    }
+
+    @Test
+    public void testReleaseInfoAddFileAndValue() {
+        expected.expect(BuildException.class);
+        buildRule.executeTarget("releaseinfo-add-file-and-value");
+    }
+
+    @Test
+    public void testDebugStripping()
+    throws IOException,
+           InterruptedException {
+
+        buildRule.executeTarget("debug");
+        ImageStructure image = verifyImageBuiltNormally();
+
+        ProcessBuilder builder = new ProcessBuilder(
+            image.java.toString(),
+            buildRule.getProject().getProperty("thrower.main-class"));
+        builder.redirectInput(ProcessBuilder.Redirect.INHERIT);
+        builder.redirectErrorStream(true);
+
+        Process process = builder.start();
+        try (BufferedReader linesReader = new BufferedReader(
+            new InputStreamReader(process.getInputStream()))) {
+
+            Assert.assertTrue(
+                "Checking that stack trace contains no debug information.",
+                linesReader.lines().noneMatch(
+                    l -> l.matches(".*\\([^)]*:[0-9]+\\)")));
+        }
+        process.waitFor();
+    }
+
+    @Test
+    public void testDeduplicationOfLicenses() {
+        buildRule.executeTarget("dedup");
+        ImageStructure image = verifyImageBuiltNormally();
+
+        String helloModuleName =
+            buildRule.getProject().getProperty("hello.mod");
+        String smileModuleName =
+            buildRule.getProject().getProperty("smile.mod");
+
+        Assert.assertNotNull("Checking that 'hello.mod' property was set.",
+            helloModuleName);
+        Assert.assertNotNull("Checking that 'smile.mod' property was set.",
+            smileModuleName);
+
+        Assume.assumeFalse("Checking that this operating system"
+            + " supports symbolic links as a means of license de-duplication.",
+            System.getProperty("os.name").contains("Windows"));
+
+        Path legal = image.root.toPath().resolve("legal");
+
+        Path[] licenses = {
+            legal.resolve(helloModuleName).resolve("USELESSLICENSE"),
+            legal.resolve(smileModuleName).resolve("USELESSLICENSE"),
+        };
+
+        int nonLinkCount = 0;
+        for (Path license : licenses) {
+            if (!Files.isSymbolicLink(license)) {
+                nonLinkCount++;
+            }
+        }
+
+        Assert.assertEquals(
+            "Checking that USELESSLICENSE only exists once in image "
+            + "and all other instances are links to it.",
+            1, nonLinkCount);
+    }
+
+    @Test
+    public void testIgnoreSigning() {
+        buildRule.executeTarget("ignoresigning");
+        verifyImageBuiltNormally();
+    }
+
+    /**
+     * Should fail due to jlink rejecting identically named files whose
+     * contents are different.
+     */
+    @Test
+    public void testDeduplicationOfInconsistentLicenses() {
+        expected.expect(BuildException.class);
+        buildRule.executeTarget("dedup-identical");
+    }
+
+    @Test
+    public void testBindingOfServices()
+    throws IOException,
+           InterruptedException {
+        buildRule.executeTarget("bindservices");
+        ImageStructure image = verifyImageBuiltNormally();
+
+        String mainClass = buildRule.getProject().getProperty("inc.main-class");
+
+        ProcessBuilder builder = new ProcessBuilder(
+            image.java.toString(), mainClass);
+        builder.redirectInput(ProcessBuilder.Redirect.INHERIT);
+        builder.redirectError(ProcessBuilder.Redirect.INHERIT);
+
+        Process process = builder.start();
+        try (BufferedReader linesReader = new BufferedReader(
+            new InputStreamReader(process.getInputStream()))) {
+
+            Assert.assertEquals(
+                "Checking that bindServices=false results in no providers in image.",
+                0, linesReader.lines().count());
+        }
+        process.waitFor();
+
+        image = new ImageStructure(
+            new File(buildRule.getProject().getProperty("image2")));
+
+        Assert.assertTrue("Checking that image2 was successfully created.",
+            image.root.exists());
+        Assert.assertTrue("Checking that image2 has java executable.",
+            image.java.exists());
+
+        builder = new ProcessBuilder(image.java.toString(), mainClass);
+        builder.redirectInput(ProcessBuilder.Redirect.INHERIT);
+        builder.redirectError(ProcessBuilder.Redirect.INHERIT);
+
+        process = builder.start();
+        try (BufferedReader linesReader = new BufferedReader(
+            new InputStreamReader(process.getInputStream()))) {
+
+            Assert.assertEquals(
+                "Checking that bindServices=true results in image with provider.",
+                5, linesReader.lines().count());
+        }
+        process.waitFor();
+    }
+
+    private String runJlink(final String... args) {
+        ToolProvider jlink = ToolProvider.findFirst("jlink").orElseThrow(
+            () -> new RuntimeException("jlink tool not found in JDK."));
+
+        ByteArrayOutputStream stdout = new ByteArrayOutputStream();
+        ByteArrayOutputStream stderr = new ByteArrayOutputStream();
+
+        int exitCode;
+        try (PrintStream out = new PrintStream(stdout);
+             PrintStream err = new PrintStream(stderr)) {
+
+            exitCode = jlink.run(out, err, args);
+        }
+
+        if (exitCode != 0) {
+            throw new RuntimeException(
+                "jlink failed, output is: " + stdout + ", error is: " + stderr);
+        }
+
+        return stdout.toString();
+    }
+}
diff --git a/src/tests/junit/org/apache/tools/ant/types/ModuleVersionTest.java b/src/tests/junit/org/apache/tools/ant/types/ModuleVersionTest.java
new file mode 100644 (file)
index 0000000..b3cf904
--- /dev/null
@@ -0,0 +1,115 @@
+package org.apache.tools.ant.types;
+
+import org.junit.Assert;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+/**
+ * Tests {@link ModuleVersion} class.
+ */
+public class ModuleVersionTest {
+    @Rule
+    public final ExpectedException expected = ExpectedException.none();
+
+    @Test
+    public void testModuleVersionStringNumberPreBuild() {
+        ModuleVersion moduleVersion = new ModuleVersion();
+
+        moduleVersion.setNumber("1.1.3");
+        moduleVersion.setPreRelease("ea");
+        moduleVersion.setBuild("25");
+
+        String versionStr = moduleVersion.toModuleVersionString();
+
+        Assert.assertNotNull("Checking for non-null module version string.",
+            versionStr);
+        Assert.assertTrue("Checking for correct module version string.",
+            versionStr.matches("1\\.1\\.3[-+]ea\\+25"));
+    }
+
+    @Test
+    public void testModuleVersionStringNumberPre() {
+        ModuleVersion moduleVersion = new ModuleVersion();
+
+        moduleVersion.setNumber("1.1.3");
+        moduleVersion.setPreRelease("ea");
+
+        String versionStr = moduleVersion.toModuleVersionString();
+
+        Assert.assertNotNull("Checking for non-null module version string.",
+            versionStr);
+        Assert.assertTrue("Checking for correct module version string.",
+            versionStr.matches("1\\.1\\.3[-+]ea"));
+    }
+
+    @Test
+    public void testModuleVersionStringNumberBuild() {
+        ModuleVersion moduleVersion = new ModuleVersion();
+
+        moduleVersion.setNumber("1.1.3");
+        moduleVersion.setBuild("25");
+
+        String versionStr = moduleVersion.toModuleVersionString();
+
+        Assert.assertNotNull("Checking for non-null module version string.",
+            versionStr);
+        Assert.assertTrue("Checking for correct module version string.",
+            versionStr.matches("1\\.1\\.3[-+]\\+25"));
+    }
+
+    @Test
+    public void testModuleVersionStringNumberOnly() {
+        ModuleVersion moduleVersion = new ModuleVersion();
+
+        moduleVersion.setNumber("1.1.3");
+
+        String versionStr = moduleVersion.toModuleVersionString();
+
+        Assert.assertNotNull("Checking for non-null module version string.",
+            versionStr);
+        Assert.assertEquals("Checking for correct module version string.",
+            "1.1.3", versionStr);
+    }
+
+    @Test
+    public void testModuleVersionStringNullNumber() {
+        expected.expect(IllegalStateException.class);
+
+        ModuleVersion moduleVersion = new ModuleVersion();
+        moduleVersion.toModuleVersionString();
+    }
+
+    @Test
+    public void testNullNumber() {
+        expected.expect(NullPointerException.class);
+
+        ModuleVersion moduleVersion = new ModuleVersion();
+        moduleVersion.setNumber(null);
+    }
+
+    @Test
+    public void testInvalidNumber() {
+        expected.expect(IllegalArgumentException.class);
+
+        ModuleVersion moduleVersion = new ModuleVersion();
+        moduleVersion.setNumber("1-1-3");
+    }
+
+    @Test
+    public void testInvalidNumber2() {
+        expected.expect(IllegalArgumentException.class);
+
+        ModuleVersion moduleVersion = new ModuleVersion();
+        moduleVersion.setNumber("1.1+3");
+    }
+
+    @Test
+    public void testInvalidPreRelease() {
+        expected.expect(IllegalArgumentException.class);
+
+        ModuleVersion moduleVersion = new ModuleVersion();
+        moduleVersion.setNumber("1.1.3");
+        moduleVersion.setPreRelease("ea+interim");
+    }
+}