From e9e2d1782409a0cb490c5862f4aee4c62dfb3075 Mon Sep 17 00:00:00 2001 From: Martin Desruisseaux Date: Sun, 12 Oct 2025 17:39:27 +0200 Subject: [PATCH 1/5] Replace `org.apache.maven.shared.model.fileset` by a temporary copy of the `PathSelector` class of Maven core. This is for the transition to Maven 4.0.0 API. As a side effect, this change may improve performances because the new implementation based on `FileVisitor` makes some efforts for avoiding to scan unnecessary directories. --- pom.xml | 5 - .../verify.bsh | 2 +- .../maven/plugins/jar/AbstractJarMojo.java | 95 ++-- .../apache/maven/plugins/jar/Archiver.java | 198 +++++++ .../maven/plugins/jar/PathSelector.java | 538 ++++++++++++++++++ 5 files changed, 778 insertions(+), 60 deletions(-) create mode 100644 src/main/java/org/apache/maven/plugins/jar/Archiver.java create mode 100644 src/main/java/org/apache/maven/plugins/jar/PathSelector.java diff --git a/pom.xml b/pom.xml index 9016cc4a..1986349d 100644 --- a/pom.xml +++ b/pom.xml @@ -144,11 +144,6 @@ ${mavenVersion} provided - - org.apache.maven.shared - file-management - ${mavenFileManagementVersion} - org.apache.maven.shared maven-archiver diff --git a/src/it/MJAR-260-invalid-automatic-module-name/verify.bsh b/src/it/MJAR-260-invalid-automatic-module-name/verify.bsh index 5b9d457f..452aed0e 100644 --- a/src/it/MJAR-260-invalid-automatic-module-name/verify.bsh +++ b/src/it/MJAR-260-invalid-automatic-module-name/verify.bsh @@ -45,7 +45,7 @@ try String[] snippets = new String[] { "[INFO] BUILD FAILURE", "[ERROR] Failed to execute goal org.apache.maven.plugins:maven-jar-plugin", - "Caused by: org.apache.maven.api.plugin.MojoException: Error assembling JAR", + "Caused by: org.apache.maven.api.plugin.MojoException: Error while assembling the JAR file.", "Caused by: org.codehaus.plexus.archiver.jar.ManifestException: Invalid automatic module name: 'in-valid.name.with.new.keyword'" }; diff --git a/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java b/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java index 058ac058..ef167879 100644 --- a/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java +++ b/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java @@ -18,11 +18,11 @@ */ package org.apache.maven.plugins.jar; -import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.jar.Attributes; @@ -39,8 +39,6 @@ import org.apache.maven.shared.archiver.MavenArchiveConfiguration; import org.apache.maven.shared.archiver.MavenArchiver; import org.apache.maven.shared.archiver.MavenArchiverException; -import org.apache.maven.shared.model.fileset.FileSet; -import org.apache.maven.shared.model.fileset.util.FileSetManager; import org.codehaus.plexus.archiver.Archiver; import org.codehaus.plexus.archiver.jar.JarArchiver; @@ -55,8 +53,6 @@ public abstract class AbstractJarMojo implements org.apache.maven.api.plugin.Moj private static final String[] DEFAULT_INCLUDES = new String[] {"**/**"}; - private static final String MODULE_DESCRIPTOR_FILE_NAME = "module-info.class"; - /** * List of files to include. Specified as fileset patterns which are relative to the input directory whose contents * is being packaged into the JAR. @@ -222,43 +218,36 @@ protected Path getJarFile(Path basedir, String resultFinalName, String classifie } /** - * Generates the JAR. + * Generates the JAR file. * * @return the path to the created archive file - * @throws MojoException in case of an error + * @throws IOException in case of an error while reading a file or writing in the JAR file */ - public Path createArchive() throws MojoException { - Path jarFile = getJarFile(outputDirectory, finalName, getClassifier()); - - FileSetManager fileSetManager = new FileSetManager(); - FileSet jarContentFileSet = new FileSet(); - jarContentFileSet.setDirectory(getClassesDirectory().toAbsolutePath().toString()); - jarContentFileSet.setIncludes(Arrays.asList(getIncludes())); - jarContentFileSet.setExcludes(Arrays.asList(getExcludes())); - - String[] includedFiles = fileSetManager.getIncludedFiles(jarContentFileSet); - - if (detectMultiReleaseJar - && Arrays.stream(includedFiles) - .anyMatch( - p -> p.startsWith("META-INF" + File.separatorChar + "versions" + File.separatorChar))) { - getLog().debug("Adding 'Multi-Release: true' manifest entry."); - archive.addManifestEntry(Attributes.Name.MULTI_RELEASE.toString(), "true"); + @SuppressWarnings("checkstyle:UnusedLocalVariable") // Checkstyle bug: does not detect `includedFiles` usage. + public Path createArchive() throws IOException { + String archiverName = "jar"; + final Path jarFile = getJarFile(outputDirectory, finalName, getClassifier()); + final Path classesDirectory = getClassesDirectory(); + if (Files.exists(classesDirectory)) { + var includedFiles = new org.apache.maven.plugins.jar.Archiver( + classesDirectory, + (includes != null) ? Arrays.asList(includes) : List.of(), + (excludes != null && excludes.length > 0) + ? Arrays.asList(excludes) + : List.of(AbstractJarMojo.DEFAULT_EXCLUDES)); + + var scanner = includedFiles.new VersionScanner(detectMultiReleaseJar); + if (Files.exists(classesDirectory)) { + Files.walkFileTree(classesDirectory, scanner); + } + if (detectMultiReleaseJar && scanner.detectedMultiReleaseJAR) { + getLog().debug("Adding 'Multi-Release: true' manifest entry."); + archive.addManifestEntry(Attributes.Name.MULTI_RELEASE.toString(), "true"); + } + if (scanner.containsModuleDescriptor) { + archiverName = "mjar"; + } } - - // May give false positives if the files is named as module descriptor - // but is not in the root of the archive or in the versioned area - // (and hence not actually a module descriptor). - // That is fine since the modular Jar archiver will gracefully - // handle such case. - // And also such case is unlikely to happen as file ending - // with "module-info.class" is unlikely to be included in Jar file - // unless it is a module descriptor. - boolean containsModuleDescriptor = - Arrays.stream(includedFiles).anyMatch(p -> p.endsWith(MODULE_DESCRIPTOR_FILE_NAME)); - - String archiverName = containsModuleDescriptor ? "mjar" : "jar"; - MavenArchiver archiver = new MavenArchiver(); archiver.setCreatedBy("Maven JAR Plugin", "org.apache.maven.plugins", "maven-jar-plugin"); archiver.setArchiver((JarArchiver) archivers.get(archiverName)); @@ -269,23 +258,16 @@ public Path createArchive() throws MojoException { archive.setForced(forceCreation); - try { - Path contentDirectory = getClassesDirectory(); - if (!Files.exists(contentDirectory)) { - if (!forceCreation) { - getLog().warn("JAR will be empty - no content was marked for inclusion!"); - } - } else { - archiver.getArchiver().addDirectory(contentDirectory.toFile(), getIncludes(), getExcludes()); + Path contentDirectory = getClassesDirectory(); + if (!Files.exists(contentDirectory)) { + if (!forceCreation) { + getLog().warn("JAR will be empty - no content was marked for inclusion!"); } - - archiver.createArchive(session, project, archive); - - return jarFile; - } catch (Exception e) { - // TODO: improve error handling - throw new MojoException("Error assembling JAR", e); + } else { + archiver.getArchiver().addDirectory(contentDirectory.toFile(), getIncludes(), getExcludes()); } + archiver.createArchive(session, project, archive); + return jarFile; } /** @@ -298,7 +280,12 @@ public void execute() throws MojoException { if (skipIfEmpty && isEmpty(getClassesDirectory())) { getLog().info(String.format("Skipping packaging of the %s.", getType())); } else { - Path jarFile = createArchive(); + Path jarFile; + try { + jarFile = createArchive(); + } catch (Exception e) { + throw new MojoException("Error while assembling the JAR file.", e); + } ProducedArtifact artifact; String classifier = getClassifier(); if (attach) { diff --git a/src/main/java/org/apache/maven/plugins/jar/Archiver.java b/src/main/java/org/apache/maven/plugins/jar/Archiver.java new file mode 100644 index 00000000..d44dc415 --- /dev/null +++ b/src/main/java/org/apache/maven/plugins/jar/Archiver.java @@ -0,0 +1,198 @@ +/* + * 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.maven.plugins.jar; + +import java.io.IOException; +import java.nio.file.FileVisitResult; +import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.Collection; + +import org.apache.maven.api.annotations.Nonnull; + +/** + * Creates the JAR file. + * + * TODO: this is a work in progress. + */ +final class Archiver extends SimpleFileVisitor { + /** + * Combination of includes and excludes path matchers applied on files. + */ + @Nonnull + private final PathMatcher fileMatcher; + + /** + * Combination of includes and excludes path matchers applied on directories. + */ + @Nonnull + private final PathMatcher directoryMatcher; + + /** + * Creates a new archiver. + * + * @param directory the base directory of the files to archive + * @param includes the patterns of the files to include, or null or empty for including all files + * @param excludes the patterns of the files to exclude, or null or empty for no exclusion + */ + Archiver(Path directory, Collection includes, Collection excludes) { + fileMatcher = PathSelector.of(directory, includes, excludes); + if (fileMatcher instanceof PathSelector ps) { + if (ps.canFilterDirectories()) { + directoryMatcher = (path) -> ps.couldHoldSelected(path); + return; + } + } + directoryMatcher = PathSelector.INCLUDES_ALL; + } + + /** + * A scanner of {@code module-info.class} file and {@code META-INF/versions/*} directories. + * This is used only when we need to auto-detect whether the JAR is modular or multiè-release. + * This is not needed anymore for projects using the Maven 4 {@code } elements. + */ + final class VersionScanner extends SimpleFileVisitor { + /** + * The file to check for deciding whether the JAR is a modular JAR. + */ + private static final String MODULE_DESCRIPTOR_FILE_NAME = "module-info.class"; + + /** + * The directory level in the {@code ./META-INF/versions/###/} path. + * The root directory is at level 0 before we enter in that directory. + * After the execution of {@code preVisitDirectory} (i.e., at the time of visiting files), values are: + * + *
    + *
  1. for any file in the {@code ./} root classes directory.
  2. + *
  3. for any file in the {@code ./META-INF/} directory.
  4. + *
  5. for any file in the {@code ./META-INF/versions/} directory (i.e., any version number).
  6. + *
  7. for any file in the {@code ./META-INF/versions/###/} directory (i.e., any versioned file)
  8. + *
+ */ + private int level; + + /** + * First level of versioned files in a {@code ./META-INF/versions/###/} directory. + */ + private static final int LEVEL_OF_VERSIONED_FILES = 4; + + /** + * Whether a {@code META-INF/versions/} directory has been found. + * The value of this flag is invalid if this scanner was constructed + * with a {@code detectMultiReleaseJar} argument value of {@code true}. + */ + boolean detectedMultiReleaseJAR; + + /** + * Whether a {@code module-info.class} file has been found. + */ + boolean containsModuleDescriptor; + + /** + * Creates a scanner with all flags initially {@code false}. + * + * @param detectMultiReleaseJar whether to enable the detection of multi-release JAR + */ + VersionScanner(boolean detectMultiReleaseJar) { + detectedMultiReleaseJAR = !detectMultiReleaseJar; // Not actually true, but will cause faster stop. + } + + /** + * Returns {@code true} if the given directory is one of the parts of {@code ./META-INF/versions/*}. + * The {@code .} directory is at level 0, {@code META-INF} at level 1, {@code versions} at level 2, + * and one of the versions at level 3. Files at level 4 are versioned class files. + */ + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) { + if (detectedMultiReleaseJAR && level >= LEVEL_OF_VERSIONED_FILES) { + // When searching only for `module-info.class`, we do not need to go further than that level. + return (level > LEVEL_OF_VERSIONED_FILES) + ? FileVisitResult.SKIP_SIBLINGS + : FileVisitResult.SKIP_SUBTREE; + } + if (directoryMatcher.matches(dir)) { + String expected = + switch (level) { + case 1 -> "META-INF"; + case 2 -> "versions"; + default -> null; + }; + if (expected == null || dir.endsWith(expected)) { + level++; + return FileVisitResult.CONTINUE; + } + } + return FileVisitResult.SKIP_SUBTREE; + } + + /** + * Decrements our count of directory levels after visiting a directory. + */ + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { + level--; + return super.postVisitDirectory(dir, exc); + } + + /** + * Updates the flags for the given files. This method terminates the + * scanning if it detects that there is no new information to collect. + */ + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { + if (fileMatcher.matches(file)) { + if (level == 1 || level == LEVEL_OF_VERSIONED_FILES) { + // Root directory or a `META-INF/versions/###/` directory. + containsModuleDescriptor |= file.endsWith(MODULE_DESCRIPTOR_FILE_NAME); + } + detectedMultiReleaseJAR |= (level >= LEVEL_OF_VERSIONED_FILES); + if (detectedMultiReleaseJAR) { + if (containsModuleDescriptor) { + return FileVisitResult.TERMINATE; + } + if (level > LEVEL_OF_VERSIONED_FILES) { + return FileVisitResult.SKIP_SIBLINGS; + } + } + } + return FileVisitResult.CONTINUE; + } + } + + /** + * Determines if the given directory should be scanned for files to archive. + */ + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) { + return directoryMatcher.matches(dir) ? FileVisitResult.CONTINUE : FileVisitResult.SKIP_SUBTREE; + } + + /** + * Archives a file in a directory. + */ + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { + if (fileMatcher.matches(file)) { + // TODO + } + return FileVisitResult.CONTINUE; + } +} diff --git a/src/main/java/org/apache/maven/plugins/jar/PathSelector.java b/src/main/java/org/apache/maven/plugins/jar/PathSelector.java new file mode 100644 index 00000000..9eba96b7 --- /dev/null +++ b/src/main/java/org/apache/maven/plugins/jar/PathSelector.java @@ -0,0 +1,538 @@ +/* + * 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.maven.plugins.jar; + +import java.io.File; +import java.nio.file.FileSystem; +import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +import org.apache.maven.api.annotations.Nonnull; + +/** + * Temporary copy of the {@code PathSelector} class in Maven core. + * This class will be removed after the release of the Maven core version after 4.0.0-rc-4. + * It is used for the transition from {@code org.apache.maven.shared} to Maven 4.0.0 core. + */ +final class PathSelector implements PathMatcher { + /** + * Maximum number of characters of the prefix before {@code ':'} for handling as a Maven syntax. + */ + private static final int MAVEN_SYNTAX_THRESHOLD = 1; + + /** + * The default syntax to use if none was specified. Note that when this default syntax is applied, + * the user-provided pattern get some changes as documented in class Javadoc. + */ + private static final String DEFAULT_SYNTAX = "glob:"; + + /** + * Characters having a special meaning in the glob syntax. + * + * @see FileSystem#getPathMatcher(String) + */ + private static final String SPECIAL_CHARACTERS = "*?[]{}\\"; + + /** + * A path matcher which accepts all files. + * + * @see #simplify() + */ + static final PathMatcher INCLUDES_ALL = (path) -> true; + + /** + * String representations of the normalized include filters. + * Each pattern shall be prefixed by its syntax, which is {@value #DEFAULT_SYNTAX} by default. + * An empty array means to include all files. + * + * @see #toString() + */ + private final String[] includePatterns; + + /** + * String representations of the normalized exclude filters. + * Each pattern shall be prefixed by its syntax. If no syntax is specified, + * the default is a Maven 3 syntax similar, but not identical, to {@value #DEFAULT_SYNTAX}. + * This array may be longer or shorter than the user-supplied excludes, depending on whether + * default excludes have been added and whether some unnecessary excludes have been omitted. + * + * @see #toString() + */ + private final String[] excludePatterns; + + /** + * The matcher for includes. The length of this array is equal to {@link #includePatterns} array length. + * An empty array means to include all files. + */ + private final PathMatcher[] includes; + + /** + * The matcher for excludes. The length of this array is equal to {@link #excludePatterns} array length. + */ + private final PathMatcher[] excludes; + + /** + * The matcher for all directories to include. This array includes the parents of all those directories, + * because they need to be accepted before we can walk to the sub-directories. + * This is an optimization for skipping whole directories when possible. + * An empty array means to include all directories. + */ + private final PathMatcher[] dirIncludes; + + /** + * The matcher for directories to exclude. This array does not include the parent directories, + * because they may contain other sub-trees that need to be included. + * This is an optimization for skipping whole directories when possible. + */ + private final PathMatcher[] dirExcludes; + + /** + * The base directory. All files will be relativized to that directory before to be matched. + */ + private final Path baseDirectory; + + /** + * Whether paths must be relativized before being given to a matcher. If {@code true}, then every paths + * will be made relative to {@link #baseDirectory} for allowing patterns like {@code "foo/bar/*.java"} + * to work. As a slight optimization, we can skip this step if all patterns start with {@code "**"}. + */ + private final boolean needRelativize; + + /** + * Creates a new selector from the given includes and excludes. + * + * @param directory the base directory of the files to filter + * @param includes the patterns of the files to include, or null or empty for including all files + * @param excludes the patterns of the files to exclude, or null or empty for no exclusion + * @throws NullPointerException if directory is null + */ + private PathSelector(@Nonnull Path directory, Collection includes, Collection excludes) { + baseDirectory = Objects.requireNonNull(directory, "directory cannot be null"); + includePatterns = normalizePatterns(includes, false); + excludePatterns = normalizePatterns(effectiveExcludes(excludes, includePatterns), true); + FileSystem fileSystem = baseDirectory.getFileSystem(); + this.includes = matchers(fileSystem, includePatterns); + this.excludes = matchers(fileSystem, excludePatterns); + dirIncludes = matchers(fileSystem, directoryPatterns(includePatterns, false)); + dirExcludes = matchers(fileSystem, directoryPatterns(excludePatterns, true)); + needRelativize = needRelativize(includePatterns) || needRelativize(excludePatterns); + } + + /** + * Creates a new matcher from the given includes and excludes. + * + * @param directory the base directory of the files to filter + * @param includes the patterns of the files to include, or null or empty for including all files + * @param excludes the patterns of the files to exclude, or null or empty for no exclusion + * @throws NullPointerException if directory is null + * @return a path matcher for the given includes and excludes + */ + public static PathMatcher of(@Nonnull Path directory, Collection includes, Collection excludes) { + return new PathSelector(directory, includes, excludes).simplify(); + } + + /** + * Returns the given array of excludes, optionally expanded with a default set of excludes, + * then with unnecessary excludes omitted. An unnecessary exclude is an exclude which will never + * match a file because there are no includes which would accept a file that could match the exclude. + * For example, if the only include is {@code "*.java"}, then the "**/project.pj", + * "**/.DS_Store" and other excludes will never match a file and can be omitted. + * Because the list of {@linkplain #DEFAULT_EXCLUDES default excludes} contains many elements, + * removing unnecessary excludes can reduce a lot the number of matches tested on each source file. + * + *

Implementation note

+ * The removal of unnecessary excludes is done on a best effort basis. The current implementation + * compares only the prefixes and suffixes of each pattern, keeping the pattern in case of doubt. + * This is not bad, but it does not remove all unnecessary patterns. It would be possible to do + * better in the future if benchmarking suggests that it would be worth the effort. + * + * @param excludes the user-specified excludes, potentially not yet converted to glob syntax + * @param includes the include patterns converted to glob syntax + * @return the potentially expanded or reduced set of excludes to use + */ + private static Collection effectiveExcludes(Collection excludes, final String[] includes) { + if (excludes == null || excludes.isEmpty()) { + return List.of(); + } else { + excludes = new ArrayList<>(excludes); + excludes.removeIf(Objects::isNull); + } + if (includes.length == 0) { + return excludes; + } + /* + * Get the prefixes and suffixes of all includes, stopping at the first special character. + * Redundant prefixes and suffixes are omitted. + */ + var prefixes = new String[includes.length]; + var suffixes = new String[includes.length]; + for (int i = 0; i < includes.length; i++) { + String include = includes[i]; + if (!include.startsWith(DEFAULT_SYNTAX)) { + return excludes; // Do not filter if at least one pattern is too complicated. + } + include = include.substring(DEFAULT_SYNTAX.length()); + prefixes[i] = prefixOrSuffix(include, false); + suffixes[i] = prefixOrSuffix(include, true); + } + prefixes = sortByLength(prefixes, false); + suffixes = sortByLength(suffixes, true); + /* + * Keep only the exclude which start with one of the prefixes and end with one of the suffixes. + * Note that a prefix or suffix may be the empty string, which match everything. + */ + final Iterator it = excludes.iterator(); + nextExclude: + while (it.hasNext()) { + final String exclude = it.next(); + final int s = exclude.indexOf(':'); + if (s <= MAVEN_SYNTAX_THRESHOLD || exclude.startsWith(DEFAULT_SYNTAX)) { + if (cannotMatch(exclude, prefixes, false) || cannotMatch(exclude, suffixes, true)) { + it.remove(); + } + } + } + return excludes; + } + + /** + * Returns the maximal amount of ordinary characters at the beginning or end of the given pattern. + * The prefix or suffix stops at the first {@linkplain #SPECIAL_CHARACTERS special character}. + * + * @param include the pattern for which to get a prefix or suffix without special character + * @param suffix {@code false} if a prefix is desired, or {@code true} if a suffix is desired + */ + private static String prefixOrSuffix(final String include, boolean suffix) { + int s = suffix ? -1 : include.length(); + for (int i = SPECIAL_CHARACTERS.length(); --i >= 0; ) { + char c = SPECIAL_CHARACTERS.charAt(i); + if (suffix) { + s = Math.max(s, include.lastIndexOf(c)); + } else { + int p = include.indexOf(c); + if (p >= 0 && p < s) { + s = p; + } + } + } + return suffix ? include.substring(s + 1) : include.substring(0, s); + } + + /** + * Returns {@code true} if the given exclude cannot match any include patterns. + * In case of doubt, returns {@code false}. + * + * @param exclude the exclude pattern to test + * @param fragments the prefixes or suffixes (fragments without special characters) of the includes + * @param suffix {@code false} if the specified fragments are prefixes, {@code true} if they are suffixes + * @return {@code true} if it is certain that the exclude pattern cannot match, or {@code false} in case of doubt + */ + private static boolean cannotMatch(String exclude, final String[] fragments, final boolean suffix) { + exclude = prefixOrSuffix(exclude, suffix); + for (String fragment : fragments) { + int fg = fragment.length(); + int ex = exclude.length(); + int length = Math.min(fg, ex); + if (suffix) { + fg -= length; + ex -= length; + } else { + fg = 0; + ex = 0; + } + if (exclude.regionMatches(ex, fragment, fg, length)) { + return false; + } + } + return true; + } + + /** + * Sorts the given patterns by their length. The main intent is to have the empty string first, + * while will cause the loops testing for prefixes and suffixes to stop almost immediately. + * Short prefixes or suffixes are also more likely to be matched. + * + * @param fragments the fragments to sort in-place + * @param suffix {@code false} if the specified fragments are prefixes, {@code true} if they are suffixes + * @return the given array, or a smaller array if some fragments were discarded because redundant + */ + private static String[] sortByLength(final String[] fragments, final boolean suffix) { + Arrays.sort(fragments, (s1, s2) -> s1.length() - s2.length()); + int count = 0; + /* + * Simplify the array of prefixes or suffixes by removing all redundant elements. + * An element is redundant if there is a shorter prefix or suffix with the same characters. + */ + nextBase: + for (String fragment : fragments) { + for (int i = count; --i >= 0; ) { + String base = fragments[i]; + if (suffix ? fragment.endsWith(base) : fragment.startsWith(base)) { + continue nextBase; // Skip this fragment + } + } + fragments[count++] = fragment; + } + return (fragments.length == count) ? fragments : Arrays.copyOf(fragments, count); + } + + /** + * Returns the given array of patterns with path separator normalized to {@code '/'}. + * Null or empty patterns are ignored, and duplications are removed. + * + * @param patterns the patterns to normalize + * @param excludes whether the patterns are exclude patterns + * @return normalized patterns without null, empty or duplicated patterns + */ + private static String[] normalizePatterns(final Collection patterns, final boolean excludes) { + if (patterns == null || patterns.isEmpty()) { + return new String[0]; + } + // TODO: use `LinkedHashSet.newLinkedHashSet(int)` instead with JDK19. + final var normalized = new LinkedHashSet(patterns.size()); + for (String pattern : patterns) { + if (pattern != null && !pattern.isEmpty()) { + if (pattern.indexOf(':') <= MAVEN_SYNTAX_THRESHOLD) { + pattern = pattern.replace(File.separatorChar, '/'); + if (pattern.endsWith("/")) { + pattern += "**"; + } + // Following are okay only when "**" means "0 or more directories". + while (pattern.endsWith("/**/**")) { + pattern = pattern.substring(0, pattern.length() - 3); + } + while (pattern.startsWith("**/**/")) { + pattern = pattern.substring(3); + } + pattern = pattern.replace("/**/**/", "/**/"); + + // Escape special characters, including braces + // Braces from user input must be literals; we'll inject our own braces for expansion below + pattern = pattern.replace("\\", "\\\\") + .replace("[", "\\[") + .replace("]", "\\]") + .replace("{", "\\{") + .replace("}", "\\}"); + + // Transform ** patterns to use brace expansion for POSIX behavior + // This replaces the complex addPatternsWithOneDirRemoved logic + // We perform this after escaping so that only these injected braces participate in expansion + pattern = pattern.replace("**/", "{**/,}"); + + normalized.add(DEFAULT_SYNTAX + pattern); + } else { + normalized.add(pattern); + } + } + } + return simplify(normalized, excludes); + } + + /** + * Applies some heuristic rules for simplifying the set of patterns, + * then returns the patterns as an array. + * + * @param patterns the patterns to simplify and return as an array + * @param excludes whether the patterns are exclude patterns + * @return the set content as an array, after simplification + */ + private static String[] simplify(Set patterns, boolean excludes) { + /* + * If the "**" pattern is present, it makes all other patterns useless. + * In the case of include patterns, an empty set means to include everything. + */ + if (patterns.remove("**")) { + patterns.clear(); + if (excludes) { + patterns.add("**"); + } + } + return patterns.toArray(String[]::new); + } + + /** + * Eventually adds the parent directory of the given patterns, without duplicated values. + * The patterns given to this method should have been normalized. + * + * @param patterns the normalized include or exclude patterns + * @param excludes whether the patterns are exclude patterns + * @return patterns of directories to include or exclude + */ + private static String[] directoryPatterns(final String[] patterns, final boolean excludes) { + // TODO: use `LinkedHashSet.newLinkedHashSet(int)` instead with JDK19. + final var directories = new LinkedHashSet(patterns.length); + for (String pattern : patterns) { + if (pattern.startsWith(DEFAULT_SYNTAX)) { + if (excludes) { + if (pattern.endsWith("/**")) { + directories.add(pattern.substring(0, pattern.length() - 3)); + } + } else { + int s = pattern.indexOf(':'); + if (pattern.regionMatches(++s, "**/", 0, 3)) { + s = pattern.indexOf('/', s + 3); + if (s < 0) { + return new String[0]; // Pattern is "**", so we need to accept everything. + } + directories.add(pattern.substring(0, s)); + } + } + } + } + return simplify(directories, excludes); + } + + /** + * Returns {@code true} if at least one pattern requires path being relativized before to be matched. + * + * @param patterns include or exclude patterns + * @return whether at least one pattern require relativization + */ + private static boolean needRelativize(String[] patterns) { + for (String pattern : patterns) { + if (!pattern.startsWith(DEFAULT_SYNTAX + "**/")) { + return true; + } + } + return false; + } + + /** + * Creates the path matchers for the given patterns. + * The syntax (usually {@value #DEFAULT_SYNTAX}) must be specified for each pattern. + */ + private static PathMatcher[] matchers(final FileSystem fs, final String[] patterns) { + final var matchers = new PathMatcher[patterns.length]; + for (int i = 0; i < patterns.length; i++) { + matchers[i] = fs.getPathMatcher(patterns[i]); + } + return matchers; + } + + /** + * {@return a potentially simpler matcher equivalent to this matcher}. + */ + @SuppressWarnings("checkstyle:MissingSwitchDefault") + private PathMatcher simplify() { + if (!needRelativize && excludes.length == 0) { + switch (includes.length) { + case 0: + return INCLUDES_ALL; + case 1: + return includes[0]; + } + } + return this; + } + + /** + * Determines whether a path is selected. + * This is true if the given file matches an include pattern and no exclude pattern. + * + * @param path the pathname to test, must not be {@code null} + * @return {@code true} if the given path is selected, {@code false} otherwise + */ + @Override + public boolean matches(Path path) { + if (needRelativize) { + path = baseDirectory.relativize(path); + } + return (includes.length == 0 || isMatched(path, includes)) + && (excludes.length == 0 || !isMatched(path, excludes)); + } + + /** + * {@return whether the given file matches according to one of the given matchers}. + */ + private static boolean isMatched(Path path, PathMatcher[] matchers) { + for (PathMatcher matcher : matchers) { + if (matcher.matches(path)) { + return true; + } + } + return false; + } + + /** + * Returns whether {@link #couldHoldSelected(Path)} may return {@code false} for some directories. + * This method can be used to determine if directory filtering optimization is possible. + * + * @return {@code true} if directory filtering is possible, {@code false} if all directories + * will be considered as potentially containing selected files + */ + boolean canFilterDirectories() { + return dirIncludes.length != 0 || dirExcludes.length != 0; + } + + /** + * Determines whether a directory could contain selected paths. + * + * @param directory the directory pathname to test, must not be {@code null} + * @return {@code true} if the given directory might contain selected paths, {@code false} if the + * directory will definitively not contain selected paths + */ + public boolean couldHoldSelected(Path directory) { + if (baseDirectory.equals(directory)) { + return true; + } + directory = baseDirectory.relativize(directory); + return (dirIncludes.length == 0 || isMatched(directory, dirIncludes)) + && (dirExcludes.length == 0 || !isMatched(directory, dirExcludes)); + } + + /** + * Appends the elements of the given array in the given buffer. + * This is a helper method for {@link #toString()} implementations. + * + * @param buffer the buffer to add the elements to + * @param label label identifying the array of elements to add + * @param patterns the elements to append, or {@code null} if none + */ + private static void append(StringBuilder buffer, String label, String[] patterns) { + buffer.append(label).append(": ["); + if (patterns != null) { + for (int i = 0; i < patterns.length; i++) { + if (i != 0) { + buffer.append(", "); + } + buffer.append(patterns[i]); + } + } + buffer.append(']'); + } + + /** + * {@return a string representation for logging purposes}. + */ + @Override + public String toString() { + var buffer = new StringBuilder(); + append(buffer, "includes", includePatterns); + append(buffer.append(", "), "excludes", excludePatterns); + return buffer.toString(); + } +} From cf2a27507dec34c1f4d711d0bd3795077ade1548 Mon Sep 17 00:00:00 2001 From: Martin Desruisseaux Date: Sat, 22 Nov 2025 11:32:50 +0100 Subject: [PATCH 2/5] Migrate from Maven Archiver to standard `jar` tool. Use `java.util.spi.ToolProvider` API (requires Java 9+). Files in the `classes` directories are dispatched to the `--manifest` and `--release` options, which allow additional verifications by the `jar` tool. Side effects: * Remove the default `**/package.html` exclude. * Automatic use of `META-INF/MANIFEST.MF` file found in `classes` directory. * Automatic Multi-Release always enabled, unless `detectMultiReleaseJar` is set to `false`. * In a multi-module project, use Java module names as artifact names. --- pom.xml | 22 +- .../verify.bsh | 3 +- src/it/MJAR-292-detect-mjar/pom.xml | 4 +- src/it/MJAR-292-disable-detect-mjar/pom.xml | 1 + src/it/MJAR-30-include/verify.groovy | 2 +- src/it/MJAR-70-recreation/verify.bsh | 2 +- src/it/mjar-71-01/pom.xml | 4 +- .../some-manifest.mf} | 0 src/it/mjar-71-01/verify.groovy | 16 +- src/it/mjar-71-02/pom.xml | 2 +- src/it/mjar-71-02/verify.groovy | 16 +- src/it/multi-module/pom.xml | 67 +++ .../foo.bar.more/main/java/module-info.java | 19 + .../foo.bar.more/main/java/more/MainFile.java | 29 + .../src/foo.bar/main/java/foo/MainFile.java | 29 + .../src/foo.bar/main/java/module-info.java | 19 + src/it/multi-module/verify.groovy | 62 ++ src/it/multirelease-with-modules/pom.xml | 102 ++++ .../foo.bar.more/main/java/module-info.java | 19 + .../foo.bar.more/main/java/more/MainFile.java | 29 + .../main/java/more/OtherFile.java | 29 + .../main/java_16/more/OtherFile.java | 30 + .../src/foo.bar/main/java/foo/MainFile.java | 29 + .../src/foo.bar/main/java/foo/OtherFile.java | 29 + .../foo.bar/main/java/foo/YetAnotherFile.java | 29 + .../src/foo.bar/main/java/module-info.java | 19 + .../foo.bar/main/java_16/foo/OtherFile.java | 34 ++ .../multirelease-with-modules/verify.groovy | 67 +++ .../maven/plugins/jar/AbstractJarMojo.java | 326 ++++++----- .../org/apache/maven/plugins/jar/Archive.java | 547 ++++++++++++++++++ .../apache/maven/plugins/jar/Archiver.java | 198 ------- .../maven/plugins/jar/DirectoryRole.java | 73 +++ .../maven/plugins/jar/FileCollector.java | 416 +++++++++++++ .../maven/plugins/jar/MetadataFiles.java | 221 +++++++ .../maven/plugins/jar/TimestampCheck.java | 239 ++++++++ .../maven/plugins/jar/ToolExecutor.java | 407 +++++++++++++ 36 files changed, 2768 insertions(+), 372 deletions(-) rename src/it/mjar-71-01/src/main/{resources/META-INF/MANIFEST.MF => my-custom-dir/some-manifest.mf} (100%) create mode 100644 src/it/multi-module/pom.xml create mode 100644 src/it/multi-module/src/foo.bar.more/main/java/module-info.java create mode 100644 src/it/multi-module/src/foo.bar.more/main/java/more/MainFile.java create mode 100644 src/it/multi-module/src/foo.bar/main/java/foo/MainFile.java create mode 100644 src/it/multi-module/src/foo.bar/main/java/module-info.java create mode 100644 src/it/multi-module/verify.groovy create mode 100644 src/it/multirelease-with-modules/pom.xml create mode 100644 src/it/multirelease-with-modules/src/foo.bar.more/main/java/module-info.java create mode 100644 src/it/multirelease-with-modules/src/foo.bar.more/main/java/more/MainFile.java create mode 100644 src/it/multirelease-with-modules/src/foo.bar.more/main/java/more/OtherFile.java create mode 100644 src/it/multirelease-with-modules/src/foo.bar.more/main/java_16/more/OtherFile.java create mode 100644 src/it/multirelease-with-modules/src/foo.bar/main/java/foo/MainFile.java create mode 100644 src/it/multirelease-with-modules/src/foo.bar/main/java/foo/OtherFile.java create mode 100644 src/it/multirelease-with-modules/src/foo.bar/main/java/foo/YetAnotherFile.java create mode 100644 src/it/multirelease-with-modules/src/foo.bar/main/java/module-info.java create mode 100644 src/it/multirelease-with-modules/src/foo.bar/main/java_16/foo/OtherFile.java create mode 100644 src/it/multirelease-with-modules/verify.groovy create mode 100644 src/main/java/org/apache/maven/plugins/jar/Archive.java delete mode 100644 src/main/java/org/apache/maven/plugins/jar/Archiver.java create mode 100644 src/main/java/org/apache/maven/plugins/jar/DirectoryRole.java create mode 100644 src/main/java/org/apache/maven/plugins/jar/FileCollector.java create mode 100644 src/main/java/org/apache/maven/plugins/jar/MetadataFiles.java create mode 100644 src/main/java/org/apache/maven/plugins/jar/TimestampCheck.java create mode 100644 src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java diff --git a/pom.xml b/pom.xml index 1986349d..326dfcc1 100644 --- a/pom.xml +++ b/pom.xml @@ -34,6 +34,26 @@ Apache Maven JAR Plugin Builds a Java Archive (JAR) file from the compiled project classes and resources. + + + evenisse + Emmanuel Venisse + evenisse@apache.org + + Java Developer + + + + desruisseaux + Martin Desruisseaux + desruisseaux@apache.org + Geomatys + + Java Developer + + +1 + + Jerome Lacoste @@ -208,7 +228,7 @@ - src/it/mjar-71-01/src/main/resources/META-INF/MANIFEST.MF + src/it/mjar-71-01/src/main/my-custom-dir/some-manifest.mf src/it/mjar-71-02/src/main/resources/META-INF/MANIFEST.MF diff --git a/src/it/MJAR-260-invalid-automatic-module-name/verify.bsh b/src/it/MJAR-260-invalid-automatic-module-name/verify.bsh index 452aed0e..7b39fb52 100644 --- a/src/it/MJAR-260-invalid-automatic-module-name/verify.bsh +++ b/src/it/MJAR-260-invalid-automatic-module-name/verify.bsh @@ -45,8 +45,7 @@ try String[] snippets = new String[] { "[INFO] BUILD FAILURE", "[ERROR] Failed to execute goal org.apache.maven.plugins:maven-jar-plugin", - "Caused by: org.apache.maven.api.plugin.MojoException: Error while assembling the JAR file.", - "Caused by: org.codehaus.plexus.archiver.jar.ManifestException: Invalid automatic module name: 'in-valid.name.with.new.keyword'" + "Caused by: org.apache.maven.api.plugin.MojoException: Invalid automatic module name: \"in-valid.name.with.new.keyword\"." }; System.out.println("\nVerifying log snippets..."); diff --git a/src/it/MJAR-292-detect-mjar/pom.xml b/src/it/MJAR-292-detect-mjar/pom.xml index 9c0afd52..d473e74c 100644 --- a/src/it/MJAR-292-detect-mjar/pom.xml +++ b/src/it/MJAR-292-detect-mjar/pom.xml @@ -86,8 +86,8 @@ false diff --git a/src/it/MJAR-292-disable-detect-mjar/pom.xml b/src/it/MJAR-292-disable-detect-mjar/pom.xml index 1cc61ea9..c868bdce 100644 --- a/src/it/MJAR-292-disable-detect-mjar/pom.xml +++ b/src/it/MJAR-292-disable-detect-mjar/pom.xml @@ -84,6 +84,7 @@ myproject.HelloWorld + false diff --git a/src/it/MJAR-30-include/verify.groovy b/src/it/MJAR-30-include/verify.groovy index 28d3e5f3..1b0b838c 100644 --- a/src/it/MJAR-30-include/verify.groovy +++ b/src/it/MJAR-30-include/verify.groovy @@ -67,7 +67,7 @@ try { String artifactName = artifactNames[i]; if ( !contents.contains( artifactName ) ) - { + { System.err.println( "Artifact[" + artifactName + "] not found in jar archive" ); return false; } diff --git a/src/it/MJAR-70-recreation/verify.bsh b/src/it/MJAR-70-recreation/verify.bsh index 29b80ca2..25da0f97 100644 --- a/src/it/MJAR-70-recreation/verify.bsh +++ b/src/it/MJAR-70-recreation/verify.bsh @@ -58,7 +58,7 @@ if ( buildLog.exists() ) { int jarPluginExecutions = 0; String[] lines = buildLogContent.split( "\n" ); for ( String line : lines ) { - if ( line.contains( "Building jar:" ) && line.contains( "MJAR-70-recreation-1.0-SNAPSHOT.jar" ) ) { + if ( line.contains( "Building JAR:" ) && line.contains( "MJAR-70-recreation-1.0-SNAPSHOT.jar" ) ) { jarPluginExecutions++; System.out.println( "Found JAR creation: " + line ); } diff --git a/src/it/mjar-71-01/pom.xml b/src/it/mjar-71-01/pom.xml index cb6b3195..d5b6de07 100644 --- a/src/it/mjar-71-01/pom.xml +++ b/src/it/mjar-71-01/pom.xml @@ -25,7 +25,7 @@ under the License. 1.0 jar it-mjar-71 - Test that the default manifest is added by default if found under target/classes. Can also be overriden. + Test that the specified manifest is used. http://maven.apache.org @@ -36,7 +36,7 @@ under the License. @project.version@ - src/main/resources/META-INF/MANIFEST.MF + src/main/my-custom-dir/some-manifest.mf diff --git a/src/it/mjar-71-01/src/main/resources/META-INF/MANIFEST.MF b/src/it/mjar-71-01/src/main/my-custom-dir/some-manifest.mf similarity index 100% rename from src/it/mjar-71-01/src/main/resources/META-INF/MANIFEST.MF rename to src/it/mjar-71-01/src/main/my-custom-dir/some-manifest.mf diff --git a/src/it/mjar-71-01/verify.groovy b/src/it/mjar-71-01/verify.groovy index 97f78e88..60faf65e 100644 --- a/src/it/mjar-71-01/verify.groovy +++ b/src/it/mjar-71-01/verify.groovy @@ -51,14 +51,14 @@ try // Only compare files if ( entry.getName().equals( "META-INF/MANIFEST.MF" ) ) { - String manifest = IOUtils.toString( jar.getInputStream ( entry ) ); - int index = manifest.indexOf( "Archiver-Version: foobar-1.23456" ); - if ( index <= 0 ) - { - System.err.println( "MANIFEST doesn't contain: 'Archiver-Version: foobar-1.23456'" ); - return false; - } - return true; + String manifest = IOUtils.toString( jar.getInputStream ( entry ) ); + int index = manifest.indexOf( "Archiver-Version: foobar-1.23456" ); + if ( index <= 0 ) + { + System.err.println( "MANIFEST doesn't contain: 'Archiver-Version: foobar-1.23456'" ); + return false; + } + return true; } } } diff --git a/src/it/mjar-71-02/pom.xml b/src/it/mjar-71-02/pom.xml index fa3adac7..3fd9b506 100644 --- a/src/it/mjar-71-02/pom.xml +++ b/src/it/mjar-71-02/pom.xml @@ -25,7 +25,7 @@ under the License. 1.0 jar it-mjar-71-02 - Test that the default manifest is not added when found under target/classes but support is disabled. + Test that the manifest found under target/classes is automatically used. http://maven.apache.org diff --git a/src/it/mjar-71-02/verify.groovy b/src/it/mjar-71-02/verify.groovy index 33e40531..ffddfcbf 100644 --- a/src/it/mjar-71-02/verify.groovy +++ b/src/it/mjar-71-02/verify.groovy @@ -51,14 +51,14 @@ try // Only compare files if ( entry.getName().equals ( "META-INF/MANIFEST.MF" ) ) { - String manifest = IOUtils.toString( jar.getInputStream ( entry ) ); - int index = manifest.indexOf( "Archiver-Version: foobar-1.23456" ); - if ( index > 0 ) - { - System.err.println( "MANIFEST contains: 'Archiver-Version: foobar-1.23456', but shouldn't" ); - return false; - } - return true; + String manifest = IOUtils.toString( jar.getInputStream ( entry ) ); + int index = manifest.indexOf( "Archiver-Version: foobar-1.23456" ); + if ( index <= 0 ) + { + System.err.println( "MANIFEST doesn't contain: 'Archiver-Version: foobar-1.23456'" ); + return false; + } + return true; } } } diff --git a/src/it/multi-module/pom.xml b/src/it/multi-module/pom.xml new file mode 100644 index 00000000..d4a781e9 --- /dev/null +++ b/src/it/multi-module/pom.xml @@ -0,0 +1,67 @@ + + + + 4.1.0 + + org.apache.maven.plugins + multi-module + 1.0-SNAPSHOT + jar + Multi-module + + + + + org.apache.maven.plugins + maven-compiler-plugin + 4.0.0-beta-3 + + + + + 17 + + + + org.apache.maven.plugins + maven-jar-plugin + @project.version@ + + + + true + foo.bar/foo.MainFile + + + + + + + + foo.bar + src/foo.bar/main/java + + + foo.bar.more + src/foo.bar.more/main/java + + + + diff --git a/src/it/multi-module/src/foo.bar.more/main/java/module-info.java b/src/it/multi-module/src/foo.bar.more/main/java/module-info.java new file mode 100644 index 00000000..778a3a4a --- /dev/null +++ b/src/it/multi-module/src/foo.bar.more/main/java/module-info.java @@ -0,0 +1,19 @@ +/* + * 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. + */ +module foo.bar.more {} diff --git a/src/it/multi-module/src/foo.bar.more/main/java/more/MainFile.java b/src/it/multi-module/src/foo.bar.more/main/java/more/MainFile.java new file mode 100644 index 00000000..d64f30a7 --- /dev/null +++ b/src/it/multi-module/src/foo.bar.more/main/java/more/MainFile.java @@ -0,0 +1,29 @@ +/* + * 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 more; + +/** + * Test {@code <Source>}. + * Another {@code <Source>}. + */ +public class MainFile { + public static void main(String[] args) { + System.out.println("MainFile of more"); + } +} diff --git a/src/it/multi-module/src/foo.bar/main/java/foo/MainFile.java b/src/it/multi-module/src/foo.bar/main/java/foo/MainFile.java new file mode 100644 index 00000000..502f2780 --- /dev/null +++ b/src/it/multi-module/src/foo.bar/main/java/foo/MainFile.java @@ -0,0 +1,29 @@ +/* + * 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 foo; + +/** + * Test {@code <Source>}. + * Another {@code <Source>}. + */ +public class MainFile { + public static void main(String[] args) { + System.out.println("MainFile"); + } +} diff --git a/src/it/multi-module/src/foo.bar/main/java/module-info.java b/src/it/multi-module/src/foo.bar/main/java/module-info.java new file mode 100644 index 00000000..38f61c0e --- /dev/null +++ b/src/it/multi-module/src/foo.bar/main/java/module-info.java @@ -0,0 +1,19 @@ +/* + * 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. + */ +module foo.bar {} diff --git a/src/it/multi-module/verify.groovy b/src/it/multi-module/verify.groovy new file mode 100644 index 00000000..df8756de --- /dev/null +++ b/src/it/multi-module/verify.groovy @@ -0,0 +1,62 @@ +/* + * 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. + */ + +import java.io.*; +import java.util.*; +import java.util.jar.*; + +File target = new File(basedir, "target"); + +Set content = new HashSet<>(); +content.add("module-info.class") +content.add("foo/MainFile.class") +content.add("META-INF/MANIFEST.MF") +content.add("META-INF/maven/org.apache.maven.plugins/multi-module/pom.xml") +content.add("META-INF/maven/org.apache.maven.plugins/multi-module/pom.properties") +verify(new File(target, "foo.bar-1.0-SNAPSHOT.jar"), content, "foo.MainFile") + +content.clear() +content.add("module-info.class") +content.add("more/MainFile.class") +content.add("META-INF/MANIFEST.MF") +content.add("META-INF/maven/org.apache.maven.plugins/multi-module/pom.xml") +content.add("META-INF/maven/org.apache.maven.plugins/multi-module/pom.properties") +verify(new File(target, "foo.bar.more-1.0-SNAPSHOT.jar"), content, null) + +void verify(File artifact, Set content, String mainClass) +{ + JarFile jar = new JarFile(artifact) + Enumeration jarEntries = jar.entries() + while (jarEntries.hasMoreElements()) + { + JarEntry entry = (JarEntry) jarEntries.nextElement() + if (!entry.isDirectory()) + { + String name = entry.getName() + assert content.remove(name) : "Missing entry: " + name + } + } + assert content.isEmpty() : "Unexpected entries: " + content + + Attributes attributes = jar.getManifest().getMainAttributes() + assert attributes.get(Attributes.Name.MULTI_RELEASE) == null + assert Objects.equals(mainClass, attributes.get(Attributes.Name.MAIN_CLASS)) + + jar.close(); +} diff --git a/src/it/multirelease-with-modules/pom.xml b/src/it/multirelease-with-modules/pom.xml new file mode 100644 index 00000000..5f02bbbe --- /dev/null +++ b/src/it/multirelease-with-modules/pom.xml @@ -0,0 +1,102 @@ + + + + 4.1.0 + + org.apache.maven.plugins + multirelease-with-modules + 1.0-SNAPSHOT + jar + Multirelease with modules + + + + + org.apache.maven.plugins + maven-compiler-plugin + 4.0.0-beta-3 + + + + + 17 + + + + + + org.apache.maven.plugins + maven-antrun-plugin + 3.1.0 + + + move + + run + + prepare-package + + + + + + + + + + + org.apache.maven.plugins + maven-jar-plugin + @project.version@ + + + + true + true + foo.bar/foo.MainFile + + + + + + + + foo.bar + src/foo.bar/main/java + 15 + + + foo.bar + src/foo.bar/main/java_16 + 16 + + + foo.bar.more + src/foo.bar.more/main/java + 15 + + + foo.bar.more + src/foo.bar.more/main/java_16 + 16 + + + + diff --git a/src/it/multirelease-with-modules/src/foo.bar.more/main/java/module-info.java b/src/it/multirelease-with-modules/src/foo.bar.more/main/java/module-info.java new file mode 100644 index 00000000..778a3a4a --- /dev/null +++ b/src/it/multirelease-with-modules/src/foo.bar.more/main/java/module-info.java @@ -0,0 +1,19 @@ +/* + * 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. + */ +module foo.bar.more {} diff --git a/src/it/multirelease-with-modules/src/foo.bar.more/main/java/more/MainFile.java b/src/it/multirelease-with-modules/src/foo.bar.more/main/java/more/MainFile.java new file mode 100644 index 00000000..d64f30a7 --- /dev/null +++ b/src/it/multirelease-with-modules/src/foo.bar.more/main/java/more/MainFile.java @@ -0,0 +1,29 @@ +/* + * 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 more; + +/** + * Test {@code <Source>}. + * Another {@code <Source>}. + */ +public class MainFile { + public static void main(String[] args) { + System.out.println("MainFile of more"); + } +} diff --git a/src/it/multirelease-with-modules/src/foo.bar.more/main/java/more/OtherFile.java b/src/it/multirelease-with-modules/src/foo.bar.more/main/java/more/OtherFile.java new file mode 100644 index 00000000..54e29b3c --- /dev/null +++ b/src/it/multirelease-with-modules/src/foo.bar.more/main/java/more/OtherFile.java @@ -0,0 +1,29 @@ +/* + * 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 more; + +/** + * Test {@code <Source>}. + * Another {@code <Source>}. + */ +public class OtherFile { + public static void main(String[] args) { + System.out.println("OtherFile of more"); + } +} diff --git a/src/it/multirelease-with-modules/src/foo.bar.more/main/java_16/more/OtherFile.java b/src/it/multirelease-with-modules/src/foo.bar.more/main/java_16/more/OtherFile.java new file mode 100644 index 00000000..4b21485e --- /dev/null +++ b/src/it/multirelease-with-modules/src/foo.bar.more/main/java_16/more/OtherFile.java @@ -0,0 +1,30 @@ +/* + * 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 more; + +/** + * Test {@code <Source>}. + * Another {@code <Source>}. + */ +public class OtherFile { + public static void main(String[] args) { + System.out.println("OtherFile of more on Java 16"); + MainFile.main(args); // Verify that we have access to the base version. + } +} diff --git a/src/it/multirelease-with-modules/src/foo.bar/main/java/foo/MainFile.java b/src/it/multirelease-with-modules/src/foo.bar/main/java/foo/MainFile.java new file mode 100644 index 00000000..502f2780 --- /dev/null +++ b/src/it/multirelease-with-modules/src/foo.bar/main/java/foo/MainFile.java @@ -0,0 +1,29 @@ +/* + * 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 foo; + +/** + * Test {@code <Source>}. + * Another {@code <Source>}. + */ +public class MainFile { + public static void main(String[] args) { + System.out.println("MainFile"); + } +} diff --git a/src/it/multirelease-with-modules/src/foo.bar/main/java/foo/OtherFile.java b/src/it/multirelease-with-modules/src/foo.bar/main/java/foo/OtherFile.java new file mode 100644 index 00000000..472210e1 --- /dev/null +++ b/src/it/multirelease-with-modules/src/foo.bar/main/java/foo/OtherFile.java @@ -0,0 +1,29 @@ +/* + * 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 foo; + +/** + * Test {@code <Source>}. + * Another {@code <Source>}. + */ +public class OtherFile { + public static void main(String[] args) { + System.out.println("OtherFile"); + } +} diff --git a/src/it/multirelease-with-modules/src/foo.bar/main/java/foo/YetAnotherFile.java b/src/it/multirelease-with-modules/src/foo.bar/main/java/foo/YetAnotherFile.java new file mode 100644 index 00000000..ab5f9009 --- /dev/null +++ b/src/it/multirelease-with-modules/src/foo.bar/main/java/foo/YetAnotherFile.java @@ -0,0 +1,29 @@ +/* + * 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 foo; + +/** + * Test {@code <Source>}. + * Another {@code <Source>}. + */ +public class YetAnotherFile { + public static void main(String[] args) { + System.out.println("YetAnotherFile"); + } +} diff --git a/src/it/multirelease-with-modules/src/foo.bar/main/java/module-info.java b/src/it/multirelease-with-modules/src/foo.bar/main/java/module-info.java new file mode 100644 index 00000000..38f61c0e --- /dev/null +++ b/src/it/multirelease-with-modules/src/foo.bar/main/java/module-info.java @@ -0,0 +1,19 @@ +/* + * 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. + */ +module foo.bar {} diff --git a/src/it/multirelease-with-modules/src/foo.bar/main/java_16/foo/OtherFile.java b/src/it/multirelease-with-modules/src/foo.bar/main/java_16/foo/OtherFile.java new file mode 100644 index 00000000..cbfa0b98 --- /dev/null +++ b/src/it/multirelease-with-modules/src/foo.bar/main/java_16/foo/OtherFile.java @@ -0,0 +1,34 @@ +/* + * 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 foo; + +/** + * Test {@code <Source>}. + * Another {@code <Source>}. + */ +public class OtherFile { + public static void main(String[] args) { + System.out.println("OtherFile on Java 16"); + MainFile.main(args); // Verify that we have access to the base version. + } + + static void requireJava16() { + System.out.println("Method available only on Java 16+"); + } +} diff --git a/src/it/multirelease-with-modules/verify.groovy b/src/it/multirelease-with-modules/verify.groovy new file mode 100644 index 00000000..d62d91f2 --- /dev/null +++ b/src/it/multirelease-with-modules/verify.groovy @@ -0,0 +1,67 @@ +/* + * 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. + */ + +import java.io.*; +import java.util.*; +import java.util.jar.*; + +File target = new File(basedir, "target"); + +Set content = new HashSet<>(); +content.add("module-info.class") +content.add("foo/MainFile.class") +content.add("foo/OtherFile.class") +content.add("foo/YetAnotherFile.class") +content.add("META-INF/versions/16/foo/OtherFile.class") +content.add("META-INF/MANIFEST.MF") +content.add("META-INF/maven/org.apache.maven.plugins/multirelease-with-modules/pom.xml") +content.add("META-INF/maven/org.apache.maven.plugins/multirelease-with-modules/pom.properties") +verify(new File(target, "foo.bar-1.0-SNAPSHOT.jar"), content, "foo.MainFile") + +content.clear() +content.add("module-info.class") +content.add("more/MainFile.class") +content.add("more/OtherFile.class") +content.add("META-INF/versions/16/more/OtherFile.class") +content.add("META-INF/MANIFEST.MF") +content.add("META-INF/maven/org.apache.maven.plugins/multirelease-with-modules/pom.xml") +content.add("META-INF/maven/org.apache.maven.plugins/multirelease-with-modules/pom.properties") +verify(new File(target, "foo.bar.more-1.0-SNAPSHOT.jar"), content, null) + +void verify(File artifact, Set content, String mainClass) +{ + JarFile jar = new JarFile(artifact) + Enumeration jarEntries = jar.entries() + while (jarEntries.hasMoreElements()) + { + JarEntry entry = (JarEntry) jarEntries.nextElement() + if (!entry.isDirectory()) + { + String name = entry.getName() + assert content.remove(name) : "Missing entry: " + name + } + } + assert content.isEmpty() : "Unexpected entries: " + content + + Attributes attributes = jar.getManifest().getMainAttributes() + assert Objects.equals("true", attributes.get(Attributes.Name.MULTI_RELEASE)) + assert Objects.equals(mainClass, attributes.get(Attributes.Name.MAIN_CLASS)) + + jar.close(); +} diff --git a/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java b/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java index ef167879..0fa813d9 100644 --- a/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java +++ b/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java @@ -21,12 +21,12 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.time.Instant; import java.util.Arrays; import java.util.List; import java.util.Map; -import java.util.Objects; -import java.util.jar.Attributes; -import java.util.stream.Stream; +import java.util.jar.Manifest; +import java.util.spi.ToolProvider; import org.apache.maven.api.ProducedArtifact; import org.apache.maven.api.Project; @@ -38,20 +38,22 @@ import org.apache.maven.api.services.ProjectManager; import org.apache.maven.shared.archiver.MavenArchiveConfiguration; import org.apache.maven.shared.archiver.MavenArchiver; -import org.apache.maven.shared.archiver.MavenArchiverException; -import org.codehaus.plexus.archiver.Archiver; -import org.codehaus.plexus.archiver.jar.JarArchiver; /** * Base class for creating a JAR file from project classes. * * @author Emmanuel Venisse + * @author Martin Desruisseaux */ public abstract class AbstractJarMojo implements org.apache.maven.api.plugin.Mojo { - - private static final String[] DEFAULT_EXCLUDES = new String[] {"**/package.html"}; - - private static final String[] DEFAULT_INCLUDES = new String[] {"**/**"}; + /** + * Identifier of the tool to use. This identifier shall match the identifier of a tool + * registered as a {@link ToolProvider}. By default, the {@code "jar"} tool is used. + * + * @since 4.0.0-beta-2 + */ + @Parameter(defaultValue = "jar", required = true) + private String toolId; /** * List of files to include. Specified as fileset patterns which are relative to the input directory whose contents @@ -68,23 +70,20 @@ public abstract class AbstractJarMojo implements org.apache.maven.api.plugin.Moj private String[] excludes; /** - * Directory containing the generated JAR. + * Directory containing the generated JAR files. */ @Parameter(defaultValue = "${project.build.directory}", required = true) private Path outputDirectory; /** - * Name of the generated JAR. + * Name of the generated JAR file. + * The default value is {@code "${project.build.finalName}"}, + * which itself defaults to {@code "${artifactId}-${version}"}. + * Ignored if the Maven sub-project to archive uses module hierarchy. */ @Parameter(defaultValue = "${project.build.finalName}", readonly = true) private String finalName; - /** - * The JAR archiver. - */ - @Inject - private Map archivers; - /** * The Maven project. */ @@ -108,9 +107,9 @@ public abstract class AbstractJarMojo implements org.apache.maven.api.plugin.Moj private ProjectManager projectManager; /** - * Require the jar plugin to build a new JAR even if none of the contents appear to have changed. - * By default, this plugin looks to see if the output JAR exists and inputs have not changed. - * If these conditions are true, the plugin skips creation of the JAR file. + * Require the jar plugin to build new JAR files even if none of the contents appear to have changed. + * By default, this plugin looks to see if the output JAR files exist and inputs have not changed. + * If these conditions are true, the plugin skips creation of the JAR files. * This does not work when other plugins, like the maven-shade-plugin, are configured to post-process the JAR. * This plugin can not detect the post-processing, and so leaves the post-processed JAR file in place. * This can lead to failures when those plugins do not expect to find their own output as an input. @@ -120,20 +119,23 @@ public abstract class AbstractJarMojo implements org.apache.maven.api.plugin.Moj * to {@code maven.jar.forceCreation}.

*/ @Parameter(property = "maven.jar.forceCreation", defaultValue = "false") - private boolean forceCreation; + protected boolean forceCreation; /** * Skip creating empty archives. */ @Parameter(defaultValue = "false") - private boolean skipIfEmpty; + protected boolean skipIfEmpty; /** * Timestamp for reproducible output archive entries. * This is either formatted as ISO 8601 extended offset date-time * (e.g. in UTC such as '2011-12-03T10:15:30Z' or with an offset '2019-10-05T20:37:42+06:00'), - * or as an integer representing seconds since the epoch - * (like SOURCE_DATE_EPOCH). + * or as an integer representing seconds since the Java epoch (January 1st, 1970). + * If not configured or disabled, + * the SOURCE_DATE_EPOCH + * environment variable is used as a fallback value, + * to ease forcing Reproducible Build externally when the build has not enabled it natively in POM. * * @since 3.2.0 */ @@ -141,14 +143,25 @@ public abstract class AbstractJarMojo implements org.apache.maven.api.plugin.Moj private String outputTimestamp; /** - * Whether to detect multi-release JAR files. - * If the JAR contains the {@code META-INF/versions} directory it will be detected as a multi-release JAR file - * ("MRJAR"), adding the {@code Multi-Release: true} attribute to the main section of the JAR {@code MANIFEST.MF}. + * Whether to detect multi-release JAR files. + * If the JAR contains the {@code META-INF/versions} directory it will be detected as a multi-release JAR file, + * adding the {@code Multi-Release: true} attribute to the main section of the JAR {@code MANIFEST.MF} entry. + * In addition, the class files in {@code META-INF/versions} will be checked for API compatibility + * with the class files in the base version. If this flag is {@code false}, then the {@code META-INF/versions} + * directories are included without processing. * * @since 3.4.0 */ @Parameter(property = "maven.jar.detectMultiReleaseJar", defaultValue = "true") - private boolean detectMultiReleaseJar; + protected boolean detectMultiReleaseJar; + + /** + * Specifies whether to attach the JAR files to the project. + * + * @since 4.0.0-beta-2 + */ + @Parameter(property = "maven.jar.attach", defaultValue = "true") + protected boolean attach; /** * The MOJO logger. @@ -162,22 +175,19 @@ public abstract class AbstractJarMojo implements org.apache.maven.api.plugin.Moj protected AbstractJarMojo() {} /** - * Specifies whether to attach the jar to the project - * - * @since 4.0.0-beta-2 + * {@return the specific output directory to serve as the root for the archive} */ - @Parameter(property = "maven.jar.attach", defaultValue = "true") - protected boolean attach; + protected abstract Path getClassesDirectory(); /** - * {@return the specific output directory to serve as the root for the archive} + * {@return the directory containing the generated JAR files} */ - protected abstract Path getClassesDirectory(); + protected Path getOutputDirectory() { + return outputDirectory; + } /** - * Return the {@linkplain #project Maven project}. - * - * @return the Maven project + * {@return the Maven project} */ protected final Project getProject() { return project; @@ -186,12 +196,19 @@ protected final Project getProject() { /** * {@return the MOJO logger} */ - protected final Log getLog() { + protected Log getLog() { return log; } /** - * {@return the classifier of the JAR file to produce} + * {@return the name of the generated JAR file} + */ + protected String getFinalName() { + return finalName; + } + + /** + * {@return the classifier of the JAR file to produce} * This is usually null or empty for the main artifact, or {@code "tests"} for the JAR file of test code. */ protected abstract String getClassifier(); @@ -203,151 +220,164 @@ protected final Log getLog() { protected abstract String getType(); /** - * Returns the JAR file to generate, based on an optional classifier. + * {@return the JAR tool to use for archiving the code} * - * @param basedir the output directory - * @param resultFinalName the name of the ear file - * @param classifier an optional classifier - * @return the file to generate + * @throws MojoException if no JAR tool was found + * + * @since 4.0.0-beta-2 */ - protected Path getJarFile(Path basedir, String resultFinalName, String classifier) { - Objects.requireNonNull(basedir, "basedir is not allowed to be null"); - Objects.requireNonNull(resultFinalName, "finalName is not allowed to be null"); - String fileName = resultFinalName + (hasClassifier(classifier) ? '-' + classifier : "") + ".jar"; - return basedir.resolve(fileName); + protected ToolProvider getJarTool() throws MojoException { + return ToolProvider.findFirst(toolId).orElseThrow(() -> new MojoException("No such \"" + toolId + "\" tool.")); } /** - * Generates the JAR file. + * Returns the output time stamp or, as a fallback, the {@code SOURCE_DATE_EPOCH} environment variable. + * If the time stamp is expressed in seconds, it is converted to ISO 8601 format. Otherwise it is returned as-is. * - * @return the path to the created archive file - * @throws IOException in case of an error while reading a file or writing in the JAR file + * @return the time stamp in presumed ISO 8601 format, or {@code null} if none + * + * @since 4.0.0-beta-2 */ - @SuppressWarnings("checkstyle:UnusedLocalVariable") // Checkstyle bug: does not detect `includedFiles` usage. - public Path createArchive() throws IOException { - String archiverName = "jar"; - final Path jarFile = getJarFile(outputDirectory, finalName, getClassifier()); - final Path classesDirectory = getClassesDirectory(); - if (Files.exists(classesDirectory)) { - var includedFiles = new org.apache.maven.plugins.jar.Archiver( - classesDirectory, - (includes != null) ? Arrays.asList(includes) : List.of(), - (excludes != null && excludes.length > 0) - ? Arrays.asList(excludes) - : List.of(AbstractJarMojo.DEFAULT_EXCLUDES)); - - var scanner = includedFiles.new VersionScanner(detectMultiReleaseJar); - if (Files.exists(classesDirectory)) { - Files.walkFileTree(classesDirectory, scanner); + protected String getOutputTimestamp() { + String time = nullIfAbsent(outputTimestamp); + if (time == null) { + time = nullIfAbsent(System.getenv("SOURCE_DATE_EPOCH")); + if (time == null) { + return null; } - if (detectMultiReleaseJar && scanner.detectedMultiReleaseJAR) { - getLog().debug("Adding 'Multi-Release: true' manifest entry."); - archive.addManifestEntry(Attributes.Name.MULTI_RELEASE.toString(), "true"); - } - if (scanner.containsModuleDescriptor) { - archiverName = "mjar"; + } + for (int i = time.length(); --i >= 0; ) { + char c = time.charAt(i); + if ((c < '0' || c > '9') && (i != 0 || c != '-')) { + return time; } } - MavenArchiver archiver = new MavenArchiver(); - archiver.setCreatedBy("Maven JAR Plugin", "org.apache.maven.plugins", "maven-jar-plugin"); - archiver.setArchiver((JarArchiver) archivers.get(archiverName)); - archiver.setOutputFile(jarFile.toFile()); + return Instant.ofEpochSecond(Long.parseLong(time)).toString(); + } - // configure for Reproducible Builds based on outputTimestamp value - archiver.configureReproducibleBuild(outputTimestamp); + /** + * {@return the patterns of files to include, or an empty list if no include pattern was specified} + */ + protected List getIncludes() { + return asList(includes); + } - archive.setForced(forceCreation); + /** + * {@return the patterns of files to exclude, or an empty list if no exclude pattern was specified} + */ + protected List getExcludes() { + return asList(excludes); + } - Path contentDirectory = getClassesDirectory(); - if (!Files.exists(contentDirectory)) { - if (!forceCreation) { - getLog().warn("JAR will be empty - no content was marked for inclusion!"); + /** + * Returns the given elements as a list if non-null. + * + * @param elements the elements, or {@code null} + * @return the elements as a list, or {@code null} if the given array was null + */ + private static List asList(String[] elements) { + return (elements == null) ? List.of() : Arrays.asList(elements); + } + + /** + * Generates the JAR files. + * Map keys are module names or {@code null} if the project does not use module hierarchy. + * Values are paths to the JAR file associated with each module. + * + *

Note that a null key does not necessarily means that the JAR is not modular. + * It only means that the project was not compiled with module hierarchy, + * i.e. {@code target/classes/} subdirectories having module names. + * A project can be compiled with package hierarchy and still be modular.

+ * + * @return the paths to the created archive files + * @throws IOException if an error occurred while walking the file tree + * @throws MojoException if an error occurred while writing a JAR file + */ + public Map createArchives() throws IOException, MojoException { + final Path classesDirectory = getClassesDirectory(); + final boolean notExists = Files.notExists(classesDirectory); + if (notExists) { + if (forceCreation) { + getLog().warn("No JAR created because no content was marked for inclusion."); + } + if (skipIfEmpty) { + getLog().info(String.format("Skipping packaging of the %s.", getType())); + return Map.of(); } - } else { - archiver.getArchiver().addDirectory(contentDirectory.toFile(), getIncludes(), getExcludes()); } - archiver.createArchive(session, project, archive); - return jarFile; + archive.setForced(forceCreation); + // TODO: we want a null manifest if there is no configuration. + Manifest manifest = new MavenArchiver().getManifest(session, project, archive); + var executor = new ToolExecutor(this, manifest, archive); + var files = new FileCollector(this, executor, classesDirectory); + if (!notExists) { + Files.walkFileTree(classesDirectory, files); + } + return executor.writeAllJARs(files); } /** - * Generates the JAR. + * Generates the JAR file, then attaches the artifact. * * @throws MojoException in case of an error */ @Override + @SuppressWarnings("UseSpecificCatch") public void execute() throws MojoException { - if (skipIfEmpty && isEmpty(getClassesDirectory())) { - getLog().info(String.format("Skipping packaging of the %s.", getType())); - } else { - Path jarFile; - try { - jarFile = createArchive(); - } catch (Exception e) { - throw new MojoException("Error while assembling the JAR file.", e); - } - ProducedArtifact artifact; - String classifier = getClassifier(); - if (attach) { - if (hasClassifier(classifier)) { + final Map jarFiles; + try { + jarFiles = createArchives(); + } catch (MojoException e) { + throw e; + } catch (Exception e) { + throw new MojoException("Error while assembling the JAR file.", e); + } + if (jarFiles.isEmpty()) { + // Message already logged by `createArchives()`. + return; + } + if (attach) { + final String classifier = nullIfAbsent(getClassifier()); + for (Map.Entry entry : jarFiles.entrySet()) { + String moduleName = entry.getKey(); + ProducedArtifact artifact; + if (moduleName == null && classifier == null) { + if (projectHasAlreadySetAnArtifact()) { + throw new MojoException("You have to use a classifier " + + "to attach supplemental artifacts to the project instead of replacing them."); + } + artifact = project.getMainArtifact().orElseThrow(); + } else { + /* + * TODO: we need to generate artifact with dependencies filtered from the module-info. + */ artifact = session.createProducedArtifact( project.getGroupId(), - project.getArtifactId(), + (moduleName != null) ? moduleName : project.getArtifactId(), project.getVersion(), classifier, null, getType()); - } else { - if (projectHasAlreadySetAnArtifact()) { - throw new MojoException("You have to use a classifier " - + "to attach supplemental artifacts to the project instead of replacing them."); - } - artifact = project.getMainArtifact().get(); } - projectManager.attachArtifact(project, artifact, jarFile); - } else { - getLog().debug("Skipping attachment of the " + getType() + " artifact to the project."); + projectManager.attachArtifact(project, artifact, entry.getValue()); } + } else { + getLog().debug("Skipping attachment of the " + getType() + " artifact to the project."); } } - private static boolean isEmpty(Path directory) { - if (!Files.isDirectory(directory)) { - return true; - } - try (Stream children = Files.list(directory)) { - return children.findAny().isEmpty(); - } catch (IOException e) { - throw new MavenArchiverException("Unable to access directory", e); - } - } - + /** + * Verifies whether the main artifact is already set. + * This verification does not apply for module hierarchy, where more than one artifact is produced. + */ private boolean projectHasAlreadySetAnArtifact() { - Path path = projectManager.getPath(project).orElse(null); - return path != null && Files.isRegularFile(path); + return projectManager.getPath(project).filter(Files::isRegularFile).isPresent(); } /** - * Return {@code true} in case where the classifier is not {@code null} and contains something else than white spaces. - * - * @param classifier the classifier to verify - * @return {@code true} if the classifier is set + * Returns the given value if non-null, non-empty and non-blank, or {@code null} otherwise. */ - private static boolean hasClassifier(String classifier) { - return classifier != null && !classifier.isBlank(); - } - - private String[] getIncludes() { - if (includes != null && includes.length > 0) { - return includes; - } - return DEFAULT_INCLUDES; - } - - private String[] getExcludes() { - if (excludes != null && excludes.length > 0) { - return excludes; - } - return DEFAULT_EXCLUDES; + static String nullIfAbsent(String value) { + return (value == null || value.isBlank()) ? null : value; } } diff --git a/src/main/java/org/apache/maven/plugins/jar/Archive.java b/src/main/java/org/apache/maven/plugins/jar/Archive.java new file mode 100644 index 00000000..feb7c785 --- /dev/null +++ b/src/main/java/org/apache/maven/plugins/jar/Archive.java @@ -0,0 +1,547 @@ +/* + * 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.maven.plugins.jar; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.jar.Attributes; +import java.util.jar.Manifest; + +import org.apache.maven.api.annotations.Nonnull; +import org.apache.maven.api.annotations.Nullable; +import org.apache.maven.api.plugin.Log; + +/** + * Files or root directories to archive for a single module. + * A single instance of {@code Archive} may contain many directories for different target Java releases. + * Many instances of {@code Archive} may exist when archiving a multi-modules project. + */ +final class Archive { + /** + * The JAR file to create. May be an existing file, + * in which case the file creation may be skipped if the file is still up-to-date. + */ + @Nonnull + final Path jarFile; + + /** + * A helper class for checking whether an existing JAR file is still up-to-date. + * This is null if there is no existing JAR file, or if we determined that the file is outdated. + */ + private TimestampCheck existingJAR; + + /** + * Name of the module being archived when the project is using module hierarchy. + * This is {@code null} if the project is using package hierarchy, either because it is a classical + * class-path project or because it is a single module compiled without using the module hierarchy. + * When using module source hierarchy, {@code javac} guarantees that the module name in the output + * directory is the name of the parent directory of {@code module-info.class}. + */ + @Nullable + final String moduleName; + + /** + * Path to {@code META-INF/MANIFEST.MF}, or {@code null} if none. The manifest file + * should be included by the {@code --manifest} option instead of as an ordinary file. + */ + @Nullable + private Path manifest; + + /** + * The Maven generated {@code pom.xml} and {@code pom.properties} files, or {@code null} if none. + * This first item shall be the base directory where the files are located. + */ + @Nullable + List mavenFiles; + + /** + * Fully-qualified name of the main class, or {@code null} if none. + * This is the value to provide to the {@code --main-class} option. + */ + private String mainClass; + + /** + * Files or root directories to store in the JAR file for targeting the base Java release. + */ + @Nonnull + final FileSet baseRelease; + + /** + * Files or root directories to store in the JAR file for each target Java release + * other than the base release. + * + *

Note on duplicated versions

+ * In principle, we should not have two elements with the same {@link FileSet#version} value. + * However, while it should not happen in default Maven builds, we do not forbid the case where + * the same version would be defined in {@code "./META-INF"} and {@code ".//META-INF"}. + * In such case, two {@code FileSet} instances would exist for the same Java release but with + * two different {@link FileSet#directory} values. + */ + @Nonnull + private final List additionalReleases; + + /** + * Files or root directories to archive for a single target Java release of a single module. + * The {@link Archive} enclosing shall contain at least one instance of {@code FileSet} for + * the base release, and an arbitrary amount of other instances for other target releases. + */ + final class FileSet { + /** + * The target Java release, or {@code null} for the base version of the JAR file. + */ + @Nullable + final Runtime.Version version; + + /** + * The root directory of all files or directories to archive. + * This is the value to pass to the {@code -C} tool option. + */ + @Nonnull + private final Path directory; + + /** + * The files or directories to include in the JAR file. + * May be absolute paths or paths relative to {@link #directory}. + */ + @Nonnull + private final List files; + + /** + * Creates an initially empty set of files or directories for the specified target Java release. + * + * @param directory the base directory of the files or directories to archive + * @param version the target Java release, or {@code null} for the base version of the JAR file + */ + private FileSet(final Path directory, final Runtime.Version version) { + this.version = version; + this.directory = directory; + this.files = new ArrayList<>(); + } + + /** + * Finds a common directory for all remaining files, then clears the list of file. + * The common directory can be used for logging a warning message. + * + * @param base base directory found by previous invocations of this method, or {@code null} if none + * @return common directory of remaining files + */ + private Path clear(Path base) { + for (Path file : files) { + base = findCommonBaseDirectory(base, directory.resolve(file)); + } + files.clear(); + return base; + } + + /** + * Returns files as values, together with the base directory (as key) for resolving relative files. + */ + private Map.Entry> files() { + return Map.entry(directory, files); + } + + /** + * Adds the given path to the list of files or directories to archive. + * This method may store a relative path instead of the absolute path. + * + * @param item a file or directory to archive + * @param attributes the file's basic attributes + * @param isDirectory whether the file is a directory + * @throws IllegalArgumentException if the given path cannot be made relative to the base directory + */ + void add(Path item, final BasicFileAttributes attributes, final boolean isDirectory) { + TimestampCheck tc = existingJAR; + if (tc != null && tc.isUpdated(item, attributes, isDirectory)) { + existingJAR = null; // Signal that the existing file is outdated. + } + if (files.isEmpty()) { + /* + * In our tests, it seems that the first file after the "-C" option needs to be relative + * to the directory given to "-C" and all other files need to be absolute. This behavior + * does not seem to be documented, but we couldn't get the "jar" tool to work otherwise + * (except by repeating "-C" before each file). + */ + item = directory.relativize(item); + } + files.add(item); + } + + /** + * Adds to the given list the arguments to provide to the "jar" tool for this version. + * Elements added to the list shall be instances of {@link String} or {@link Path}. + * + * @param addTo the list where to add the arguments as {@link String} or {@link Path} instances + * @param versioned whether to add arguments for the version specified by {@linkplain #version} + * @return whether at least one file has been added as argument + */ + private boolean arguments(final List addTo, final boolean versioned) { + if (files.isEmpty()) { + // Happen if both `FileCollector.moduleHierarchy` and `FileCollector.packageHierarchy` are empty. + return false; + } + if (versioned && version != null) { + addTo.add("--release"); + addTo.add(version); + } + addTo.add("-C"); + addTo.add(directory); + addTo.addAll(files); + return true; + } + + /** + * {@return a string representation for debugging purposes} + */ + @Override + public String toString() { + return getClass().getSimpleName() + '[' + (version != null ? version : "base") + " = " + + directory.getFileName() + ']'; + } + } + + /** + * Creates an initially empty set of files or directories. + * + * @param jarFile path to the JAR file to create + * @param moduleName the module name if using module hierarchy, or {@code null} if using package hierarchy + * @param directory the directory of the classes targeting the base Java release + * @param forceCreation whether to force new JAR file even the contents seem unchanged. + * @param logger where to send a warning if an error occurred while checking an existing JAR file + */ + Archive(final Path jarFile, final String moduleName, final Path directory, boolean forceCreation, Log logger) { + this.jarFile = jarFile; + this.moduleName = moduleName; + baseRelease = new FileSet(directory, null); + additionalReleases = new ArrayList<>(); + if (!forceCreation && Files.isRegularFile(jarFile)) { + try { + existingJAR = new TimestampCheck(jarFile, directory(), logger); + } catch (IOException e) { + // Ignore, we will regenerate the JAR file. + logger.warn(e); + } + } + } + + /** + * Returns the root directory of all files or directories to archive for this module. + * + * @return the root directory of this module. + */ + public Path directory() { + return baseRelease.directory; + } + + /** + * Finds a common directory for all remaining files, then clears the list of file. + * The common directory can be used for logging a warning message. + * + * @return common directory of remaining files + */ + Path clear() { + Path base = baseRelease.clear(null); + for (FileSet release : additionalReleases) { + base = release.clear(base); + } + return (base != null) ? base : directory(); + } + + /** + * Returns a directory which is the base of the given {@code file}. + * This method returns either {@code base}, or a parent of {@code base}, or {@code null}. + * + * @param base the last base directory found, or {@code null} + * @param file the file for which to find a common base directory + * @return {@code base}, or a parent of {@code base}, or {@code null} + */ + private static Path findCommonBaseDirectory(Path base, Path file) { + if (base == null) { + base = file.getParent(); + } else { + while (!file.startsWith(base)) { + base = base.getParent(); + if (base == null) { + break; + } + } + } + return base; + } + + /** + * Returns whether this module can be skipped. This is {@code true} if this module has no file to archive, + * ignoring Maven-generated files, and {@code skipIfEmpty} is {@code true}. This method should be invoked + * even in the trivial case where the {@code skipIfEmpty} argument is {@code false}. + * + * @param skipIfEmpty value of {@link AbstractJarMojo#skipIfEmpty} + * @return whether this module can be skipped + */ + public boolean canSkip(final boolean skipIfEmpty) { + additionalReleases.removeIf((v) -> v.files.isEmpty()); + return skipIfEmpty && baseRelease.files.isEmpty() && additionalReleases.isEmpty(); + } + + /** + * Checks whether the JAR file already exists and can be reused. + * This method verifies that the JAR file contains all the files to archive, + * contains no extra file, and no file to archive is newer than the JAR file. + * + *

This method can be invoked only once. + * If invoked more often, it returns {@code false} on all subsequent invocations.

+ * + * @return whether the JAR file already exists and can be reused + */ + public boolean isUpToDateJAR() { + final TimestampCheck tc = existingJAR; + if (tc == null) { + return false; + } + existingJAR = null; // Let GC do its job. + var fileSets = new ArrayList>>(additionalReleases.size() + 1); + fileSets.add(baseRelease.files()); + additionalReleases.forEach((release) -> fileSets.add(release.files())); + return tc.isUpToDateJAR(fileSets); + } + + /** + * Returns an initially empty set of files or directories for the specified target Java release. + * + * @param directory the base directory of the files to archive + * @param version the target Java release, or {@code null} for the base version + * @return container where to declare files and directories to archive + */ + FileSet newTargetRelease(Path directory, Runtime.Version version) { + var release = new FileSet(directory, version); + additionalReleases.add(release); + return release; + } + + /** + * Sets the {@code --main-class} option to the value of the {@code Main-Class} entry of the given manifest. + * As an extension, this method accepts the {@code module/classname} syntax (a syntax already used in some + * Java tools). If a module is specified, the main class is kept only if the module match. The intent is to + * allow users to specify on which module the main class applies when they use plugin configuration. + * + * @param content combination of existing {@code MANIFEST.MF} and manifest inferred from configuration, or null + * @return whether the given manifest has been modified by this method + */ + boolean setMainClass(Manifest content) { + if (content == null || mainClass != null) { + return false; + } + // We need to remove the attribute, otherwise it will conflict with `--main-class`. + mainClass = (String) content.getMainAttributes().remove(Attributes.Name.MAIN_CLASS); + if (mainClass != null) { + int s = mainClass.indexOf('/'); + if (s >= 0) { + if (mainClass.substring(0, s).strip().equals(moduleName)) { + mainClass = mainClass.substring(s + 1).strip(); + } else { + mainClass = null; // Main class is defined for another module. + } + } + } + return mainClass != null; + } + + /** + * Sets the {@code --manifest} option to the given value if that option was not already set. + * + * @param file path to the manifest file + * @param force whether to set the manifest even if already set + * @return whether the manifest has been set + */ + boolean setManifest(Path file, boolean force) { + if (manifest == null || force) { + manifest = file; + return true; + } + return false; + } + + /** + * Merges the manifest of this module with the manifest specified in plugin configuration. + * If both {@code file} and {@code content} are non-null, then {@code content} must be the + * result of reading {@code file}. + * + *

This method never modifies the given {@code content} object. If manifest are merged, + * a new {@link Manifest} instance is created. Therefore, caller can check whether this + * method returned a new instance as a way to recognize that a merge occurred.

+ * + *

If a merge occurs, the content specified to {@link #setManifest(Path)} has precedence. + * It should be the {@code target/classes/META-INF/MANIFEST.MF} file (or modular equivalent).

+ * + * @param file an additional manifest file, or {@code null} + * @param content the content of {@code file}, or a standalone manifest produced at runtime + * @return the merged manifest as a new instance if some changes were necessary + * @throws IOException if an error occurred while reading a manifest file + */ + Manifest mergeManifest(Path file, Manifest content) throws IOException { + if (manifest == null) { + manifest = file; + } else if (file != null && Files.isSameFile(file, manifest)) { + // Nothing to merge because of the constraint that `content` must be the content of `file`. + } else { + try (InputStream in = Files.newInputStream(manifest)) { + // No need to wrap in `BufferedInputStream`. + if (content != null) { + content = new Manifest(content); + content.read(in); + } else { + content = new Manifest(in); + } + } + } + return content; + } + + /** + * Adds to the given list the arguments to provide to the "jar" tool for each version. + * Elements added to the list shall be instances of {@link String} or {@link Path}. + * Callers should have added the following options (if applicable) before to invoke this method: + * + *
    + *
  • {@code --create}
  • + *
  • {@code --no-compress}
  • + *
  • {@code --date} followed by the output time stamp
  • + *
  • {@code --module-version} followed by module version
  • + *
  • {@code --hash-modules} followed by patters of module names
  • + *
  • {@code --module-path} followed by module path
  • + *
+ * + * This method adds the following options: + * + *
    + *
  • {@code --file} followed by the name of the JAR file
  • + *
  • {@code --manifest} followed by path to the manifest file
  • + *
  • {@code --main-class} followed by fully qualified name class
  • + *
  • {@code --release} followed by Java target release
  • + *
  • {@code -C} followed by directory
  • + *
  • files or directories to archive
  • + *
+ * + * @param addTo the list where to add the arguments as {@link String} or {@link Path} instances + */ + @SuppressWarnings("checkstyle:NeedBraces") + void arguments(final List addTo) { + addTo.add("--file"); + addTo.add(jarFile); + if (manifest != null) { + addTo.add("--manifest"); + addTo.add(manifest); + } + if (mainClass != null) { + addTo.add("--main-class"); + addTo.add(mainClass); + } + if (mavenFiles != null) { + addTo.add("-C"); + addTo.addAll(mavenFiles); + } + // Sort by increasing release version. + additionalReleases.sort((f1, f2) -> { + Runtime.Version v1 = f1.version; + Runtime.Version v2 = f2.version; + if (v1 != v2) { + if (v1 == null) return -1; + if (v2 == null) return +1; + int c = v1.compareTo(v2); + if (c != 0) return c; + } + // Give precedence to directories closer to the root. + return f1.directory.getNameCount() - f2.directory.getNameCount(); + }); + boolean versioned = baseRelease.arguments(addTo, false); + for (FileSet release : additionalReleases) { + versioned |= release.arguments(addTo, versioned); + } + } + + /** + * Dumps the tool options together with the list of files into a debug file. + * This is invoked in case of compilation failure, or if debug is enabled. + * The arguments can be separated by spaces or by new line characters. + * File name should be between double quotation marks. + * + * @param baseDir project base directory for relativizing the arguments + * @param debugDirectory the directory where to write the debug file + * @param classifier the classifier (e.g. "tests"), or {@code null} if none + * @param arguments the arguments formatted by {@link #arguments(List)} + * @return the debug file where arguments have been written + * @throws IOException if an error occurred while writing the debug file + */ + Path writeDebugFile(Path baseDir, Path debugDirectory, String classifier, List arguments) + throws IOException { + var filename = new StringBuilder("jar"); + if (moduleName != null) { + filename.append('-').append(moduleName); + } + if (classifier != null) { + filename.append('-').append(classifier); + } + Path debugFile = debugDirectory.resolve(filename.append(".args").toString()); + try (BufferedWriter out = Files.newBufferedWriter(debugFile)) { + boolean isNewLine = true; + for (Object argument : arguments) { + if (argument instanceof Path file) { + try { + file = baseDir.relativize(file); + } catch (IllegalArgumentException e) { + // Ignore, keep the absolute path. + } + if (!isNewLine) { + out.write(' '); + } + out.write('"'); + out.write(file.toString()); + out.write('"'); + out.newLine(); + isNewLine = true; + } else { + String option = argument.toString(); + if (!isNewLine) { + if (option.startsWith("--") || option.equals("-C")) { + out.newLine(); + } else { + out.write(' '); + } + } + out.write(option); + isNewLine = false; + } + } + } + return debugFile; + } + + /** + * {@return a string representation for debugging purposes} + */ + @Override + public String toString() { + return getClass().getSimpleName() + '[' + (moduleName != null ? moduleName : "no module") + ']'; + } +} diff --git a/src/main/java/org/apache/maven/plugins/jar/Archiver.java b/src/main/java/org/apache/maven/plugins/jar/Archiver.java deleted file mode 100644 index d44dc415..00000000 --- a/src/main/java/org/apache/maven/plugins/jar/Archiver.java +++ /dev/null @@ -1,198 +0,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. - */ -package org.apache.maven.plugins.jar; - -import java.io.IOException; -import java.nio.file.FileVisitResult; -import java.nio.file.Path; -import java.nio.file.PathMatcher; -import java.nio.file.SimpleFileVisitor; -import java.nio.file.attribute.BasicFileAttributes; -import java.util.Collection; - -import org.apache.maven.api.annotations.Nonnull; - -/** - * Creates the JAR file. - * - * TODO: this is a work in progress. - */ -final class Archiver extends SimpleFileVisitor { - /** - * Combination of includes and excludes path matchers applied on files. - */ - @Nonnull - private final PathMatcher fileMatcher; - - /** - * Combination of includes and excludes path matchers applied on directories. - */ - @Nonnull - private final PathMatcher directoryMatcher; - - /** - * Creates a new archiver. - * - * @param directory the base directory of the files to archive - * @param includes the patterns of the files to include, or null or empty for including all files - * @param excludes the patterns of the files to exclude, or null or empty for no exclusion - */ - Archiver(Path directory, Collection includes, Collection excludes) { - fileMatcher = PathSelector.of(directory, includes, excludes); - if (fileMatcher instanceof PathSelector ps) { - if (ps.canFilterDirectories()) { - directoryMatcher = (path) -> ps.couldHoldSelected(path); - return; - } - } - directoryMatcher = PathSelector.INCLUDES_ALL; - } - - /** - * A scanner of {@code module-info.class} file and {@code META-INF/versions/*} directories. - * This is used only when we need to auto-detect whether the JAR is modular or multiè-release. - * This is not needed anymore for projects using the Maven 4 {@code } elements. - */ - final class VersionScanner extends SimpleFileVisitor { - /** - * The file to check for deciding whether the JAR is a modular JAR. - */ - private static final String MODULE_DESCRIPTOR_FILE_NAME = "module-info.class"; - - /** - * The directory level in the {@code ./META-INF/versions/###/} path. - * The root directory is at level 0 before we enter in that directory. - * After the execution of {@code preVisitDirectory} (i.e., at the time of visiting files), values are: - * - *
    - *
  1. for any file in the {@code ./} root classes directory.
  2. - *
  3. for any file in the {@code ./META-INF/} directory.
  4. - *
  5. for any file in the {@code ./META-INF/versions/} directory (i.e., any version number).
  6. - *
  7. for any file in the {@code ./META-INF/versions/###/} directory (i.e., any versioned file)
  8. - *
- */ - private int level; - - /** - * First level of versioned files in a {@code ./META-INF/versions/###/} directory. - */ - private static final int LEVEL_OF_VERSIONED_FILES = 4; - - /** - * Whether a {@code META-INF/versions/} directory has been found. - * The value of this flag is invalid if this scanner was constructed - * with a {@code detectMultiReleaseJar} argument value of {@code true}. - */ - boolean detectedMultiReleaseJAR; - - /** - * Whether a {@code module-info.class} file has been found. - */ - boolean containsModuleDescriptor; - - /** - * Creates a scanner with all flags initially {@code false}. - * - * @param detectMultiReleaseJar whether to enable the detection of multi-release JAR - */ - VersionScanner(boolean detectMultiReleaseJar) { - detectedMultiReleaseJAR = !detectMultiReleaseJar; // Not actually true, but will cause faster stop. - } - - /** - * Returns {@code true} if the given directory is one of the parts of {@code ./META-INF/versions/*}. - * The {@code .} directory is at level 0, {@code META-INF} at level 1, {@code versions} at level 2, - * and one of the versions at level 3. Files at level 4 are versioned class files. - */ - @Override - public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) { - if (detectedMultiReleaseJAR && level >= LEVEL_OF_VERSIONED_FILES) { - // When searching only for `module-info.class`, we do not need to go further than that level. - return (level > LEVEL_OF_VERSIONED_FILES) - ? FileVisitResult.SKIP_SIBLINGS - : FileVisitResult.SKIP_SUBTREE; - } - if (directoryMatcher.matches(dir)) { - String expected = - switch (level) { - case 1 -> "META-INF"; - case 2 -> "versions"; - default -> null; - }; - if (expected == null || dir.endsWith(expected)) { - level++; - return FileVisitResult.CONTINUE; - } - } - return FileVisitResult.SKIP_SUBTREE; - } - - /** - * Decrements our count of directory levels after visiting a directory. - */ - @Override - public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { - level--; - return super.postVisitDirectory(dir, exc); - } - - /** - * Updates the flags for the given files. This method terminates the - * scanning if it detects that there is no new information to collect. - */ - @Override - public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { - if (fileMatcher.matches(file)) { - if (level == 1 || level == LEVEL_OF_VERSIONED_FILES) { - // Root directory or a `META-INF/versions/###/` directory. - containsModuleDescriptor |= file.endsWith(MODULE_DESCRIPTOR_FILE_NAME); - } - detectedMultiReleaseJAR |= (level >= LEVEL_OF_VERSIONED_FILES); - if (detectedMultiReleaseJAR) { - if (containsModuleDescriptor) { - return FileVisitResult.TERMINATE; - } - if (level > LEVEL_OF_VERSIONED_FILES) { - return FileVisitResult.SKIP_SIBLINGS; - } - } - } - return FileVisitResult.CONTINUE; - } - } - - /** - * Determines if the given directory should be scanned for files to archive. - */ - @Override - public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) { - return directoryMatcher.matches(dir) ? FileVisitResult.CONTINUE : FileVisitResult.SKIP_SUBTREE; - } - - /** - * Archives a file in a directory. - */ - @Override - public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { - if (fileMatcher.matches(file)) { - // TODO - } - return FileVisitResult.CONTINUE; - } -} diff --git a/src/main/java/org/apache/maven/plugins/jar/DirectoryRole.java b/src/main/java/org/apache/maven/plugins/jar/DirectoryRole.java new file mode 100644 index 00000000..ccfbb7b1 --- /dev/null +++ b/src/main/java/org/apache/maven/plugins/jar/DirectoryRole.java @@ -0,0 +1,73 @@ +/* + * 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.maven.plugins.jar; + +/** + * Directories that the archiver needs to handle in a special way. + */ +enum DirectoryRole { + /** + * The root directory. This is usually {@code "target/classes"}. + * The next locations can be {@link #META_INF}, {@link #NAMED_MODULE} or {@link #RESOURCES}. + */ + ROOT, + + /** + * The {@code "META-INF"} or {@code "/META-INF"} directory. + * This is part of the JAR specification. + * The next locations can be {@link #VERSIONS} or {@link #VERSIONS_MODULAR}. + */ + META_INF, + + /** + * The {@code "META-INF/versions"} or {@code "/META-INF/versions"} directory. + * This is part of the JAR specification, except the {@code } prefix. + * The sub-directories are named according Java releases such as "21". + * The next location can only be {@link #RESOURCES}. + */ + VERSIONS, + + /** + * The Maven-specific {@code "META-INF/versions-modular"} directory. + * Note that {@code "/META-INF/versions-modular"} is not forbidden, but does not make sense. + * The sub-directories are named according Java releases such as "21". + * The next location can only be {@link #MODULES}. + */ + VERSIONS_MODULAR, + + /** + * The Maven-specific {@code "META-INF/versions-modular"} directory. + * All sub-directories shall have the name of a Java module. + * The next location can only be {@link #NAMED_MODULE}. + */ + MODULES, + + /** + * The root of a single Java module in a module hierarchy. + * The name of this directory is the Java module name. + * The next location can only be {@link #RESOURCES}. + */ + NAMED_MODULE, + + /** + * The classes or other types of resources to include in a single archive. + * May also be other files in the {@code META-INF} directory. + */ + RESOURCES +} diff --git a/src/main/java/org/apache/maven/plugins/jar/FileCollector.java b/src/main/java/org/apache/maven/plugins/jar/FileCollector.java new file mode 100644 index 00000000..fc6a30f2 --- /dev/null +++ b/src/main/java/org/apache/maven/plugins/jar/FileCollector.java @@ -0,0 +1,416 @@ +/* + * 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.maven.plugins.jar; + +import java.io.IOException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.apache.maven.api.annotations.Nonnull; +import org.apache.maven.api.annotations.Nullable; + +/** + * Dispatch the files in the output directory into the JAR files to create. + * Instead of just archiving as-is the content of the output directory, this class separates + * the following subdirectories to the options listed below: + * + *
    + *
  • The {@code META-INF/MANIFEST.MF} file will be given to the {@code --manifest} option.
  • + *
  • Files in the following directories will be given to the {@code --release} option: + *
      + *
    • {@code META-INF/versions/}
    • + *
    • {@code META-INF/versions-modular//}
    • + *
    • {@code /META-INF/versions/}
    • + *
    + *
  • + *
+ * + * The reason for using the options is that they allow the {@code jar} tool to perform additional verifications. + * For example, when using the {@code --release} option, {@code jar} verifies the API compatibility. + */ +final class FileCollector extends SimpleFileVisitor { + /** + * The file to check for deciding whether the JAR is modular. + */ + private static final String MODULE_DESCRIPTOR_FILE_NAME = "module-info.class"; + + /** + * The {@value} directory. + * This is part of JAR file specification. + */ + private static final String VERSIONS = "versions"; + + /** + * The {@value} directory. + * This is Maven-specific. + */ + private static final String VERSIONS_MODULAR = "versions-modular"; + + /** + * Context (logger, configuration) in which the JAR file are created. + */ + private final ToolExecutor context; + + /** + * Whether to detect multi-release JAR files. + */ + private final boolean detectMultiReleaseJar; + + /** + * Combination of includes and excludes path matcher applied on files. + */ + @Nonnull + private final PathMatcher fileMatcher; + + /** + * Combination of includes and excludes path matcher applied on directories. + */ + @Nonnull + private final PathMatcher directoryMatcher; + + /** + * Whether the matchers accept all files. In such case, we can declare whole directories + * to the {@code jar} tool instead of scaning the directory tree ourselves. + */ + private final boolean acceptsAllFiles; + + /** + * Files found in the output directory when package hierarchy is used. + * At most one of {@code packageHierarchy} and {@link #moduleHierarchy} can be non-empty. + */ + @Nonnull + private final Archive packageHierarchy; + + /** + * Files found in the output directory when module hierarchy is used. Keys are module names. + * At most one of {@link #packageHierarchy} and {@code moduleHierarchy} can be non-empty. + */ + @Nonnull + private final Map moduleHierarchy; + + /** + * The current module being archived. This field is updated every times that {@code FileCollector} + * enters in a new module directory. + */ + @Nonnull + private Archive currentModule; + + /** + * The module and target Java release currently being scanned. This field is updated every times that + * {@code FileCollector} enters in a new module directory or in a new target Java release for a given module. + */ + @Nonnull + private Archive.FileSet currentFilesToArchive; + + /** + * The current target Java release, or {@code null} if none. + */ + @Nullable + private Runtime.Version currentTargetVersion; + + /** + * Identification of the kinds of directories being traversed. + * The length of this list is the depth in the directory hierarchy. + * The last element identifies the type of the current directory. + */ + private final Deque directoryRoles; + + /** + * Whether to check when a file is the {@code MANIFEST.MF} file. + * This is allowed only when scanning the content of a {@code META-INF} directory. + */ + private boolean checkForManifest; + + /** + * Creates a new file collector. + * + * @param mojo the MOJO from which to get the configuration + * @param context context (logger, configuration) in which the JAR file are created + * @param directory the base directory of the files to archive + */ + FileCollector(final AbstractJarMojo mojo, final ToolExecutor context, final Path directory) { + this.context = context; + detectMultiReleaseJar = mojo.detectMultiReleaseJar; + directoryRoles = new ArrayDeque<>(); + fileMatcher = PathSelector.of(directory, mojo.getIncludes(), mojo.getExcludes()); + if (fileMatcher instanceof PathSelector ps && ps.canFilterDirectories()) { + directoryMatcher = (path) -> ps.couldHoldSelected(path); + } else { + directoryMatcher = PathSelector.INCLUDES_ALL; + } + acceptsAllFiles = directoryMatcher == PathSelector.INCLUDES_ALL && fileMatcher == PathSelector.INCLUDES_ALL; + packageHierarchy = context.newArchive(null, directory); + moduleHierarchy = new LinkedHashMap<>(); + resetToPackageHierarchy(); + } + + /** + * Resets this {@code FileCollector} to the state where a package hierarchy is presumed. + */ + private void resetToPackageHierarchy() { + currentModule = packageHierarchy; + currentFilesToArchive = currentModule.baseRelease; + } + + /** + * Declares that the given directory is the base directory of a module. + * For an output generated by {@code javac} from a module source hierarchy, + * the directory name is guaranteed to be the module name. + * + * @param directory a {@code ""} or {@code "META-INF/versions-modular/"} directory + */ + private void enterModuleDirectory(final Path directory) { + String moduleName = directory.getFileName().toString(); + currentModule = moduleHierarchy.computeIfAbsent(moduleName, (name) -> context.newArchive(name, directory)); + currentFilesToArchive = currentModule.newTargetRelease(directory, null); + } + + /** + * Declares that the given directory is the base directory of a target Java version. + * + * @param directory a {@code "META-INF/versions/"} or {@code "META-INF/versions-modular/"} directory + * @return whether to skip the directory because of invalid version number + */ + private boolean enterVersionDirectory(Path directory) { + try { + currentTargetVersion = Runtime.Version.parse(directory.getFileName().toString()); + } catch (IllegalArgumentException e) { + context.warnInvalidVersion(directory, e); + return true; + } + currentFilesToArchive = currentModule.newTargetRelease(directory, currentTargetVersion); + return false; + } + + /** + * Determines if the given directory should be scanned for files to archive. + * This method may also update {@link #currentFilesToArchive} if it detects + * that we are entering in a new module or a new target Java release. + * + * @param directory the directory which will be traversed + * @param attributes the directory's basic attributes + */ + @Override + @SuppressWarnings("checkstyle:MissingSwitchDefault") + public FileVisitResult preVisitDirectory(final Path directory, final BasicFileAttributes attributes) + throws IOException { + DirectoryRole role; + if (directoryRoles.isEmpty()) { + role = DirectoryRole.ROOT; + } else { + if (!directoryMatcher.matches(directory)) { + return FileVisitResult.SKIP_SUBTREE; + } + checkForManifest = false; + role = directoryRoles.getLast(); + switch (role) { + case ROOT: + /* + * Entering in any subdirectory of `target/classes` (or other directory to archive). + * We need to handle `META-INF` and modules in a special way, and archive the rest. + */ + if (directory.endsWith(MetadataFiles.META_INF)) { + role = DirectoryRole.META_INF; + checkForManifest = true; + } else if (Files.isRegularFile(directory.resolve(MODULE_DESCRIPTOR_FILE_NAME))) { + role = DirectoryRole.NAMED_MODULE; + enterModuleDirectory(directory); + } else { + role = DirectoryRole.RESOURCES; + } + break; + + case META_INF: + /* + * Entering in a subdirectory of `META-INF` or `/META-INF`. We will need to handle + * `MANIFEST.MF`, `versions` and `versions-modular` in a special way, and archive the rest. + */ + if (detectMultiReleaseJar && directory.endsWith(VERSIONS)) { + role = DirectoryRole.VERSIONS; + } else if (directory.endsWith(VERSIONS_MODULAR)) { + if (!detectMultiReleaseJar) { + return FileVisitResult.SKIP_SUBTREE; + } + role = DirectoryRole.VERSIONS_MODULAR; + } else { + role = DirectoryRole.RESOURCES; + } + break; + + case VERSIONS: + /* + * Entering in a `META-INF/versions//` directory for a specific target Java release. + * May also be a `/META-INF/versions//` directory, even if the latter is not + * the layout generated by Maven Compiler Plugin. + */ + if (enterVersionDirectory(directory)) { + return FileVisitResult.SKIP_SUBTREE; + } + role = DirectoryRole.RESOURCES; + break; + + case VERSIONS_MODULAR: + /* + * Entering in a `META-INF/versions-modular//` directory for a specific target Java release. + * That directory contains all modules for the version. + */ + resetToPackageHierarchy(); // No module in particular yet. + if (enterVersionDirectory(directory)) { + return FileVisitResult.SKIP_SUBTREE; + } + role = DirectoryRole.MODULES; + break; + + case MODULES: + /* + * Entering in a `META-INF/versions-modular//` directory. + */ + enterModuleDirectory(directory); + role = DirectoryRole.NAMED_MODULE; + currentFilesToArchive = currentModule.newTargetRelease(directory, currentTargetVersion); + break; + + case NAMED_MODULE: + /* + * Entering in a `` or `META-INF/versions-modular//` subdirectory. + * A module could have its own `META-INF` subdirectory, so we need to check again. + */ + if (directory.endsWith(MetadataFiles.META_INF)) { + role = DirectoryRole.META_INF; + checkForManifest = true; + } else { + role = DirectoryRole.RESOURCES; + } + break; + } + } + if (acceptsAllFiles && role == DirectoryRole.RESOURCES) { + currentFilesToArchive.add(directory, attributes, true); + return FileVisitResult.SKIP_SUBTREE; + } else { + directoryRoles.addLast(role); + return FileVisitResult.CONTINUE; + } + } + + /** + * Updates the {@code FileCollector} state if we finished to scan the content of a module. + * + * @param directory the directory which has been traversed + * @param error the error that occurred while traversing the directory, or {@code null} if none + */ + @Override + @SuppressWarnings("checkstyle:MissingSwitchDefault") + public FileVisitResult postVisitDirectory(final Path directory, final IOException error) throws IOException { + if (error != null) { + throw error; + } + switch (directoryRoles.removeLast()) { + case NAMED_MODULE: + // Exited the directory of a single module. + resetToPackageHierarchy(); + break; + + case ROOT: + break; + + default: + switch (directoryRoles.getLast()) { + case VERSIONS: + case VERSIONS_MODULAR: + // Exited the directory for one target Java release. + currentFilesToArchive = currentModule.baseRelease; + currentTargetVersion = null; + break; + + case META_INF: + checkForManifest = true; + break; + } + } + return FileVisitResult.CONTINUE; + } + + /** + * Archives a single file if accepted by the matcher. + * + * @param file the file + * @param attributes the file's basic attributes + */ + @Override + public FileVisitResult visitFile(final Path file, final BasicFileAttributes attributes) { + if (fileMatcher.matches(file)) { + if (checkForManifest && file.endsWith(MetadataFiles.MANIFEST) && currentModule.setManifest(file, false)) { + // Do not add `MANIFEST.MF`, it will be handled by the `--manifest` option instead. + } else { + currentFilesToArchive.add(file, attributes, false); + } + } + return FileVisitResult.CONTINUE; + } + + /** + * Moves, copies or ignores orphan files. + * An orphan file is a file which is not in any module when module hierarchy is used. + * For example, some Maven plugins may create files such as {@code META-INF/LICENSE}, + * {@code META-INF/NOTICE} or {@code META-INF/DEPENDENCIES}. These files are not in + * the correct directory (they should be in a {@code "/META-INF"} directory) + * because the plugin may not be aware of module hierarchy. + * + *

A possible strategy could be to copy the {@code LICENSE} and {@code NOTICE} files + * in each module, and ignore the {@code DEPENDENCIES} file because its content is not + * correct for a module. For now, we just log a warning an ignore.

+ * + * @return if this method ignored some files, the root directory of those files + */ + Path handleOrphanFiles() { + if (moduleHierarchy.isEmpty() || packageHierarchy.canSkip(true)) { + // Classpath project or module-project without orphan files. Nothing to do. + return null; + } + // TODO: we may want to copy LICENSE and NOTICE files here. + return packageHierarchy.clear(); + } + + /** + * Writes all JAR files. + * + * @throws MojoException if an error occurred during the execution of the "jar" tool + * @throws IOException if an error occurred while reading or writing a manifest file + */ + void writeAllJARs(final ToolExecutor executor) throws IOException { + for (Archive module : moduleHierarchy.values()) { + if (!module.canSkip(executor.skipIfEmpty)) { + executor.writeSingleJAR(this, module); + } + } + // `packageHierarchy` is expected to be empty if `moduleHierarchy` was used. + if (!packageHierarchy.canSkip(executor.skipIfEmpty || !moduleHierarchy.isEmpty())) { + executor.writeSingleJAR(this, packageHierarchy); + } + } +} diff --git a/src/main/java/org/apache/maven/plugins/jar/MetadataFiles.java b/src/main/java/org/apache/maven/plugins/jar/MetadataFiles.java new file mode 100644 index 00000000..790627b2 --- /dev/null +++ b/src/main/java/org/apache/maven/plugins/jar/MetadataFiles.java @@ -0,0 +1,221 @@ +/* + * 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.maven.plugins.jar; + +import java.io.BufferedWriter; +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Properties; +import java.util.jar.Manifest; +import java.util.stream.Collectors; + +import org.apache.maven.api.ProducedArtifact; +import org.apache.maven.api.Project; +import org.apache.maven.shared.archiver.MavenArchiveConfiguration; + +/** + * Temporary metadata files generated by Maven before inclusion in the archive. + * Those files are created in a temporary {@code META-INF} directory when first needed. + * Those files are deleted after the build, unless the build failed or Maven was run in verbose mode. + */ +final class MetadataFiles implements Closeable { + /** + * The {@value} directory. + * This is part of JAR file specification. + */ + static final String META_INF = "META-INF"; + + /** + * The {@value} file. + * This is part of JAR file specification. + */ + static final String MANIFEST = "MANIFEST.MF"; + + /** + * The subdirectory where to add Maven-specific files. + */ + static final String MAVEN_DIR = "maven"; + + /** + * The output directory (usually {@code ${baseDir}/target/}). + */ + private final Path buildDir; + + /** + * All files and directories in the order that they were created. + * The first element of this list shall be the root temporary directory created by this class. + */ + private final List filesToDelete; + + /** + * Creates an initially empty set of temporary metadata files. + * + * @param buildDir the (usually) {@code ${baseDir}/target/} directory + */ + MetadataFiles(final Path buildDir) { + this.buildDir = buildDir; + filesToDelete = new ArrayList<>(); + } + + /** + * Adds the given manifest in a temporary file. + * The file will be deleted when {@link #close()} is invoked. + * + * @param manifest the manifest to write + * @return the temporary manifest file + * @throws IOException if an error occurred while writing the file + */ + public Path addManifest(final Manifest manifest) throws IOException { + Path file = baseDirectory().resolve(MANIFEST); + try (OutputStream out = Files.newOutputStream(file)) { + filesToDelete.add(file); + manifest.write(out); + } + return file; + } + + /** + * {@return the root temporary directory for the files created by this class} + * The directory is created the first time that this method is invoked. + * + * @throws IOException if an error occurred while creating the temporary directory + */ + private Path baseDirectory() throws IOException { + if (filesToDelete.isEmpty()) { + filesToDelete.add(Files.createTempDirectory(buildDir, "classes-")); + } + return filesToDelete.get(0); + } + + /** + * Creates a new directory and adds it to the list of files to delete after the build. + * + * @param dir the existing directory where to create a sub-directory + * @param path path to the subdirectory to create + * @return the new directory + * @throws IOException if an error occurred while creating the subdirectory + */ + private Path createDirectories(Path dir, String... path) throws IOException { + for (String subdir : path) { + dir = Files.createDirectory(dir.resolve(subdir)); + filesToDelete.add(dir); + } + return dir; + } + + /** + * Writes the {@code pom.xml} and {@code pom.properties} files. + * This method returns the base temporary directory followed by files that the "jar" tool will need to add + * + * @param project the project for which to write the files + * @param archive archive configuration + * @param reproducible whether to enforce reproducible build + * @return arguments for the "jar" tool + * @throws IOException if an error occurred while writing the files + */ + public List addPOM(final Project project, final MavenArchiveConfiguration archive, final boolean reproducible) + throws IOException { + final String groupId = project.getGroupId(); + final String artifactId = project.getArtifactId(); + final String version; + ProducedArtifact pom = project.getPomArtifact(); + if (pom.isSnapshot()) { + version = pom.getVersion().toString(); + } else { + version = project.getVersion(); + } + Path baseDir = baseDirectory(); + Path mavenDir = createDirectories(baseDir, META_INF, MAVEN_DIR, groupId, artifactId); + Path pomFile = linkOrCopy(project.getPomPath(), mavenDir.resolve("pom.xml")); + filesToDelete.add(pomFile); // Add soon for deleting this file even if an exception is thrown below. + /* + * Subset of above "pom.xml" file but written as a properties file. + * If reproducible build is enabled, we will need to reformat after + * writing for ensuring a deterministic order of entries. + */ + Properties properties = new Properties(); + Path propertiesFile = archive.getPomPropertiesFile(); + if (propertiesFile != null) { + try (InputStream in = Files.newInputStream(propertiesFile)) { + properties.load(in); + } + } + properties.setProperty("groupId", groupId); + properties.setProperty("artifactId", artifactId); + properties.setProperty("version", version); + propertiesFile = mavenDir.resolve("pom.properties"); + try (BufferedWriter out = Files.newBufferedWriter(propertiesFile)) { + filesToDelete.add(propertiesFile); // Add soon for deleting this file even if an exception is thrown below. + properties.store(out, "Subset of pom.xml"); + } + if (reproducible) { + // The encoding can be either UTF-8 or ISO-8859-1, as any non ASCII character + // is transformed into a \\uxxxx sequence anyway. + Files.writeString( + propertiesFile, + Files.lines(propertiesFile) + .filter(line -> !line.startsWith("#")) + .sorted() + .collect(Collectors.joining("\n", "", "\n"))); // system independent new line. + } + return List.of(baseDir, Path.of(META_INF, MAVEN_DIR)); + } + + /** + * Creates a link to the given source if supported, or copies the file otherwise. + * + * @param source the source file to link or copy + * @param target the file to create + * @return the target file which should be deleted after the build + */ + private static Path linkOrCopy(final Path source, final Path target) throws IOException { + try { + return Files.createLink(target, source); + } catch (UnsupportedOperationException e) { + return Files.copy(source, target); + } + } + + /** + * Cancels the deletion of files. The files will stay present after the build. + * This is desired for allowing user to execute {@code jar} on the command-line, + * for example when the build failed. + */ + public void cancelFileDeletion() { + filesToDelete.clear(); + } + + /** + * Deletes all temporary files and directories created by this class. + * + * @throws IOException if an error occurred while deleting a file or directory + */ + @Override + public void close() throws IOException { + for (int i = filesToDelete.size(); --i >= 0; ) { + Files.delete(filesToDelete.get(i)); + } + } +} diff --git a/src/main/java/org/apache/maven/plugins/jar/TimestampCheck.java b/src/main/java/org/apache/maven/plugins/jar/TimestampCheck.java new file mode 100644 index 00000000..d7122ef8 --- /dev/null +++ b/src/main/java/org/apache/maven/plugins/jar/TimestampCheck.java @@ -0,0 +1,239 @@ +/* + * 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.maven.plugins.jar; + +import java.io.IOException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.FileTime; +import java.util.Enumeration; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +import org.apache.maven.api.plugin.Log; + +/** + * Checks file timestamps in order to determine if anything changed compared to an existing JAR file. + * This class may scan directories, but only if they have not already been visited by {@link FileCollector}. + * Note that the latter can occur only if {@link FileCollector} has no {@code PathMatcher}. + * Therefore, this class uses no {@code PathMatcher} neither. + * + *

Ignore files

+ * The {@code META-INF/MANIFEST.MF} file and the {@code META-INF/maven/} directory are ignored. + * See {@link #isIgnored(Path)} for the rational. + */ +final class TimestampCheck extends SimpleFileVisitor { + /** + * The base directory of archived files. + */ + private final Path classesDir; + + /** + * Path to the existing JAR file. + */ + private final Path jarFile; + + /** + * The last modified time of the JAR file. + */ + private final FileTime jarFileTime; + + /** + * Where to send non-fatal error messages. + */ + private final Log logger; + + /** + * Entries of the JAR file. Note that getting elements from this enumeration can be costly. + * Therefore, we do not fetch all elements in advance but only when needed. + */ + private Enumeration entries; + + /** + * Files found in the JAR file but not yet traversed by the file visitor. + * Files are added lazily only when needed, and removed as soon as they have been traversed. + * Path are absolute (resolved with {@link #classesDir}). + */ + private final Set filesInJAR; + + /** + * Some of the files in the build directory. This list contains only the files for which we have already + * verified the timestamp. We store them in a separated list for avoiding to check the timestamp twice. + * We need this list because we still need to verify if the files are in the {@link #jarFile}. + */ + private final Set filesInBuild; + + /** + * Whether at least one file is more recent than the JAR file. + * The scan of files will stop quickly after this flag become {@code true}. + */ + private boolean hasUpdates; + + /** + * Creates a new visitor for checking the validity of a JAR file. + * + * @param jarFile the existing JAR file + * @param jarFileTime the last modified time of the JAR file + * @param classesDir base directory of archived files + * @param logger where to send a warning if an error occurred while checking an existing JAR file + * @throws IOException if an error occurred while fetching the JAR file modification time + */ + TimestampCheck(final Path jarFile, final Path classesDir, final Log logger) throws IOException { + this.classesDir = classesDir; + this.jarFile = jarFile; + this.logger = logger; + jarFileTime = Files.getLastModifiedTime(jarFile); + filesInJAR = new HashSet<>(); + filesInBuild = new HashSet<>(); + } + + /** + * Returns {@code true} if the given file is more recent that the JAR file. + * + * @param file the file to check + * @param attributes the file's basic attributes + * @param isDirectory whether the file is a directory + * @return whether the modification time is more recent that the JAR file + */ + boolean isUpdated(final Path file, final BasicFileAttributes attributes, final boolean isDirectory) { + if (jarFileTime.compareTo(attributes.lastModifiedTime()) < 0) { + return true; + } + if (!isDirectory) { + filesInBuild.add(file); + } + return false; + } + + /** + * Checks if the JAR file contains all the given files, no extra entry, and no outdated entry. + * For each {@code Map.Entry}, the key is the base directory for resolving relative paths given by the value. + * If an I/O error occurs, this method returns {@code true} for instructing to recreate the JAR file. + * + * @param fileSets pairs of base directory and files potentially relative to the base directory + * @return whether the JAR file is up-to-date + */ + boolean isUpToDateJAR(final Iterable>> fileSets) { + // No need to use JarFile because no need to handle META-INF in a special way. + try (ZipFile jar = new ZipFile(jarFile.toFile())) { + entries = jar.entries(); + for (Path file : filesInBuild) { + if (!isFoundInJAR(file)) { + return false; + } + } + for (Map.Entry> fileSet : fileSets) { + final Path baseDir = fileSet.getKey(); + for (Path file : fileSet.getValue()) { + file = baseDir.resolve(file); + if (!filesInBuild.remove(file)) { // For skipping the files already verified by above loop. + Files.walkFileTree(file, this); + if (hasUpdates) { + return false; + } + } + } + } + // Check for remaining files in the JAR which were not in the build directory. + for (Path file : filesInJAR) { + if (!isIgnored(classesDir.relativize(file))) { + return false; + } + } + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + if (!(entry.isDirectory() || isIgnored(Path.of(entry.getName())))) { + return false; + } + } + } catch (IOException e) { + logger.warn(e); + return false; + } finally { + entries = null; + } + return true; + } + + /** + * Returns whether the given file in a JAR file should be ignored. + * We have to ignore the files that are generated in a temporary directory + * because they do not exist yet when the directory is traversed. Furthermore, + * their timestamp would always be newer then the JAR file anyway. + * + * @param file path to a file relative to the root of the JAR file + */ + private static boolean isIgnored(Path file) { + if (file.startsWith(MetadataFiles.META_INF)) { + file = file.subpath(1, file.getNameCount()); + if (file.startsWith(MetadataFiles.MANIFEST)) { + return file.getNameCount() == 1; + } else if (file.startsWith(MetadataFiles.MAVEN_DIR)) { + return true; // Ignore all subdirectories. + } + } + return false; + } + + /** + * Checks if the given file is new or more recent than the JAR file. + * Checks also if the file exists in the JAR file. + * + * @param file the traversed file + * @param attributes the file's basic attributes + */ + @Override + public FileVisitResult visitFile(final Path file, final BasicFileAttributes attributes) { + if (jarFileTime.compareTo(attributes.lastModifiedTime()) >= 0 && isFoundInJAR(file)) { + return FileVisitResult.CONTINUE; + } else { + hasUpdates = true; + return FileVisitResult.TERMINATE; + } + } + + /** + * Returns whether the given file is found in the JAR file. + * + * @param file the file to check + * @return whether the given file was found in the JAR file + */ + private boolean isFoundInJAR(final Path file) { + if (filesInJAR.remove(file)) { + return true; + } + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + if (!entry.isDirectory()) { + Path p = classesDir.resolve(entry.getName()); + if (p.equals(file)) { + return true; + } + filesInJAR.add(p); + } + } + return false; + } +} diff --git a/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java b/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java new file mode 100644 index 00000000..7a8f873c --- /dev/null +++ b/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java @@ -0,0 +1,407 @@ +/* + * 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.maven.plugins.jar; + +import javax.lang.model.SourceVersion; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.jar.Attributes; +import java.util.jar.Manifest; +import java.util.spi.ToolProvider; + +import org.apache.maven.api.Project; +import org.apache.maven.api.plugin.Log; +import org.apache.maven.api.plugin.MojoException; +import org.apache.maven.shared.archiver.MavenArchiveConfiguration; + +/** + * Writer of JAR files using the information collected by {@link FileCollector}. + * This class uses the {@code "jar"} tool provided with the JDK. + */ +final class ToolExecutor { + /** + * The Maven project for which to create an archive. + */ + private final Project project; + + /** + * The output directory where to write the JAR file. + * This is usually {@code ${baseDir}/target/}. + */ + private final Path outputDirectory; + + /** + * The JAR file name when package hierarchy is used. + * This is usually a file placed in the {@link ToolExecutor#outputDirectory} directory. + */ + private final String finalName; + + /** + * The classifier (e.g. "test"), or {@code null} if none. + */ + private final String classifier; + + /** + * The tool to use for creating the JAR files. + */ + private final ToolProvider tool; + + /** + * Where to send messages emitted by the "jar" tool. + */ + private final PrintWriter messageWriter; + + /** + * Where to send error messages emitted by the "jar" tool. + */ + private final PrintWriter errorWriter; + + /** + * Where the messages sent to {@link #messageWriter} are stored. + */ + private final StringBuffer messages; + + /** + * Where the messages sent to {@link #errorWriter} are stored. + */ + private final StringBuffer errors; + + /** + * A buffer for the arguments given to the "jar" tool, reused for each module. + * Each element of the list shall be instances of either {@link String} or {@link Path}. + */ + private final List arguments; + + /** + * The paths to the created archive files. + */ + private final Map result; + + /** + * Manifest to merge with the manifest found in the files to archive. + * This is a manifest built from the {@code } plugin configuration. + * Can be {@code null} if there is noting to add to the existing manifests. + */ + private final Manifest manifestFromPlugin; + + /** + * The file from which {@link #manifestFromPlugin} has been read, or {@code null} if none. + * If non-null, reading that file shall produce the same manifest as {@link #manifestFromPlugin}. + * It implies that this field shall be {@code null} if {@link #manifestFromPlugin} is the result + * of merging elements specified in {@code } with a file specified in the plugin configuration. + */ + private final Path manifestFile; + + /** + * The archive configuration to use. + */ + private final MavenArchiveConfiguration archiveConfiguration; + + /** + * The timestamp in ISO-8601 extended offset date-time, or {@code null} if none. + * If user provided a value in seconds, it shall have been converted to ISO-8601. + * This is used for reproducible builds. + */ + private final String outputTimestamp; + + /** + * Whether to skip empty JAR files. + */ + final boolean skipIfEmpty; + + /** + * Whether to force to build new JAR files even if none of the contents appear to have changed. + */ + private final boolean forceCreation; + + /** + * Where to send informative or error messages. + */ + private final Log logger; + + /** + * Creates a new writer. + * + * @param mojo the MOJO from which to get the configuration + * @param manifest manifest built from plugin configuration, or {@code null} if none + * @param archive the archive configuration + * @throws IOException if an error occurred while reading the manifest file + */ + ToolExecutor(AbstractJarMojo mojo, Manifest manifest, MavenArchiveConfiguration archive) throws IOException { + project = mojo.getProject(); + outputDirectory = mojo.getOutputDirectory(); + classifier = AbstractJarMojo.nullIfAbsent(mojo.getClassifier()); + finalName = mojo.getFinalName(); + skipIfEmpty = mojo.skipIfEmpty; + forceCreation = mojo.forceCreation; + outputTimestamp = mojo.getOutputTimestamp(); + logger = mojo.getLog(); + tool = mojo.getJarTool(); + + var buffer = new StringWriter(); + messages = buffer.getBuffer(); + messageWriter = new PrintWriter(buffer); + + buffer = new StringWriter(); + errors = buffer.getBuffer(); + errorWriter = new PrintWriter(buffer); + + arguments = new ArrayList<>(); + result = new LinkedHashMap<>(); + archiveConfiguration = archive; + + Path file = archive.getManifestFile(); + if (file != null) { + try (InputStream in = Files.newInputStream(file)) { + // No need to wrap in `BufferedInputStream`. + if (manifest != null) { + manifest.read(in); + file = null; // Because the manifest is the result of a merge. + } else { + manifest = new Manifest(in); + } + } + } + if (manifest != null) { + if (mojo.detectMultiReleaseJar) { + manifest.getMainAttributes().remove(Attributes.Name.MULTI_RELEASE); + } + if (!isReproducible()) { + // If reproducible build was not requested, let the tool declares itself. + // This is a workaround until we port Maven archiver to this JAR plugin. + manifest.getMainAttributes().remove("Created-By"); + } + } + manifestFromPlugin = manifest; + manifestFile = file; + } + + /** + * Whether reproducible build was requested. + * In current version, the output time stamp is used as a sentinel value. + */ + public boolean isReproducible() { + return outputTimestamp != null; + } + + /** + * Creates an initially empty archive for JAR file to generate. + * This method does not create the JAR file immediately, + * but collect information for creating the file later. + * + * @param moduleName the module name if using module hierarchy, or {@code null} if using package hierarchy + * @param directory the directory of the classes targeting the base Java release + */ + Archive newArchive(final String moduleName, final Path directory) { + var sb = new StringBuilder(60); + if (moduleName != null) { + sb.append(moduleName).append('-').append(project.getVersion()); + } else { + sb.append(finalName); + } + if (classifier != null) { + sb.append('-').append(classifier); + } + String filename = sb.append(".jar").toString(); + return new Archive(outputDirectory.resolve(filename), moduleName, directory, forceCreation, logger); + } + + /** + * Writes all JAR files. + * + * @param files the result of scanning the build directory for listing the files or directories to archive + * @return the paths to the created archive files + * @throws MojoException if an error occurred during the execution of the "jar" tool + * @throws IOException if an error occurred while reading or writing a manifest file + */ + @SuppressWarnings("ReturnOfCollectionOrArrayField") + public Map writeAllJARs(final FileCollector files) throws IOException { + Path ignored = files.handleOrphanFiles(); + files.writeAllJARs(this); + if (ignored != null) { + logger.warn("Some files in \"" + relativize(outputDirectory, ignored) + + "\" were ignored because they belong to no module."); + } + return result; + } + + /** + * Creates the JAR files for the specified set of files. + * If the operation fails, an error message may be available in the {@link #errors} buffer. + * + * @param files the result of scanning the build directory for listing the files or directories to archive + * @throws MojoException if an error occurred during the execution of the "jar" tool + * @throws IOException if an error occurred while reading or writing a manifest file + */ + void writeSingleJAR(final FileCollector files, final Archive archive) throws IOException { + final Path relativePath = relativize(project.getRootDirectory(), archive.jarFile); + if (archive.isUpToDateJAR()) { + logger.info("Keep up-to-date JAR: \"" + relativePath + "\"."); + result.put(archive.moduleName, archive.jarFile); + return; + } + logger.info("Building JAR: \"" + relativePath + "\"."); + /* + * If `MANIFEST.MF` entries were specified by JAR plugin configuration, + * merge those entries with the content of `MANIFEST.MF` file found in + * the files to archive. + */ + boolean writeTemporaryManifest = (manifestFromPlugin != null && manifestFile == null); // Check . + final Manifest manifest = archive.mergeManifest(manifestFile, manifestFromPlugin); + if (manifest != manifestFromPlugin) { + writeTemporaryManifest |= (manifestFromPlugin != null); // Check if a merge of two manifests. + } + writeTemporaryManifest |= archive.setMainClass(manifest); + if (manifest != null) { + String name = manifest.getMainAttributes().getValue("Automatic-Module-Name"); + if (name != null && !SourceVersion.isName(name)) { + throw new MojoException("Invalid automatic module name: \"" + name + "\"."); + } + } + /* + * Creates temporary files for META-INF (if the existing file cannot be used directly) + * and for the Maven metadata (if requested). The temporary files are in the `target` + * directory and will be deleted, unless the build fails or is run in verbose mode. + */ + try (MetadataFiles temporaryMetadataFiles = new MetadataFiles(outputDirectory)) { + if (writeTemporaryManifest) { + archive.setManifest(temporaryMetadataFiles.addManifest(manifest), true); + } + if (archiveConfiguration.isAddMavenDescriptor()) { + archive.mavenFiles = temporaryMetadataFiles.addPOM(project, archiveConfiguration, isReproducible()); + } + /* + * Prepare the arguments to send to the `jar` tool and log a message. + */ + arguments.add("--create"); + if (!archiveConfiguration.isCompress()) { + arguments.add("--no-compress"); + } + if (outputTimestamp != null) { + arguments.add("--date"); + arguments.add(outputTimestamp); + } + archive.arguments(arguments); + /* + * Execute the `jar` tool with arguments determined by the values dispatched + * in the various fields of the `Archive`. Information and error essages are logged. + */ + String[] options = new String[arguments.size()]; + Arrays.setAll(options, (i) -> arguments.get(i).toString()); + int status = tool.run(messageWriter, errorWriter, options); + if (!messages.isEmpty()) { + logger.info(messages); + } + if (!errors.isEmpty()) { + logger.error(errors); + } + if (status != 0 || logger.isDebugEnabled()) { + Path debugFile = archive.writeDebugFile(project.getBasedir(), outputDirectory, classifier, arguments); + temporaryMetadataFiles.cancelFileDeletion(); + if (status != 0) { + logCommandLineTip(project.getBasedir(), debugFile); + String error = errors.toString().strip(); + if (error.isEmpty()) { + error = "unspecified error."; + } + throw new MojoException("Cannot create the \"" + relativePath + "\" archive file: " + error); + } + } + } + arguments.clear(); + errors.setLength(0); + messages.setLength(0); + result.put(archive.moduleName, archive.jarFile); + } + + /** + * Sends an error message to the logger if non-blank, then log a tip for testing from the command-line. + * + * @param baseDir the project base directory, or {@code null} + * @param debugFile the file containing the "jar" tool arguments + */ + private void logCommandLineTip(Path baseDir, Path debugFile) { + final var commandLine = new StringBuilder("For trying to archive from the command-line, use:"); + if (baseDir != null) { + debugFile = relativize(baseDir, debugFile); + baseDir = relativize(Path.of(System.getProperty("user.dir")), baseDir); + String chdir = baseDir.toString(); + if (!chdir.isEmpty()) { + boolean isWindows = (File.separatorChar == '\\'); + commandLine + .append(System.lineSeparator()) + .append(" ") + .append(isWindows ? "chdir " : "cd ") + .append(chdir); + } + } + commandLine + .append(System.lineSeparator()) + .append(" ") + .append(tool.name()) + .append(" @") + .append(debugFile); + logger.info(commandLine); + } + + /** + * Logs a warning saying that a {@code META-INF/versions/} directory cannot be parsed as a version number. + * + * @param path the directory + * @param e the exception that occurred while trying to parse the directory name + */ + void warnInvalidVersion(Path path, IllegalArgumentException e) { + var message = new StringBuilder(160) + .append("The \"") + .append(relativize(outputDirectory, path)) + .append("\" directory cannot be parsed as a version number."); + String cause = e.getMessage(); + if (cause != null) { + message.append(System.lineSeparator()).append("Caused by: ").append(cause); + } + logger.warn(message, e); + } + + /** + * Tries to return the given directory relative to the given base. + * If any directory is null, or if the directory cannot be relativized, + * returns the directory unchanged (usually as an absolute path). + */ + private static Path relativize(Path base, Path dir) { + if (base != null && dir != null) { + try { + return base.relativize(dir); + } catch (IllegalArgumentException e) { + // Ignore, keep the absolute path. + } + } + return dir; + } +} From 78039b6297bb0c52d21c5136bc42b0b769e0286b Mon Sep 17 00:00:00 2001 From: Martin Desruisseaux Date: Mon, 15 Dec 2025 15:11:34 +0100 Subject: [PATCH 3/5] Whether the `--date` option is supported depends on the Java version. --- .../invoker.properties | 10 ++++------ .../maven/plugins/jar/AbstractJarMojo.java | 20 +++++++++++++++++++ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/it/MJAR-275-reproducible-module-info/invoker.properties b/src/it/MJAR-275-reproducible-module-info/invoker.properties index 71eea457..452fbdb2 100644 --- a/src/it/MJAR-275-reproducible-module-info/invoker.properties +++ b/src/it/MJAR-275-reproducible-module-info/invoker.properties @@ -5,9 +5,9 @@ # 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 @@ -15,7 +15,5 @@ # specific language governing permissions and limitations # under the License. -# NOTE: Requires Java 10+ to compile the module declaration for Java 9+, -# this is due that compiling the module declaration generates a -# module descriptor with the JDK version on it, making it unreproducible. -invoker.java.version = 10+ +# The --date option needed for reproducible build is available only since Java 19. +invoker.java.version = 19+ diff --git a/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java b/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java index 0fa813d9..c57ffe5b 100644 --- a/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java +++ b/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java @@ -18,6 +18,8 @@ */ package org.apache.maven.plugins.jar; +import javax.lang.model.SourceVersion; + import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -230,6 +232,20 @@ protected ToolProvider getJarTool() throws MojoException { return ToolProvider.findFirst(toolId).orElseThrow(() -> new MojoException("No such \"" + toolId + "\" tool.")); } + /** + * Returns whether the specified Java version is supported. + * + * @param release name of an {@link SourceVersion} enumeration constant + * @return whether the current environment support that version + */ + private static boolean isSupported(String release) { + try { + return SourceVersion.latestSupported().compareTo(SourceVersion.valueOf(release)) >= 0; + } catch (IllegalArgumentException e) { + return false; + } + } + /** * Returns the output time stamp or, as a fallback, the {@code SOURCE_DATE_EPOCH} environment variable. * If the time stamp is expressed in seconds, it is converted to ISO 8601 format. Otherwise it is returned as-is. @@ -246,6 +262,10 @@ protected String getOutputTimestamp() { return null; } } + if (!isSupported("RELEASE_19")) { + log.warn("Reproducible build requires Java 19 or later."); + return null; + } for (int i = time.length(); --i >= 0; ) { char c = time.charAt(i); if ((c < '0' || c > '9') && (i != 0 || c != '-')) { From c4f206bd8defa23effdf15bdbde9552f83c2a653 Mon Sep 17 00:00:00 2001 From: Martin Desruisseaux Date: Sat, 27 Dec 2025 13:06:59 +0100 Subject: [PATCH 4/5] Derive a POM for each individual JAR file as the intersection of the project model and the module-info of the JAR file. --- pom.xml | 6 + .../maven/plugins/jar/AbstractJarMojo.java | 71 ++- .../org/apache/maven/plugins/jar/Archive.java | 258 +++++----- .../maven/plugins/jar/FileCollector.java | 73 ++- .../org/apache/maven/plugins/jar/JarMojo.java | 11 + .../maven/plugins/jar/MetadataFiles.java | 47 +- .../maven/plugins/jar/PomDerivation.java | 484 ++++++++++++++++++ .../apache/maven/plugins/jar/Providers.java | 18 +- .../apache/maven/plugins/jar/TestJarMojo.java | 11 + .../maven/plugins/jar/TimestampCheck.java | 12 +- .../maven/plugins/jar/ToolExecutor.java | 54 +- 11 files changed, 840 insertions(+), 205 deletions(-) create mode 100644 src/main/java/org/apache/maven/plugins/jar/PomDerivation.java diff --git a/pom.xml b/pom.xml index 326dfcc1..2f9a0630 100644 --- a/pom.xml +++ b/pom.xml @@ -152,6 +152,12 @@ ${mavenVersion} provided + + org.apache.maven + maven-support + ${mavenVersion} + compile + org.apache.maven maven-api-xml diff --git a/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java b/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java index c57ffe5b..ed823459 100644 --- a/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java +++ b/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java @@ -30,6 +30,7 @@ import java.util.jar.Manifest; import java.util.spi.ToolProvider; +import org.apache.maven.api.PathScope; import org.apache.maven.api.ProducedArtifact; import org.apache.maven.api.Project; import org.apache.maven.api.Session; @@ -188,6 +189,13 @@ protected Path getOutputDirectory() { return outputDirectory; } + /** + * {@return the Maven session in which the project is built} + */ + protected final Session getSession() { + return session; + } + /** * {@return the Maven project} */ @@ -221,6 +229,14 @@ protected String getFinalName() { */ protected abstract String getType(); + /** + * {@return the scope of dependencies} + * It should be {@link PathScope#MAIN_COMPILE} or {@link PathScope#TEST_COMPILE}. + * Note that we use compile scope rather than runtime scope because dependencies + * cannot appear in {@code requires} statement if they didn't had compile scope. + */ + protected abstract PathScope getDependencyScope(); + /** * {@return the JAR tool to use for archiving the code} * @@ -302,7 +318,9 @@ private static List asList(String[] elements) { /** * Generates the JAR files. * Map keys are module names or {@code null} if the project does not use module hierarchy. - * Values are paths to the JAR file associated with each module. + * Values are (type, path) pairs associated with each module where + * type is {@code "pom"}, {@code "jar"} or {@code "test-jar"} and path + * is the path to the POM or JAR file. * *

Note that a null key does not necessarily means that the JAR is not modular. * It only means that the project was not compiled with module hierarchy, @@ -313,7 +331,7 @@ private static List asList(String[] elements) { * @throws IOException if an error occurred while walking the file tree * @throws MojoException if an error occurred while writing a JAR file */ - public Map createArchives() throws IOException, MojoException { + public Map> createArchives() throws IOException, MojoException { final Path classesDirectory = getClassesDirectory(); final boolean notExists = Files.notExists(classesDirectory); if (notExists) { @@ -333,6 +351,11 @@ public Map createArchives() throws IOException, MojoException { if (!notExists) { Files.walkFileTree(classesDirectory, files); } + files.prune(skipIfEmpty); + List moduleRoots = files.getModuleHierarchyRoots(); + if (!moduleRoots.isEmpty()) { + executor.pomDerivation = new PomDerivation(this, moduleRoots); + } return executor.writeAllJARs(files); } @@ -344,42 +367,42 @@ public Map createArchives() throws IOException, MojoException { @Override @SuppressWarnings("UseSpecificCatch") public void execute() throws MojoException { - final Map jarFiles; + final Map> artifactFiles; try { - jarFiles = createArchives(); + artifactFiles = createArchives(); } catch (MojoException e) { throw e; } catch (Exception e) { throw new MojoException("Error while assembling the JAR file.", e); } - if (jarFiles.isEmpty()) { + if (artifactFiles.isEmpty()) { // Message already logged by `createArchives()`. return; } if (attach) { final String classifier = nullIfAbsent(getClassifier()); - for (Map.Entry entry : jarFiles.entrySet()) { + for (Map.Entry> entry : artifactFiles.entrySet()) { String moduleName = entry.getKey(); - ProducedArtifact artifact; - if (moduleName == null && classifier == null) { - if (projectHasAlreadySetAnArtifact()) { - throw new MojoException("You have to use a classifier " - + "to attach supplemental artifacts to the project instead of replacing them."); + for (Map.Entry path : entry.getValue().entrySet()) { + ProducedArtifact artifact; + if (moduleName == null && classifier == null) { + // Note: the two maps on which we are iterating should contain only one entry in this case. + if (projectHasAlreadySetAnArtifact()) { + throw new MojoException("You have to use a classifier " + + "to attach supplemental artifacts to the project instead of replacing them."); + } + artifact = project.getMainArtifact().orElseThrow(); + } else { + artifact = session.createProducedArtifact( + project.getGroupId(), + (moduleName != null) ? moduleName : project.getArtifactId(), + project.getVersion(), + classifier, + null, + path.getKey()); } - artifact = project.getMainArtifact().orElseThrow(); - } else { - /* - * TODO: we need to generate artifact with dependencies filtered from the module-info. - */ - artifact = session.createProducedArtifact( - project.getGroupId(), - (moduleName != null) ? moduleName : project.getArtifactId(), - project.getVersion(), - classifier, - null, - getType()); + projectManager.attachArtifact(project, artifact, path.getValue()); } - projectManager.attachArtifact(project, artifact, entry.getValue()); } } else { getLog().debug("Skipping attachment of the " + getType() + " artifact to the project."); diff --git a/src/main/java/org/apache/maven/plugins/jar/Archive.java b/src/main/java/org/apache/maven/plugins/jar/Archive.java index feb7c785..a9ae0998 100644 --- a/src/main/java/org/apache/maven/plugins/jar/Archive.java +++ b/src/main/java/org/apache/maven/plugins/jar/Archive.java @@ -25,14 +25,19 @@ import java.nio.file.Path; import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; +import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.NavigableMap; +import java.util.NoSuchElementException; +import java.util.TreeMap; import java.util.jar.Attributes; import java.util.jar.Manifest; import org.apache.maven.api.annotations.Nonnull; import org.apache.maven.api.annotations.Nullable; import org.apache.maven.api.plugin.Log; +import org.apache.maven.api.plugin.MojoException; /** * Files or root directories to archive for a single module. @@ -40,6 +45,15 @@ * Many instances of {@code Archive} may exist when archiving a multi-modules project. */ final class Archive { + /** + * Path to the POM file generated for this archive, or {@code null} if none. + * This is non-null only if module source hierarchy is used, in which case the dependencies + * declared in this file are the intersection of the project dependencies and the content of + * the {@code module-info.class} file. + */ + @Nullable + Path pomFile; + /** * The JAR file to create. May be an existing file, * in which case the file creation may be skipped if the file is still up-to-date. @@ -83,25 +97,13 @@ final class Archive { */ private String mainClass; - /** - * Files or root directories to store in the JAR file for targeting the base Java release. - */ - @Nonnull - final FileSet baseRelease; - /** * Files or root directories to store in the JAR file for each target Java release - * other than the base release. - * - *

Note on duplicated versions

- * In principle, we should not have two elements with the same {@link FileSet#version} value. - * However, while it should not happen in default Maven builds, we do not forbid the case where - * the same version would be defined in {@code "./META-INF"} and {@code ".//META-INF"}. - * In such case, two {@code FileSet} instances would exist for the same Java release but with - * two different {@link FileSet#directory} values. + * other than the base release. Keys are the target Java release with {@code null} for the base + * release. */ @Nonnull - private final List additionalReleases; + private final NavigableMap filesetForRelease; /** * Files or root directories to archive for a single target Java release of a single module. @@ -109,60 +111,56 @@ final class Archive { * the base release, and an arbitrary amount of other instances for other target releases. */ final class FileSet { - /** - * The target Java release, or {@code null} for the base version of the JAR file. - */ - @Nullable - final Runtime.Version version; - /** * The root directory of all files or directories to archive. * This is the value to pass to the {@code -C} tool option. */ @Nonnull - private final Path directory; + final Path directory; /** * The files or directories to include in the JAR file. * May be absolute paths or paths relative to {@link #directory}. */ @Nonnull - private final List files; + final List files; /** - * Creates an initially empty set of files or directories for the specified target Java release. + * Creates an initially empty set of files or directories for a specific target Java release. * * @param directory the base directory of the files or directories to archive - * @param version the target Java release, or {@code null} for the base version of the JAR file */ - private FileSet(final Path directory, final Runtime.Version version) { - this.version = version; + private FileSet(Path directory) { this.directory = directory; this.files = new ArrayList<>(); } /** - * Finds a common directory for all remaining files, then clears the list of file. - * The common directory can be used for logging a warning message. + * Discards all files in this file set, normally because those files are not in any module. + * This method returns a common parent directory for all the files that were discarded. + * The caller should use that common directory for logging a warning message. * * @param base base directory found by previous invocations of this method, or {@code null} if none - * @return common directory of remaining files + * @return common directory of discarded files */ - private Path clear(Path base) { + private Path discardAllFiles(Path base) { for (Path file : files) { - base = findCommonBaseDirectory(base, directory.resolve(file)); + file = directory.resolve(file); + if (base == null) { + base = file.getParent(); + } else { + while (!file.startsWith(base)) { + base = base.getParent(); + if (base == null) { + break; + } + } + } } files.clear(); return base; } - /** - * Returns files as values, together with the base directory (as key) for resolving relative files. - */ - private Map.Entry> files() { - return Map.entry(directory, files); - } - /** * Adds the given path to the list of files or directories to archive. * This method may store a relative path instead of the absolute path. @@ -172,7 +170,7 @@ private Map.Entry> files() { * @param isDirectory whether the file is a directory * @throws IllegalArgumentException if the given path cannot be made relative to the base directory */ - void add(Path item, final BasicFileAttributes attributes, final boolean isDirectory) { + void add(Path item, BasicFileAttributes attributes, boolean isDirectory) { TimestampCheck tc = existingJAR; if (tc != null && tc.isUpdated(item, attributes, isDirectory)) { existingJAR = null; // Signal that the existing file is outdated. @@ -194,22 +192,18 @@ void add(Path item, final BasicFileAttributes attributes, final boolean isDirect * Elements added to the list shall be instances of {@link String} or {@link Path}. * * @param addTo the list where to add the arguments as {@link String} or {@link Path} instances - * @param versioned whether to add arguments for the version specified by {@linkplain #version} - * @return whether at least one file has been added as argument + * @param version the target Java release, or {@code null} for the base version of the JAR file */ - private boolean arguments(final List addTo, final boolean versioned) { - if (files.isEmpty()) { - // Happen if both `FileCollector.moduleHierarchy` and `FileCollector.packageHierarchy` are empty. - return false; - } - if (versioned && version != null) { - addTo.add("--release"); - addTo.add(version); + private void arguments(List addTo, Runtime.Version version) { + if (!files.isEmpty()) { + if (version != null) { + addTo.add("--release"); + addTo.add(version); + } + addTo.add("-C"); + addTo.add(directory); + addTo.addAll(files); } - addTo.add("-C"); - addTo.add(directory); - addTo.addAll(files); - return true; } /** @@ -217,8 +211,7 @@ private boolean arguments(final List addTo, final boolean versioned) { */ @Override public String toString() { - return getClass().getSimpleName() + '[' + (version != null ? version : "base") + " = " - + directory.getFileName() + ']'; + return getClass().getSimpleName() + '[' + directory.getFileName() + ": " + files.size() + " files]"; } } @@ -228,17 +221,23 @@ public String toString() { * @param jarFile path to the JAR file to create * @param moduleName the module name if using module hierarchy, or {@code null} if using package hierarchy * @param directory the directory of the classes targeting the base Java release - * @param forceCreation whether to force new JAR file even the contents seem unchanged. + * @param forceCreation whether to force a new JAR file even if the content seems unchanged. * @param logger where to send a warning if an error occurred while checking an existing JAR file */ - Archive(final Path jarFile, final String moduleName, final Path directory, boolean forceCreation, Log logger) { + @SuppressWarnings("checkstyle:NeedBraces") + Archive(Path jarFile, String moduleName, Path directory, boolean forceCreation, Log logger) { this.jarFile = jarFile; this.moduleName = moduleName; - baseRelease = new FileSet(directory, null); - additionalReleases = new ArrayList<>(); + filesetForRelease = new TreeMap<>((v1, v2) -> { + if (v1 == v2) return 0; + if (v1 == null) return -1; + if (v2 == null) return +1; + return v1.compareTo(v2); + }); + filesetForRelease.put(null, new FileSet(directory)); if (!forceCreation && Files.isRegularFile(jarFile)) { try { - existingJAR = new TimestampCheck(jarFile, directory(), logger); + existingJAR = new TimestampCheck(jarFile, directory, logger); } catch (IOException e) { // Ignore, we will regenerate the JAR file. logger.warn(e); @@ -247,61 +246,81 @@ public String toString() { } /** - * Returns the root directory of all files or directories to archive for this module. + * {@return the files or directories to store in the JAR file for targeting the base Java release} * - * @return the root directory of this module. + * @throws NoSuchElementException should not happen unless {@link #prune(boolean)} has been invoked */ - public Path directory() { - return baseRelease.directory; + FileSet baseRelease() { + return filesetForRelease.firstEntry().getValue(); } /** - * Finds a common directory for all remaining files, then clears the list of file. - * The common directory can be used for logging a warning message. + * Returns the {@code module-info.class} files. Conceptually, there is at most once such file per module. + * However, more than one file may exist if additional files are provided for additional Java releases. + * This method returns only the files that exist. * - * @return common directory of remaining files + * @return all {@code module-info.class} files found for all target Java releases */ - Path clear() { - Path base = baseRelease.clear(null); - for (FileSet release : additionalReleases) { - base = release.clear(base); - } - return (base != null) ? base : directory(); + public List moduleInfoFiles() { + var files = new ArrayList(); + filesetForRelease.values().forEach((release) -> { + Path file = release.directory.resolve(FileCollector.MODULE_DESCRIPTOR_FILE_NAME); + if (Files.isRegularFile(file)) { + files.add(file); + } + }); + return files; } /** - * Returns a directory which is the base of the given {@code file}. - * This method returns either {@code base}, or a parent of {@code base}, or {@code null}. + * Discards all files in this archive, normally because those files are not in any module. + * This method returns a common parent directory for all the files that were discarded. + * The caller should use that common directory for logging a warning message. * - * @param base the last base directory found, or {@code null} - * @param file the file for which to find a common base directory - * @return {@code base}, or a parent of {@code base}, or {@code null} + * @return common directory of discarded files, or {@code null} if none */ - private static Path findCommonBaseDirectory(Path base, Path file) { - if (base == null) { - base = file.getParent(); - } else { - while (!file.startsWith(base)) { - base = base.getParent(); - if (base == null) { - break; - } - } + Path discardAllFiles() { + Path base = null; + for (FileSet release : filesetForRelease.values()) { + base = release.discardAllFiles(base); } return base; } /** - * Returns whether this module can be skipped. This is {@code true} if this module has no file to archive, - * ignoring Maven-generated files, and {@code skipIfEmpty} is {@code true}. This method should be invoked - * even in the trivial case where the {@code skipIfEmpty} argument is {@code false}. + * Removes all empty file sets and ensures that the lowest version is declared as the base version. + * This method should be invoked after all output directories to archive have been fully scanned. + * If {@code skipIfEmpty} is {@code false}, then this method ensures that at least one file set + * remains even if that file set is empty. * * @param skipIfEmpty value of {@link AbstractJarMojo#skipIfEmpty} - * @return whether this module can be skipped */ - public boolean canSkip(final boolean skipIfEmpty) { - additionalReleases.removeIf((v) -> v.files.isEmpty()); - return skipIfEmpty && baseRelease.files.isEmpty() && additionalReleases.isEmpty(); + public void prune(final boolean skipIfEmpty) { + FileSet keep = (skipIfEmpty || filesetForRelease.isEmpty()) + ? null + : filesetForRelease.firstEntry().getValue(); + filesetForRelease.values().removeIf((v) -> v.files.isEmpty()); + Iterator> it = + filesetForRelease.entrySet().iterator(); + if (it.hasNext()) { + Map.Entry first = it.next(); + if (first.getKey() != null) { + keep = first.getValue(); + it.remove(); + } + } + if (keep != null) { + filesetForRelease.put(null, keep); + } + } + + /** + * {@return whether this module can be skipped} + * This is {@code true} if this module has no file to archive, ignoring Maven-generated files. + * The {@link #prune(boolean)} method should be invoked before this method for accurate result. + */ + public boolean canSkip() { + return filesetForRelease.isEmpty(); } /** @@ -320,10 +339,7 @@ public boolean isUpToDateJAR() { return false; } existingJAR = null; // Let GC do its job. - var fileSets = new ArrayList>>(additionalReleases.size() + 1); - fileSets.add(baseRelease.files()); - additionalReleases.forEach((release) -> fileSets.add(release.files())); - return tc.isUpToDateJAR(fileSets); + return tc.isUpToDateJAR(filesetForRelease.values()); } /** @@ -334,9 +350,7 @@ public boolean isUpToDateJAR() { * @return container where to declare files and directories to archive */ FileSet newTargetRelease(Path directory, Runtime.Version version) { - var release = new FileSet(directory, version); - additionalReleases.add(release); - return release; + return filesetForRelease.computeIfAbsent(version, (key) -> new FileSet(directory)); } /** @@ -445,7 +459,6 @@ Manifest mergeManifest(Path file, Manifest content) throws IOException { * * @param addTo the list where to add the arguments as {@link String} or {@link Path} instances */ - @SuppressWarnings("checkstyle:NeedBraces") void arguments(final List addTo) { addTo.add("--file"); addTo.add(jarFile); @@ -461,22 +474,8 @@ void arguments(final List addTo) { addTo.add("-C"); addTo.addAll(mavenFiles); } - // Sort by increasing release version. - additionalReleases.sort((f1, f2) -> { - Runtime.Version v1 = f1.version; - Runtime.Version v2 = f2.version; - if (v1 != v2) { - if (v1 == null) return -1; - if (v2 == null) return +1; - int c = v1.compareTo(v2); - if (c != 0) return c; - } - // Give precedence to directories closer to the root. - return f1.directory.getNameCount() - f2.directory.getNameCount(); - }); - boolean versioned = baseRelease.arguments(addTo, false); - for (FileSet release : additionalReleases) { - versioned |= release.arguments(addTo, versioned); + for (Map.Entry entry : filesetForRelease.entrySet()) { + entry.getValue().arguments(addTo, entry.getKey()); } } @@ -537,6 +536,25 @@ Path writeDebugFile(Path baseDir, Path debugDirectory, String classifier, List> addTo) { + final Map paths; + if (pomFile != null) { + paths = Map.of(artifactType, jarFile, "pom", pomFile); + } else { + paths = Map.of(artifactType, jarFile); + } + if (addTo.put(moduleName, paths) != null) { + // Should never happen, but check anyway. + throw new MojoException("Module archived twice: " + moduleName); + } + } + /** * {@return a string representation for debugging purposes} */ diff --git a/src/main/java/org/apache/maven/plugins/jar/FileCollector.java b/src/main/java/org/apache/maven/plugins/jar/FileCollector.java index fc6a30f2..e8b5bb03 100644 --- a/src/main/java/org/apache/maven/plugins/jar/FileCollector.java +++ b/src/main/java/org/apache/maven/plugins/jar/FileCollector.java @@ -28,6 +28,7 @@ import java.util.ArrayDeque; import java.util.Deque; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import org.apache.maven.api.annotations.Nonnull; @@ -56,7 +57,7 @@ final class FileCollector extends SimpleFileVisitor { /** * The file to check for deciding whether the JAR is modular. */ - private static final String MODULE_DESCRIPTOR_FILE_NAME = "module-info.class"; + static final String MODULE_DESCRIPTOR_FILE_NAME = "module-info.class"; /** * The {@value} directory. @@ -173,7 +174,7 @@ final class FileCollector extends SimpleFileVisitor { */ private void resetToPackageHierarchy() { currentModule = packageHierarchy; - currentFilesToArchive = currentModule.baseRelease; + currentFilesToArchive = currentModule.baseRelease(); } /** @@ -186,23 +187,29 @@ private void resetToPackageHierarchy() { private void enterModuleDirectory(final Path directory) { String moduleName = directory.getFileName().toString(); currentModule = moduleHierarchy.computeIfAbsent(moduleName, (name) -> context.newArchive(name, directory)); - currentFilesToArchive = currentModule.newTargetRelease(directory, null); + currentFilesToArchive = currentModule.newTargetRelease(directory, currentTargetVersion); } /** * Declares that the given directory is the base directory of a target Java version. + * The {@code useDirectly} argument tells whether the content of this directory will be specified directly + * as the content to add in the JAR file. This argument should be {@code false} when there is + * another directory level (the module names) to process before to add content. * * @param directory a {@code "META-INF/versions/"} or {@code "META-INF/versions-modular/"} directory + * @param useDirectly whether the directory is {@code "META-INF/versions/"} * @return whether to skip the directory because of invalid version number */ - private boolean enterVersionDirectory(Path directory) { + private boolean enterVersionDirectory(final Path directory, final boolean useDirectly) { try { currentTargetVersion = Runtime.Version.parse(directory.getFileName().toString()); } catch (IllegalArgumentException e) { context.warnInvalidVersion(directory, e); return true; } - currentFilesToArchive = currentModule.newTargetRelease(directory, currentTargetVersion); + if (useDirectly) { + currentFilesToArchive = currentModule.newTargetRelease(directory, currentTargetVersion); + } return false; } @@ -267,7 +274,7 @@ public FileVisitResult preVisitDirectory(final Path directory, final BasicFileAt * May also be a `/META-INF/versions//` directory, even if the latter is not * the layout generated by Maven Compiler Plugin. */ - if (enterVersionDirectory(directory)) { + if (enterVersionDirectory(directory, true)) { return FileVisitResult.SKIP_SUBTREE; } role = DirectoryRole.RESOURCES; @@ -279,7 +286,7 @@ public FileVisitResult preVisitDirectory(final Path directory, final BasicFileAt * That directory contains all modules for the version. */ resetToPackageHierarchy(); // No module in particular yet. - if (enterVersionDirectory(directory)) { + if (enterVersionDirectory(directory, false)) { return FileVisitResult.SKIP_SUBTREE; } role = DirectoryRole.MODULES; @@ -291,7 +298,6 @@ public FileVisitResult preVisitDirectory(final Path directory, final BasicFileAt */ enterModuleDirectory(directory); role = DirectoryRole.NAMED_MODULE; - currentFilesToArchive = currentModule.newTargetRelease(directory, currentTargetVersion); break; case NAMED_MODULE: @@ -343,7 +349,7 @@ public FileVisitResult postVisitDirectory(final Path directory, final IOExceptio case VERSIONS: case VERSIONS_MODULAR: // Exited the directory for one target Java release. - currentFilesToArchive = currentModule.baseRelease; + currentFilesToArchive = currentModule.baseRelease(); currentTargetVersion = null; break; @@ -373,6 +379,21 @@ public FileVisitResult visitFile(final Path file, final BasicFileAttributes attr return FileVisitResult.CONTINUE; } + /** + * Removes all empty archives and ensures that the lowest version is declared as the base version. + * This method should be invoked after all output directories to archive have been fully scanned. + * If {@code skipIfEmpty} is {@code false}, then this method ensures that at least one archive + * remains even if that archive is empty. + * + * @param skipIfEmpty value of {@link AbstractJarMojo#skipIfEmpty} + */ + public void prune(boolean skipIfEmpty) { + boolean isPackageHierarchy = moduleHierarchy.isEmpty(); + moduleHierarchy.values().forEach((archive) -> archive.prune(skipIfEmpty)); + moduleHierarchy.values().removeIf(Archive::canSkip); + packageHierarchy.prune(isPackageHierarchy && skipIfEmpty); + } + /** * Moves, copies or ignores orphan files. * An orphan file is a file which is not in any module when module hierarchy is used. @@ -385,32 +406,56 @@ public FileVisitResult visitFile(final Path file, final BasicFileAttributes attr * in each module, and ignore the {@code DEPENDENCIES} file because its content is not * correct for a module. For now, we just log a warning an ignore.

* + *

Preconditions

+ * The {@link #prune(boolean)} method should have been invoked once before to invoke this method. + * * @return if this method ignored some files, the root directory of those files */ Path handleOrphanFiles() { - if (moduleHierarchy.isEmpty() || packageHierarchy.canSkip(true)) { + if (moduleHierarchy.isEmpty() || packageHierarchy.canSkip()) { // Classpath project or module-project without orphan files. Nothing to do. return null; } // TODO: we may want to copy LICENSE and NOTICE files here. - return packageHierarchy.clear(); + return packageHierarchy.discardAllFiles(); } /** * Writes all JAR files. + * The {@link #prune(boolean)} method should have been invoked once before to invoke this method. * * @throws MojoException if an error occurred during the execution of the "jar" tool * @throws IOException if an error occurred while reading or writing a manifest file */ void writeAllJARs(final ToolExecutor executor) throws IOException { for (Archive module : moduleHierarchy.values()) { - if (!module.canSkip(executor.skipIfEmpty)) { + if (!module.canSkip()) { executor.writeSingleJAR(this, module); } } - // `packageHierarchy` is expected to be empty if `moduleHierarchy` was used. - if (!packageHierarchy.canSkip(executor.skipIfEmpty || !moduleHierarchy.isEmpty())) { + if (!packageHierarchy.canSkip()) { executor.writeSingleJAR(this, packageHierarchy); } } + + /** + * {@return the paths to all root directories of modules in a module hierarchy} + * They are usually {@code target/classes/} directories, but could also + * be sub-directories in the {@code META-INF/modular-versions//} directory. + * If the project does not use module hierarchy, then this method returns an empty list. + * + *

Ignored package hierarchy

+ * Note that an empty list does not necessarily means that the JAR is not modular, + * as a modular JAR can also be built from package hierarchy. But we intentionally + * ignore the latter case because this method is used for deriving POM files, and + * we do not perform such derivation for projects organized in the classical Maven 3 way. + * + *

Preconditions

+ * The {@link #prune(boolean)} method should have been invoked once before to invoke this method. + */ + List getModuleHierarchyRoots() { + return moduleHierarchy.values().stream() + .map((archive) -> archive.baseRelease().directory) + .toList(); + } } diff --git a/src/main/java/org/apache/maven/plugins/jar/JarMojo.java b/src/main/java/org/apache/maven/plugins/jar/JarMojo.java index 9bf9028d..a83c4389 100644 --- a/src/main/java/org/apache/maven/plugins/jar/JarMojo.java +++ b/src/main/java/org/apache/maven/plugins/jar/JarMojo.java @@ -20,6 +20,7 @@ import java.nio.file.Path; +import org.apache.maven.api.PathScope; import org.apache.maven.api.plugin.annotations.Mojo; import org.apache.maven.api.plugin.annotations.Parameter; @@ -68,4 +69,14 @@ protected String getType() { protected Path getClassesDirectory() { return classesDirectory; } + + /** + * Returns the scope of dependencies for main code. + * + * @return {@link PathScope#MAIN_COMPILE} + */ + @Override + protected PathScope getDependencyScope() { + return PathScope.MAIN_COMPILE; + } } diff --git a/src/main/java/org/apache/maven/plugins/jar/MetadataFiles.java b/src/main/java/org/apache/maven/plugins/jar/MetadataFiles.java index 790627b2..521b4234 100644 --- a/src/main/java/org/apache/maven/plugins/jar/MetadataFiles.java +++ b/src/main/java/org/apache/maven/plugins/jar/MetadataFiles.java @@ -58,6 +58,11 @@ final class MetadataFiles implements Closeable { */ static final String MAVEN_DIR = "maven"; + /** + * The project for which to write metadata files. + */ + private final Project project; + /** * The output directory (usually {@code ${baseDir}/target/}). */ @@ -69,14 +74,40 @@ final class MetadataFiles implements Closeable { */ private final List filesToDelete; + /** + * The POM file to attach to the artifact. This is initially the project POM, + * but will be replaced by a new file generated by {@link ForModule} if module hierarchy is used. + * That file may be copied verbatim in the {@code META-INF/maven/} directory of the JAR. + */ + private Path attachedPOM; + /** * Creates an initially empty set of temporary metadata files. * + * @param project the project for which to write metadata files * @param buildDir the (usually) {@code ${baseDir}/target/} directory */ - MetadataFiles(final Path buildDir) { + MetadataFiles(Project project, Path buildDir) { + this.project = project; this.buildDir = buildDir; filesToDelete = new ArrayList<>(); + attachedPOM = project.getPomPath(); + } + + /** + * Derives a POM as the intersection of the given {@code model} and {@code archive}. + * + * @param context the tool executor which is generating all archives + * @param archive the archive for which to generate a POM + * @param manifest manifest to use for deriving project name, or {@code null} if none + * @throws IOException if an error occurred while reading the {@code module-info.class} file + * or while writing the POM file + */ + void deriveModulePOM(ToolExecutor context, Archive archive, Manifest manifest) throws IOException { + var pom = context.pomDerivation.new ForModule(archive, manifest); + pom.writeModulePOM(); + attachedPOM = pom.pomFile; + archive.pomFile = pom.pomFile; } /** @@ -129,33 +160,31 @@ private Path createDirectories(Path dir, String... path) throws IOException { * Writes the {@code pom.xml} and {@code pom.properties} files. * This method returns the base temporary directory followed by files that the "jar" tool will need to add * - * @param project the project for which to write the files * @param archive archive configuration * @param reproducible whether to enforce reproducible build * @return arguments for the "jar" tool * @throws IOException if an error occurred while writing the files */ - public List addPOM(final Project project, final MavenArchiveConfiguration archive, final boolean reproducible) - throws IOException { + public List addPOM(final MavenArchiveConfiguration archive, final boolean reproducible) throws IOException { final String groupId = project.getGroupId(); final String artifactId = project.getArtifactId(); final String version; - ProducedArtifact pom = project.getPomArtifact(); + final ProducedArtifact pom = project.getPomArtifact(); if (pom.isSnapshot()) { version = pom.getVersion().toString(); } else { version = project.getVersion(); } - Path baseDir = baseDirectory(); - Path mavenDir = createDirectories(baseDir, META_INF, MAVEN_DIR, groupId, artifactId); - Path pomFile = linkOrCopy(project.getPomPath(), mavenDir.resolve("pom.xml")); + final Path baseDir = baseDirectory(); + final Path mavenDir = createDirectories(baseDir, META_INF, MAVEN_DIR, groupId, artifactId); + final Path pomFile = linkOrCopy(attachedPOM, mavenDir.resolve("pom.xml")); filesToDelete.add(pomFile); // Add soon for deleting this file even if an exception is thrown below. /* * Subset of above "pom.xml" file but written as a properties file. * If reproducible build is enabled, we will need to reformat after * writing for ensuring a deterministic order of entries. */ - Properties properties = new Properties(); + final var properties = new Properties(); Path propertiesFile = archive.getPomPropertiesFile(); if (propertiesFile != null) { try (InputStream in = Files.newInputStream(propertiesFile)) { diff --git a/src/main/java/org/apache/maven/plugins/jar/PomDerivation.java b/src/main/java/org/apache/maven/plugins/jar/PomDerivation.java new file mode 100644 index 00000000..28ea608a --- /dev/null +++ b/src/main/java/org/apache/maven/plugins/jar/PomDerivation.java @@ -0,0 +1,484 @@ +/* + * 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.maven.plugins.jar; + +import javax.xml.stream.XMLStreamException; + +import java.io.BufferedInputStream; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.lang.module.ModuleDescriptor; +import java.lang.module.ModuleFinder; +import java.lang.module.ModuleReference; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.jar.Attributes; +import java.util.jar.Manifest; + +import org.apache.maven.api.DependencyCoordinates; +import org.apache.maven.api.JavaPathType; +import org.apache.maven.api.Session; +import org.apache.maven.api.Type; +import org.apache.maven.api.model.Dependency; +import org.apache.maven.api.model.Model; +import org.apache.maven.api.plugin.MojoException; +import org.apache.maven.api.services.DependencyCoordinatesFactory; +import org.apache.maven.api.services.DependencyCoordinatesFactoryRequest; +import org.apache.maven.api.services.DependencyResolver; +import org.apache.maven.api.services.DependencyResolverRequest; +import org.apache.maven.api.services.DependencyResolverResult; +import org.apache.maven.api.services.ModelBuilderException; +import org.apache.maven.model.v4.MavenStaxWriter; + +/** + * A mapper from Maven model dependencies to Java module names. + * A single instance of this class is created for a Maven project, + * then shared by all {@link ForModule} instances (one per module to archive). + */ +final class PomDerivation { + /** + * Whether to expand the list of transitive dependencies in the generated POM. + */ + private static final boolean EXPAND_TRANSITIVE = false; + + /** + * Copy of {@link AbstractJarMojo#session}. + */ + private final Session session; + + /** + * The project model, which includes dependencies of all modules. + */ + private final Model projectModel; + + /** + * The factory to use for creating temporary {@link DependencyCoordinates} instances. + */ + private final DependencyCoordinatesFactory coordinateFactory; + + /** + * Provide module descriptors from module names. + */ + private final ModuleFinder moduleFinder; + + /** + * Module references from paths to the JAR file or root directory. + */ + private final Map fromURI; + + /** + * Module names associated to Maven dependencies. + * This map contains {@link DependencyCoordinates#getId()} as keys and module references as values. + * This is used for detecting which dependencies are really used according {@code module-info.class}. + * + * @todo The keys should be instances of {@link DependencyCoordinates}. Unfortunately, as of Maven 4.0.0-rc-5 + * that interface does not define the {@code equals} and {@code hashCode} contracts. + */ + private final Map fromDependency; + + /** + * Modules that are built by the project. Keys are module names. + */ + private final Map builtModules; + + /** + * Creates a new mapper from Maven dependency to module name. + * + * @param mojo the enclosing MOJO + * @param moduleRoots paths to root directories of each module to archive in a module hierarchy + * @throws IOException if an I/O error occurred while fetching dependencies + * @throws MavenException if an error occurred while fetching dependencies for a reason other than I/O. + */ + PomDerivation(final AbstractJarMojo mojo, final List moduleRoots) throws IOException { + this.session = mojo.getSession(); + projectModel = mojo.getProject().getModel(); + coordinateFactory = session.getService(DependencyCoordinatesFactory.class); + DependencyResolver resolver = session.getService(DependencyResolver.class); + DependencyResolverResult result = resolver.resolve(DependencyResolverRequest.builder() + .session(session) + .project(mojo.getProject()) + .requestType(DependencyResolverRequest.RequestType.RESOLVE) + .pathScope(mojo.getDependencyScope()) + .pathTypeFilter(Set.of(JavaPathType.MODULES, JavaPathType.CLASSES)) + .build()); + + rethrow(result); + final Map dependencies = result.getDependencies(); + final Path[] allModulePaths = toRealPaths(moduleRoots, dependencies.values()); + fromURI = new HashMap<>(allModulePaths.length); // TODO: use newHashMap with JDK19. + moduleFinder = ModuleFinder.of(allModulePaths); + for (ModuleReference reference : moduleFinder.findAll()) { + reference.location().ifPresent((location) -> fromURI.put(location, reference)); + } + fromDependency = new HashMap<>(dependencies.size()); // TODO: use newHashMap with JDK19. + for (Map.Entry entry : dependencies.entrySet()) { + Path modulePath = entry.getValue().toRealPath(); + ModuleReference reference = fromURI.get(modulePath.toUri()); + if (reference != null) { + DependencyCoordinates coordinates = entry.getKey().toCoordinates(); + String id = coordinates.getId(); + ModuleReference old = fromDependency.putIfAbsent(id, reference); + if (old == null) { + coordinates = withoutVersion(coordinates); + id = coordinates.getId(); + old = fromDependency.putIfAbsent(id, reference); + } + if (old != null && !old.equals(reference)) { + mojo.getLog() + .warn("The \"" + id + "\" dependency is declared twice with different module names: \"" + + old.descriptor().name() + "\" and \"" + + reference.descriptor().name() + "\"."); + } + } + } + builtModules = new HashMap<>(moduleRoots.size()); // TODO: use newHashMap with JDK19. + for (Path root : moduleRoots) { + ModuleDescriptor descriptor = fromURI.get(root.toUri()).descriptor(); + builtModules.put( + descriptor.name(), + Dependency.newBuilder() + .groupId(projectModel.getGroupId()) + .artifactId(descriptor.name()) + .version(projectModel.getVersion()) + .type(Type.MODULAR_JAR) + .build()); + } + } + + /** + * Rebuilds the given dependency coordinates without version. + * + * Note: I'm not sure if it is necessary. This is done in case version numbers are not resolved + * in the dependencies of the model returned by {@code Project.getModel()}, or are not resolved + * in the same way as what we get from {@link DependencyResolver}. + */ + private DependencyCoordinates withoutVersion(DependencyCoordinates coordinates) { + return coordinateFactory.create(DependencyCoordinatesFactoryRequest.builder() + .session(session) + .groupId(coordinates.getGroupId()) + .artifactId(coordinates.getArtifactId()) + .extension(coordinates.getExtension()) + .classifier(coordinates.getClassifier()) + .build()); + } + + /** + * If the resolver failed, propagates its exception. + * + * @param result the resolver result + * @throws IOException if the result contains an I/O error + */ + private static void rethrow(DependencyResolverResult result) throws IOException { + Exception exception = null; + for (Exception cause : result.getExceptions()) { + if (cause instanceof UncheckedIOException e) { + cause = e.getCause(); + } + if (exception != null) { + exception.addSuppressed(cause); + } else if (cause instanceof RuntimeException || cause instanceof IOException) { + exception = cause; + } else { + exception = new MojoException("Cannot collect the runtime dependencies.", cause); + } + } + if (exception != null) { + if (exception instanceof IOException e) { + throw e; + } else { + throw (RuntimeException) exception; // A ClassCastException here would be a bug in above loop. + } + } + } + + /** + * Returns the real paths of the given collections, in iteration order and without duplicated values. + */ + private static Path[] toRealPaths(Collection moduleRoots, Collection dependencies) throws IOException { + // TODO: use newLinkedHashSet(int) after we are allowed to compile for JDK19. + final var paths = new LinkedHashSet(moduleRoots.size() + dependencies.size()); + for (Path path : moduleRoots) { + paths.add(path.toRealPath()); + } + for (Path path : dependencies) { + paths.add(path.toRealPath()); + } + return paths.toArray(Path[]::new); + } + + /** + * Returns the module descriptor for the {@code module-info.class} at the given path. + * + * @param moduleInfo path to a {@code module-info.class} file + * @return module descriptor for the specified file + * @throws IOException if an error occurred while reading the file + */ + private ModuleDescriptor findModuleDescriptor(Path moduleInfo) throws IOException { + Path directory = moduleInfo.toRealPath().getParent(); + ModuleReference reference = fromURI.get(directory.toUri()); + if (reference != null) { + return reference.descriptor(); + } + try (InputStream in = new BufferedInputStream(Files.newInputStream(moduleInfo))) { + return ModuleDescriptor.read(in); + } + } + + /** + * Returns the module descriptor for the specified Maven dependency. + * + * @param dependency dependency for which to get the module descriptor + * @return Java module descriptor for the given Maven dependency + */ + private Optional findModuleDescriptor(Dependency dependency) { + DependencyCoordinates coordinates = coordinateFactory.create(session, dependency); + ModuleReference reference = fromDependency.get(coordinates.getId()); + if (reference == null) { + coordinates = withoutVersion(coordinates); + reference = fromDependency.get(coordinates.getId()); + if (reference == null) { + return Optional.empty(); + } + } + return Optional.of(reference.descriptor()); + } + + /** + * Returns the module descriptor for the specified module name. + * + * @param moduleName name of the module for which to get the descriptor + * @return module descriptor for the specified module name + */ + private Optional findModuleDescriptor(String moduleName) { + return moduleFinder.find(moduleName).map(ModuleReference::descriptor); + } + + /** + * Derives a POM as the intersection of the consumer POM + * and the dependencies required by {@code module-info}. + */ + final class ForModule { + /** + * Value of the {@code } element in the derived POM, or {@code null} if none. + */ + private String name; + + /** + * Name of the module for which to derive a POM file. + */ + private final String moduleName; + + /** + * Whether a dependency is optional or required only at runtime. + */ + private enum Modifier { + OPTIONAL, + RUNTIME + } + + /** + * The required dependencies as Java module names, including transitive dependencies. + * Values tell whether the dependency is optional or should have runtime scope. + */ + private final Map> requires; + + /** + * Path to the POM file written by this class. + */ + final Path pomFile; + + /** + * Creates a new POM generator for the given archive. + * + * @param archive the archive for which to generate a POM + * @param manifest manifest to use for deriving project name, or {@code null} if none + * @throws IOException if an error occurred while reading the {@code module-info.class} file + */ + ForModule(final Archive archive, final Manifest manifest) throws IOException { + moduleName = archive.moduleName; + pomFile = derivePathToPOM(archive.jarFile); + requires = new LinkedHashMap<>(); + for (Path file : archive.moduleInfoFiles()) { + addDependencies(findModuleDescriptor(file), EnumSet.noneOf(Modifier.class)); + } + if (manifest != null) { + name = (String) manifest.getMainAttributes().get(Attributes.Name.IMPLEMENTATION_TITLE); + if (name == null) { + name = (String) manifest.getMainAttributes().get(Attributes.Name.SPECIFICATION_TITLE); + } + } + } + + /** + * Add the dependencies of the given module, including transitive dependencies. + * If the same dependency is added twice with different optional flags, + * the {@code false} value (i.e., mandatory dependency) has precedence. + * + * @param descriptor description of the module for which to add dependencies + * @param parentModifiers modifiers of the parent module for which this method adds dependencies + */ + private void addDependencies(final ModuleDescriptor descriptor, final EnumSet parentModifiers) { + for (ModuleDescriptor.Requires r : descriptor.requires()) { + final EnumSet modifiers = parentModifiers.clone(); + if (r.modifiers().contains(ModuleDescriptor.Requires.Modifier.STATIC)) { + modifiers.add(Modifier.OPTIONAL); + } + if (!r.modifiers().contains(ModuleDescriptor.Requires.Modifier.TRANSITIVE)) { + modifiers.add(Modifier.RUNTIME); + } + EnumSet current = requires.computeIfAbsent(r.name(), (key) -> modifiers); + if (EXPAND_TRANSITIVE && (current == modifiers || current.retainAll(modifiers))) { + // Transitive dependencies if not already added or if it needs to update modifiers. + findModuleDescriptor(r.name()).ifPresent((td) -> addDependencies(td, modifiers)); + } + } + } + + /** + * Derives the path to the POM file to generate. + * + * @param jarFile path to the JAR file (the file does not need to exist) + * @return path to the POM file to generate + */ + private static Path derivePathToPOM(final Path jarFile) { + String filename = jarFile.getFileName().toString(); + filename = filename.substring(0, filename.lastIndexOf('.') + 1) + "pom"; + return jarFile.resolveSibling(filename); + } + + /** + * Derives a POM file for the archive specified ad construction time. + * + * @throws IOException if an error occurred while writing the POM file + */ + void writeModulePOM() throws IOException { + try { + Model moduleModel = derive(); + try (BufferedWriter out = Files.newBufferedWriter(pomFile)) { + var sw = new MavenStaxWriter(); + sw.setAddLocationInformation(false); + sw.write(out, moduleModel); + out.newLine(); + } + } catch (ModelBuilderException | XMLStreamException e) { + throw new MojoException("Cannot derive a POM file for the \"" + moduleName + "\" module.", e); + } + } + + /** + * Derives the module POM file as the intersection of the project POM and the archive. + * + * @return intersection of {@link #projectModel} and {@code module-info.class} + * @throws ModelBuilderException if an error occurred while building the model + */ + private Model derive() throws ModelBuilderException { + Model.Builder builder = Model.newBuilder(projectModel, true).artifactId(moduleName); + /* + * Remove the list of sub-projects (also known as "modules" in Maven 3). + * They are not relevant to the specific JAR file that we are creating. + */ + builder = builder.root(false).modules(null).subprojects(null); + /* + * Remove all build information. The element contains information about many modules, + * not only the specific module that we are archiving. The element is for building the + * project and is usually not of interest for consumers. + */ + builder = builder.build(null).reporting(null); + /* + * Filter the dependencies by keeping only the one declared in a `requires` statement of the + * `module-info.class` of the module that we are archiving. Also adjust the `` and + * `` values. The dependencies that we found are removed from the `requires` map as a + * way to make sure that we do not add them twice. In principle, the map should become empty + * at the end of this loop. + */ + final List dependencies = projectModel.getDependencies(); + if (dependencies != null) { + final var filteredDependencies = new ArrayList(dependencies.size()); + for (var iterator = requires.entrySet().iterator(); iterator.hasNext(); ) { + Map.Entry> entry = iterator.next(); + Dependency dependency = builtModules.get(entry.getKey()); + if (dependency != null) { + filteredDependencies.add(amend(dependency, entry.getValue())); + iterator.remove(); + } + } + for (Dependency dependency : dependencies) { + String dependencyModuleName = findModuleDescriptor(dependency) + .map(ModuleDescriptor::name) + .orElse(null); + /* + * If `dependencyModuleName` is null, then the dependency scope is "test" or some other scope + * that resolver has chosen to exclude. Note that this is true even for JAR on the classpath, + * because we stored the automatic module name in `PomDerivation`. Next, if `modifiers` is null, + * then the dependency has compile or runtime scope but is not used by the module to archive. + */ + if (dependencyModuleName != null) { + EnumSet modifiers = requires.remove(dependencyModuleName); + if (modifiers != null) { + filteredDependencies.add(amend(dependency, modifiers)); + } + } + } + builder.dependencies(filteredDependencies); + } + /* + * Replace the `` element by the equivalent value defined in MANIFEST.MF. + * We do this replacement because the `` of the project model applies to all modules, + * while the MANIFEST.MF has more chances to be specific to the module that we are archiving. + */ + if (name != null) { + builder = builder.name(name); + } + return builder.preserveModelVersion(false).modelVersion("4.0.0").build(); + } + + /** + * Modifies the optional and scope elements of the given dependency according the given modifiers. + * + * @param dependency the dependency to amend + * @param modifiers the modifiers to apply + * @return the amended dependency + */ + private static Dependency amend(Dependency dependency, EnumSet modifiers) { + String scope = modifiers.contains(Modifier.RUNTIME) ? "runtime" : null; + if (!Objects.equals(scope, dependency.getScope())) { + dependency = dependency.withScope(scope); + } + boolean optional = modifiers.contains(Modifier.OPTIONAL); + if (Boolean.parseBoolean(dependency.getOptional()) != optional) { + dependency = dependency.withOptional(Boolean.toString(optional)); + } + return dependency; + } + } +} diff --git a/src/main/java/org/apache/maven/plugins/jar/Providers.java b/src/main/java/org/apache/maven/plugins/jar/Providers.java index b9a4acb0..8befcf53 100644 --- a/src/main/java/org/apache/maven/plugins/jar/Providers.java +++ b/src/main/java/org/apache/maven/plugins/jar/Providers.java @@ -22,25 +22,13 @@ import org.apache.maven.api.di.Named; import org.apache.maven.api.di.Provides; import org.apache.maven.api.services.ProjectManager; -import org.codehaus.plexus.archiver.Archiver; -import org.codehaus.plexus.archiver.jar.JarArchiver; -import org.codehaus.plexus.archiver.jar.JarToolModularJarArchiver; +/** + * For providing instances to fields annotated with {@code @Inject} if the MOJO. + */ @Named class Providers { - @Named("jar") - @Provides - static Archiver jarArchiver() { - return new JarArchiver(); - } - - @Named("mjar") - @Provides - static Archiver mjarArchiver() { - return new JarToolModularJarArchiver(); - } - @Provides static ProjectManager projectManager(Session session) { return session.getService(ProjectManager.class); diff --git a/src/main/java/org/apache/maven/plugins/jar/TestJarMojo.java b/src/main/java/org/apache/maven/plugins/jar/TestJarMojo.java index 84de519e..d3e7830b 100644 --- a/src/main/java/org/apache/maven/plugins/jar/TestJarMojo.java +++ b/src/main/java/org/apache/maven/plugins/jar/TestJarMojo.java @@ -20,6 +20,7 @@ import java.nio.file.Path; +import org.apache.maven.api.PathScope; import org.apache.maven.api.plugin.MojoException; import org.apache.maven.api.plugin.annotations.Mojo; import org.apache.maven.api.plugin.annotations.Parameter; @@ -86,4 +87,14 @@ public void execute() throws MojoException { super.execute(); } } + + /** + * Returns the scope of dependencies for test code. + * + * @return {@link PathScope#TEST_COMPILE} + */ + @Override + protected PathScope getDependencyScope() { + return PathScope.TEST_COMPILE; + } } diff --git a/src/main/java/org/apache/maven/plugins/jar/TimestampCheck.java b/src/main/java/org/apache/maven/plugins/jar/TimestampCheck.java index d7122ef8..59287cae 100644 --- a/src/main/java/org/apache/maven/plugins/jar/TimestampCheck.java +++ b/src/main/java/org/apache/maven/plugins/jar/TimestampCheck.java @@ -25,9 +25,9 @@ import java.nio.file.SimpleFileVisitor; import java.nio.file.attribute.BasicFileAttributes; import java.nio.file.attribute.FileTime; +import java.util.Collection; import java.util.Enumeration; import java.util.HashSet; -import java.util.Map; import java.util.Set; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; @@ -129,13 +129,11 @@ boolean isUpdated(final Path file, final BasicFileAttributes attributes, final b /** * Checks if the JAR file contains all the given files, no extra entry, and no outdated entry. - * For each {@code Map.Entry}, the key is the base directory for resolving relative paths given by the value. - * If an I/O error occurs, this method returns {@code true} for instructing to recreate the JAR file. * * @param fileSets pairs of base directory and files potentially relative to the base directory * @return whether the JAR file is up-to-date */ - boolean isUpToDateJAR(final Iterable>> fileSets) { + boolean isUpToDateJAR(final Collection fileSets) { // No need to use JarFile because no need to handle META-INF in a special way. try (ZipFile jar = new ZipFile(jarFile.toFile())) { entries = jar.entries(); @@ -144,9 +142,9 @@ boolean isUpToDateJAR(final Iterable>> fileSets) return false; } } - for (Map.Entry> fileSet : fileSets) { - final Path baseDir = fileSet.getKey(); - for (Path file : fileSet.getValue()) { + for (Archive.FileSet fileSet : fileSets) { + final Path baseDir = fileSet.directory; + for (Path file : fileSet.files) { file = baseDir.resolve(file); if (!filesInBuild.remove(file)) { // For skipping the files already verified by above loop. Files.walkFileTree(file, this); diff --git a/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java b/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java index 7a8f873c..acd7fda1 100644 --- a/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java +++ b/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java @@ -49,7 +49,12 @@ final class ToolExecutor { /** * The Maven project for which to create an archive. */ - private final Project project; + final Project project; + + /** + * {@code "jar"} or {@link "test-jar"}. + */ + private final String artifactType; /** * The output directory where to write the JAR file. @@ -101,8 +106,22 @@ final class ToolExecutor { /** * The paths to the created archive files. + * Map keys are module names or {@code null} if the project does not use module hierarchy. + * Values are (type, path) pairs associated with each module where + * type is {@code "pom"}, {@code "jar"} or {@code "test-jar"} and path + * is the path to the POM or JAR file. + */ + private final Map> result; + + /** + * Mapper from Maven dependencies to Java modules, or {@code null} if the project does not use module hierarchy. + * This mapper is created only once for a Maven project and reused for each Java module to archive. + * + *

This field is not used directly by {@code ToolExecutor}. It is defined in this class for transferring + * this information from {@link AbstractJarMojo} to {@link PomDerivation.ForModule}. + * This is an internal mechanism that should not be public or protected.

*/ - private final Map result; + PomDerivation pomDerivation; /** * Manifest to merge with the manifest found in the files to archive. @@ -131,11 +150,6 @@ final class ToolExecutor { */ private final String outputTimestamp; - /** - * Whether to skip empty JAR files. - */ - final boolean skipIfEmpty; - /** * Whether to force to build new JAR files even if none of the contents appear to have changed. */ @@ -156,10 +170,10 @@ final class ToolExecutor { */ ToolExecutor(AbstractJarMojo mojo, Manifest manifest, MavenArchiveConfiguration archive) throws IOException { project = mojo.getProject(); + artifactType = mojo.getType(); outputDirectory = mojo.getOutputDirectory(); classifier = AbstractJarMojo.nullIfAbsent(mojo.getClassifier()); finalName = mojo.getFinalName(); - skipIfEmpty = mojo.skipIfEmpty; forceCreation = mojo.forceCreation; outputTimestamp = mojo.getOutputTimestamp(); logger = mojo.getLog(); @@ -234,7 +248,12 @@ Archive newArchive(final String moduleName, final Path directory) { } /** - * Writes all JAR files. + * Writes all JAR files, together with their derived POM files if applicable. + * The derived POM files are the intersections of the project POM with the + * content of {@code module-info.class} files. + * + *

Preconditions

+ * The {@link FileCollector#prune(boolean)} method should have been invoked once before to invoke this method. * * @param files the result of scanning the build directory for listing the files or directories to archive * @return the paths to the created archive files @@ -242,7 +261,7 @@ Archive newArchive(final String moduleName, final Path directory) { * @throws IOException if an error occurred while reading or writing a manifest file */ @SuppressWarnings("ReturnOfCollectionOrArrayField") - public Map writeAllJARs(final FileCollector files) throws IOException { + public Map> writeAllJARs(final FileCollector files) throws IOException { Path ignored = files.handleOrphanFiles(); files.writeAllJARs(this); if (ignored != null) { @@ -264,7 +283,7 @@ void writeSingleJAR(final FileCollector files, final Archive archive) throws IOE final Path relativePath = relativize(project.getRootDirectory(), archive.jarFile); if (archive.isUpToDateJAR()) { logger.info("Keep up-to-date JAR: \"" + relativePath + "\"."); - result.put(archive.moduleName, archive.jarFile); + archive.saveArtifactPaths(artifactType, result); return; } logger.info("Building JAR: \"" + relativePath + "\"."); @@ -290,12 +309,15 @@ void writeSingleJAR(final FileCollector files, final Archive archive) throws IOE * and for the Maven metadata (if requested). The temporary files are in the `target` * directory and will be deleted, unless the build fails or is run in verbose mode. */ - try (MetadataFiles temporaryMetadataFiles = new MetadataFiles(outputDirectory)) { + try (MetadataFiles metadata = new MetadataFiles(project, outputDirectory)) { if (writeTemporaryManifest) { - archive.setManifest(temporaryMetadataFiles.addManifest(manifest), true); + archive.setManifest(metadata.addManifest(manifest), true); + } + if (archive.moduleName != null) { + metadata.deriveModulePOM(this, archive, manifest); } if (archiveConfiguration.isAddMavenDescriptor()) { - archive.mavenFiles = temporaryMetadataFiles.addPOM(project, archiveConfiguration, isReproducible()); + archive.mavenFiles = metadata.addPOM(archiveConfiguration, isReproducible()); } /* * Prepare the arguments to send to the `jar` tool and log a message. @@ -324,7 +346,7 @@ void writeSingleJAR(final FileCollector files, final Archive archive) throws IOE } if (status != 0 || logger.isDebugEnabled()) { Path debugFile = archive.writeDebugFile(project.getBasedir(), outputDirectory, classifier, arguments); - temporaryMetadataFiles.cancelFileDeletion(); + metadata.cancelFileDeletion(); if (status != 0) { logCommandLineTip(project.getBasedir(), debugFile); String error = errors.toString().strip(); @@ -338,7 +360,7 @@ void writeSingleJAR(final FileCollector files, final Archive archive) throws IOE arguments.clear(); errors.setLength(0); messages.setLength(0); - result.put(archive.moduleName, archive.jarFile); + archive.saveArtifactPaths(artifactType, result); } /** From a1810e053d2411df0c3891461ab501aa910574b7 Mon Sep 17 00:00:00 2001 From: Martin Desruisseaux Date: Tue, 30 Dec 2025 20:12:44 +0100 Subject: [PATCH 5/5] Change of strategy in the generated POM files for modules. Instead of copying the project POM with filtered dependencies, use the project POM (except dependencies) as the parent POM. --- .../org/apache/maven/plugins/jar/Archive.java | 34 ++++-- .../maven/plugins/jar/FileCollector.java | 28 +++-- .../maven/plugins/jar/PomDerivation.java | 108 ++++++++++++------ .../maven/plugins/jar/ToolExecutor.java | 10 +- 4 files changed, 121 insertions(+), 59 deletions(-) diff --git a/src/main/java/org/apache/maven/plugins/jar/Archive.java b/src/main/java/org/apache/maven/plugins/jar/Archive.java index a9ae0998..fb72aea8 100644 --- a/src/main/java/org/apache/maven/plugins/jar/Archive.java +++ b/src/main/java/org/apache/maven/plugins/jar/Archive.java @@ -34,6 +34,7 @@ import java.util.jar.Attributes; import java.util.jar.Manifest; +import org.apache.maven.api.Type; import org.apache.maven.api.annotations.Nonnull; import org.apache.maven.api.annotations.Nullable; import org.apache.maven.api.plugin.Log; @@ -284,6 +285,7 @@ Path discardAllFiles() { for (FileSet release : filesetForRelease.values()) { base = release.discardAllFiles(base); } + filesetForRelease.clear(); return base; } @@ -296,18 +298,19 @@ Path discardAllFiles() { * @param skipIfEmpty value of {@link AbstractJarMojo#skipIfEmpty} */ public void prune(final boolean skipIfEmpty) { - FileSet keep = (skipIfEmpty || filesetForRelease.isEmpty()) + FileSet keep = (skipIfEmpty || isEmpty()) ? null : filesetForRelease.firstEntry().getValue(); - filesetForRelease.values().removeIf((v) -> v.files.isEmpty()); + filesetForRelease.values().removeIf((fs) -> fs.files.isEmpty()); Iterator> it = filesetForRelease.entrySet().iterator(); if (it.hasNext()) { Map.Entry first = it.next(); - if (first.getKey() != null) { - keep = first.getValue(); - it.remove(); + if (first.getKey() == null) { + return; // Already contains an entry for the base version, nothing to do. } + keep = first.getValue(); + it.remove(); } if (keep != null) { filesetForRelease.put(null, keep); @@ -315,11 +318,15 @@ public void prune(final boolean skipIfEmpty) { } /** - * {@return whether this module can be skipped} - * This is {@code true} if this module has no file to archive, ignoring Maven-generated files. + * {@return whether this archive has nothing to archive} + * Note that this method may return {@code false} even when there is zero file to archive. + * It may happen if {@link AbstractJarMojo#skipIfEmpty} is {@code false}. In such case, the + * "empty" JAR file will still contain at {@code META-INF/MANIFEST.MF} file. + * + *

Prerequisites

* The {@link #prune(boolean)} method should be invoked before this method for accurate result. */ - public boolean canSkip() { + public boolean isEmpty() { return filesetForRelease.isEmpty(); } @@ -545,7 +552,7 @@ Path writeDebugFile(Path baseDir, Path debugDirectory, String classifier, List> addTo) { final Map paths; if (pomFile != null) { - paths = Map.of(artifactType, jarFile, "pom", pomFile); + paths = Map.of(artifactType, jarFile, Type.POM, pomFile); } else { paths = Map.of(artifactType, jarFile); } @@ -560,6 +567,13 @@ void saveArtifactPaths(final String artifactType, final Map release.files.size()) + .sum(); + return sb.append(count).append(" files]").toString(); } } diff --git a/src/main/java/org/apache/maven/plugins/jar/FileCollector.java b/src/main/java/org/apache/maven/plugins/jar/FileCollector.java index e8b5bb03..fdad1ae7 100644 --- a/src/main/java/org/apache/maven/plugins/jar/FileCollector.java +++ b/src/main/java/org/apache/maven/plugins/jar/FileCollector.java @@ -388,10 +388,10 @@ public FileVisitResult visitFile(final Path file, final BasicFileAttributes attr * @param skipIfEmpty value of {@link AbstractJarMojo#skipIfEmpty} */ public void prune(boolean skipIfEmpty) { - boolean isPackageHierarchy = moduleHierarchy.isEmpty(); + boolean isModuleHierarchy = !moduleHierarchy.isEmpty(); moduleHierarchy.values().forEach((archive) -> archive.prune(skipIfEmpty)); - moduleHierarchy.values().removeIf(Archive::canSkip); - packageHierarchy.prune(isPackageHierarchy && skipIfEmpty); + moduleHierarchy.values().removeIf(Archive::isEmpty); + packageHierarchy.prune(isModuleHierarchy || skipIfEmpty); } /** @@ -406,13 +406,13 @@ public void prune(boolean skipIfEmpty) { * in each module, and ignore the {@code DEPENDENCIES} file because its content is not * correct for a module. For now, we just log a warning an ignore.

* - *

Preconditions

+ *

Prerequisites

* The {@link #prune(boolean)} method should have been invoked once before to invoke this method. * * @return if this method ignored some files, the root directory of those files */ Path handleOrphanFiles() { - if (moduleHierarchy.isEmpty() || packageHierarchy.canSkip()) { + if (moduleHierarchy.isEmpty() || packageHierarchy.isEmpty()) { // Classpath project or module-project without orphan files. Nothing to do. return null; } @@ -422,20 +422,26 @@ Path handleOrphanFiles() { /** * Writes all JAR files. + * If the project is multi-module, then this method returns the path to the generated parent POM file. + * + *

Prerequisites

* The {@link #prune(boolean)} method should have been invoked once before to invoke this method. * + * @return path to the generated parent POM file, or {@code null} if none * @throws MojoException if an error occurred during the execution of the "jar" tool * @throws IOException if an error occurred while reading or writing a manifest file */ - void writeAllJARs(final ToolExecutor executor) throws IOException { + Path writeAllJARs(final ToolExecutor executor) throws IOException { for (Archive module : moduleHierarchy.values()) { - if (!module.canSkip()) { - executor.writeSingleJAR(this, module); - } + executor.writeSingleJAR(this, module); + } + if (executor.pomDerivation != null) { + return executor.pomDerivation.writeParentPOM(packageHierarchy); } - if (!packageHierarchy.canSkip()) { + if (!packageHierarchy.isEmpty()) { executor.writeSingleJAR(this, packageHierarchy); } + return null; } /** @@ -450,7 +456,7 @@ void writeAllJARs(final ToolExecutor executor) throws IOException { * ignore the latter case because this method is used for deriving POM files, and * we do not perform such derivation for projects organized in the classical Maven 3 way. * - *

Preconditions

+ *

Prerequisites

* The {@link #prune(boolean)} method should have been invoked once before to invoke this method. */ List getModuleHierarchyRoots() { diff --git a/src/main/java/org/apache/maven/plugins/jar/PomDerivation.java b/src/main/java/org/apache/maven/plugins/jar/PomDerivation.java index 28ea608a..3b076581 100644 --- a/src/main/java/org/apache/maven/plugins/jar/PomDerivation.java +++ b/src/main/java/org/apache/maven/plugins/jar/PomDerivation.java @@ -51,6 +51,7 @@ import org.apache.maven.api.Type; import org.apache.maven.api.model.Dependency; import org.apache.maven.api.model.Model; +import org.apache.maven.api.model.Parent; import org.apache.maven.api.plugin.MojoException; import org.apache.maven.api.services.DependencyCoordinatesFactory; import org.apache.maven.api.services.DependencyCoordinatesFactoryRequest; @@ -364,32 +365,16 @@ private void addDependencies(final ModuleDescriptor descriptor, final EnumSetPOM file to generate. - * - * @param jarFile path to the JAR file (the file does not need to exist) - * @return path to the POM file to generate - */ - private static Path derivePathToPOM(final Path jarFile) { - String filename = jarFile.getFileName().toString(); - filename = filename.substring(0, filename.lastIndexOf('.') + 1) + "pom"; - return jarFile.resolveSibling(filename); - } - /** * Derives a POM file for the archive specified ad construction time. * * @throws IOException if an error occurred while writing the POM file + * + * @see #writeParentPOM(Archive) */ void writeModulePOM() throws IOException { try { - Model moduleModel = derive(); - try (BufferedWriter out = Files.newBufferedWriter(pomFile)) { - var sw = new MavenStaxWriter(); - sw.setAddLocationInformation(false); - sw.write(out, moduleModel); - out.newLine(); - } + writePOM(deriveModulePOM(), pomFile); } catch (ModelBuilderException | XMLStreamException e) { throw new MojoException("Cannot derive a POM file for the \"" + moduleName + "\" module.", e); } @@ -401,19 +386,20 @@ void writeModulePOM() throws IOException { * @return intersection of {@link #projectModel} and {@code module-info.class} * @throws ModelBuilderException if an error occurred while building the model */ - private Model derive() throws ModelBuilderException { - Model.Builder builder = Model.newBuilder(projectModel, true).artifactId(moduleName); - /* - * Remove the list of sub-projects (also known as "modules" in Maven 3). - * They are not relevant to the specific JAR file that we are creating. - */ - builder = builder.root(false).modules(null).subprojects(null); - /* - * Remove all build information. The element contains information about many modules, - * not only the specific module that we are archiving. The element is for building the - * project and is usually not of interest for consumers. - */ - builder = builder.build(null).reporting(null); + private Model deriveModulePOM() throws ModelBuilderException { + Model.Builder builder = Model.newBuilder() + .modelVersion("4.0.0") + .groupId(projectModel.getGroupId()) + .artifactId(moduleName) + .version(projectModel.getVersion()) + .parent(Parent.newBuilder() + .groupId(projectModel.getGroupId()) + .artifactId(projectModel.getArtifactId()) + .version(projectModel.getVersion()) + .build()); + if (name != null) { + builder = builder.name(name); + } /* * Filter the dependencies by keeping only the one declared in a `requires` statement of the * `module-info.class` of the module that we are archiving. Also adjust the `` and @@ -456,10 +442,7 @@ private Model derive() throws ModelBuilderException { * We do this replacement because the `` of the project model applies to all modules, * while the MANIFEST.MF has more chances to be specific to the module that we are archiving. */ - if (name != null) { - builder = builder.name(name); - } - return builder.preserveModelVersion(false).modelVersion("4.0.0").build(); + return builder.build(); } /** @@ -481,4 +464,57 @@ private static Dependency amend(Dependency dependency, EnumSet modifie return dependency; } } + + /** + * Writes the parent POM file as the project file but without the dependencies. + * The dependencies are removed because they will declared on a module-by-module basis. + * + * @param packageHierarchy value of {@link FileCollector#packageHierarchy} + * @return path to the file that has been written + * @throws IOException if an I/O error occurred while writing the model + * + * @see ForModule#writeModulePOM() + */ + Path writeParentPOM(final Archive packageHierarchy) throws IOException { + Path pomFile = derivePathToPOM(packageHierarchy.jarFile); + Model.Builder builder = Model.newBuilder(projectModel, true); + builder = builder.root(false).modules(null).subprojects(null); + builder = builder.dependencies(null).build(null).reporting(null); + builder = builder.preserveModelVersion(false).modelVersion("4.0.0"); + try { + writePOM(builder.build(), pomFile); + } catch (ModelBuilderException | XMLStreamException e) { + throw new MojoException("Cannot write the parent POM.", e); + } + return pomFile; + } + + /** + * Derives the path to the POM file to generate. + * + * @param jarFile path to the JAR file (the file does not need to exist) + * @return path to the POM file to generate + */ + private static Path derivePathToPOM(final Path jarFile) { + String filename = jarFile.getFileName().toString(); + filename = filename.substring(0, filename.lastIndexOf('.') + 1) + "pom"; + return jarFile.resolveSibling(filename); + } + + /** + * Writes the given model in the given file. + * + * @param model the model to write + * @param file the destination file + * @throws IOException if an I/O error occurred while writing the model + * @throws XMLStreamException if a XML error occurred while writing the model + */ + private static void writePOM(Model model, Path file) throws IOException, XMLStreamException { + try (BufferedWriter out = Files.newBufferedWriter(file)) { + var sw = new MavenStaxWriter(); + sw.setAddLocationInformation(false); + sw.write(out, model); + out.newLine(); + } + } } diff --git a/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java b/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java index acd7fda1..b0ff3642 100644 --- a/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java +++ b/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java @@ -37,6 +37,7 @@ import java.util.spi.ToolProvider; import org.apache.maven.api.Project; +import org.apache.maven.api.Type; import org.apache.maven.api.plugin.Log; import org.apache.maven.api.plugin.MojoException; import org.apache.maven.shared.archiver.MavenArchiveConfiguration; @@ -252,7 +253,7 @@ Archive newArchive(final String moduleName, final Path directory) { * The derived POM files are the intersections of the project POM with the * content of {@code module-info.class} files. * - *

Preconditions

+ *

Prerequisites

* The {@link FileCollector#prune(boolean)} method should have been invoked once before to invoke this method. * * @param files the result of scanning the build directory for listing the files or directories to archive @@ -263,11 +264,16 @@ Archive newArchive(final String moduleName, final Path directory) { @SuppressWarnings("ReturnOfCollectionOrArrayField") public Map> writeAllJARs(final FileCollector files) throws IOException { Path ignored = files.handleOrphanFiles(); - files.writeAllJARs(this); + Path parentPOM = files.writeAllJARs(this); if (ignored != null) { logger.warn("Some files in \"" + relativize(outputDirectory, ignored) + "\" were ignored because they belong to no module."); } + if (parentPOM != null) { + if (result.put(null, Map.of(Type.POM, parentPOM)) != null) { + throw new MojoException("Internal error."); // Should never happen. + } + } return result; }