You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@ant.apache.org by bo...@apache.org on 2018/12/15 16:28:13 UTC

[3/6] ant git commit: Added tasks for JDK's jmod and jlink tools.

http://git-wip-us.apache.org/repos/asf/ant/blob/343dff90/src/main/org/apache/tools/ant/taskdefs/modules/Link.java
----------------------------------------------------------------------
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
index 0000000..36fd281
--- /dev/null
+++ b/src/main/org/apache/tools/ant/taskdefs/modules/Link.java
@@ -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;
+    }
+}

http://git-wip-us.apache.org/repos/asf/ant/blob/343dff90/src/main/org/apache/tools/ant/taskdefs/modules/package-info.java
----------------------------------------------------------------------
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
index 0000000..99b11e5
--- /dev/null
+++ b/src/main/org/apache/tools/ant/taskdefs/modules/package-info.java
@@ -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;

http://git-wip-us.apache.org/repos/asf/ant/blob/343dff90/src/main/org/apache/tools/ant/taskdefs/optional/jlink/JlinkTask.java
----------------------------------------------------------------------
diff --git a/src/main/org/apache/tools/ant/taskdefs/optional/jlink/JlinkTask.java b/src/main/org/apache/tools/ant/taskdefs/optional/jlink/JlinkTask.java
index db8b3a3..be27284 100644
--- a/src/main/org/apache/tools/ant/taskdefs/optional/jlink/JlinkTask.java
+++ b/src/main/org/apache/tools/ant/taskdefs/optional/jlink/JlinkTask.java
@@ -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

http://git-wip-us.apache.org/repos/asf/ant/blob/343dff90/src/main/org/apache/tools/ant/types/ModuleVersion.java
----------------------------------------------------------------------
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
index 0000000..0a43420
--- /dev/null
+++ b/src/main/org/apache/tools/ant/types/ModuleVersion.java
@@ -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 +
+            "]";
+    }
+}