diff --git a/src/main/java/org/apache/maven/plugin/compiler/ModuleInfoPatch.java b/src/main/java/org/apache/maven/plugin/compiler/ModuleInfoPatch.java new file mode 100644 index 000000000..e2c3b3dcc --- /dev/null +++ b/src/main/java/org/apache/maven/plugin/compiler/ModuleInfoPatch.java @@ -0,0 +1,675 @@ +/* + * 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.plugin.compiler; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.Reader; +import java.io.StreamTokenizer; +import java.lang.module.ModuleDescriptor; +import java.nio.file.Path; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.StringJoiner; + +import org.apache.maven.api.Dependency; +import org.apache.maven.api.services.DependencyResolverResult; + +/** + * Reader of {@value #FILENAME} files. + * The main options managed by this class are the options that are not defined by Maven dependencies. + * They are the options for opening or exporting packages to other modules, or reading more modules. + * The values of these options are module names or package names. + * This class does not manage the options for which the value is a path. + * + *

Global options

+ * The {@code --add-modules} and {@code --limit-modules} options are global, not options defined on a per-module basis. + * The global aspect is handled by using shared maps for the {@link #addModules} and {@link #limitModules} fields. + * The value of {@code --add-modules} is usually controlled by the dependencies declared in the {@code pom.xml} file + * and rarely needs to be modified. + * + * @author Martin Desruisseaux + */ +final class ModuleInfoPatch { + /** + * Name of {@value} files that are parsed by this class. + */ + public static final String FILENAME = "module-info-patch.maven"; + + /** + * Maven-specific keyword for meaning to export a package to all the test module path. + * Other keywords such as {@code "ALL-MODULE-PATH"} are understood by the Java compiler. + */ + private static final String TEST_MODULE_PATH = "TEST-MODULE-PATH"; + + /** + * Maven-specific keyword for meaning to export a package to all other modules in the current Maven (sub)project. + * This is useful when a module contains a package of test fixtures also used for the tests in all other modules. + */ + private static final String SUBPROJECT_MODULES = "SUBPROJECT-MODULES"; + + /** + * Special cases for the {@code --add-modules} option. + * The {@value #TEST_MODULE_PATH} keyword is specific to Maven. + * Other keywords in this set are recognized by the Java compiler. + */ + private static final Set ADD_MODULES_SPECIAL_CASES = Set.of("ALL-MODULE-PATH", TEST_MODULE_PATH); + + /** + * Special cases for the {@code --add-exports} option. + * The {@value #TEST_MODULE_PATH} and {@value #SUBPROJECT_MODULES} keywords are specific to Maven. + * Other keywords in this set are recognized by the Java compiler. + */ + private static final Set ADD_EXPORTS_SPECIAL_CASES = + Set.of("ALL-UNNAMED", TEST_MODULE_PATH, SUBPROJECT_MODULES); + + /** + * The name of the module to patch, or {@code null} if unspecified. + * + * @see #getModuleName() + */ + private String moduleName; + + /** + * Values parsed from the {@value #FILENAME} file for {@code --add-modules} option. + * A unique set is shared by {@code ModuleInfoPatch} instances of a project, because there + * is only one {@code --add-module} option applying to all modules. The values will be the + * union of the values provided by all {@value #FILENAME} files. + */ + private final Set addModules; + + /** + * Values parsed from the {@value #FILENAME} file for {@code --limit-modules} option. + * A unique set is shared by all {@code ModuleInfoPatch} instances of a project in the + * same way as {@link #addModules}. + */ + private final Set limitModules; + + /** + * Values parsed from the {@value #FILENAME} file for {@code --add-reads} option. + * Option values will be prefixed by {@link #moduleName}. + */ + private final Set addReads; + + /** + * Values parsed from the {@value #FILENAME} file for {@code --add-exports} option. + * Option values will be prefixed by {@link #moduleName}. + * Keys are package names. + */ + private final Map> addExports; + + /** + * Values parsed from the {@value #FILENAME} file for {@code --add-opens} option. + * Option values will be prefixed by {@link #moduleName}. + * Keys are package names. + */ + private final Map> addOpens; + + /** + * A clone of this {@code ModuleInfoPatch} but with runtime dependencies instead of compile-time dependencies. + * The information saved in this object are not used by the compiler plugin, because the runtime dependencies + * may differ from the runtime dependencies. But we need to save them for the needs of other plugins such as + * Surefire. If the compile and runtime dependencies are the same, then the value is {@code this}. + */ + private ModuleInfoPatch runtimeDependencies; + + /** + * Creates an initially empty module patch. + * + * @param defaultModule the name of the default module if there is no {@value #FILENAME} + * @param previous the previous instance (for sharing global options), or {@code null} if none. + */ + ModuleInfoPatch(String defaultModule, ModuleInfoPatch previous) { + if (defaultModule != null && !defaultModule.isBlank()) { + moduleName = defaultModule; + } + if (previous != null) { + addModules = previous.addModules; + limitModules = previous.limitModules; + } else { + addModules = new LinkedHashSet<>(); + limitModules = new LinkedHashSet<>(); + } + addReads = new LinkedHashSet<>(); + addExports = new LinkedHashMap<>(); + addOpens = new LinkedHashMap<>(); + runtimeDependencies = this; + } + + /** + * Creates a deep clone of the given module info patch. + * This is used for initializing the {@link #runtimeDependencies} field. + * + * @param parent the module info patch to clone + */ + private ModuleInfoPatch(ModuleInfoPatch parent) { + moduleName = parent.moduleName; + addModules = new LinkedHashSet<>(parent.addModules); + limitModules = new LinkedHashSet<>(parent.limitModules); + addReads = new LinkedHashSet<>(parent.addReads); + addExports = new LinkedHashMap<>(parent.addExports); + addOpens = new LinkedHashMap<>(parent.addOpens); + // Leave `runtimeDependencies` to null as it would be an error to use it a second time. + } + + /** + * Creates a module patch with the specified {@code --add-reads} options and everything else empty. + * + * @param addReads the {@code --add-reads} option + * @param moduleName the name of the module to patch + * + * @see #patchWithSameReads(String) + */ + private ModuleInfoPatch(Set addReads, String moduleName) { + this.moduleName = moduleName; + this.addReads = addReads; + /* + * Really need `Collections.emptyFoo()` here, not `Set.of()` or `Map.of()`. + * A difference is that the former silently accept calls to `clear()` as + * no-operation, while the latter throw `UnsupportedOperationException`. + */ + addModules = Collections.emptySet(); + limitModules = Collections.emptySet(); + addExports = Collections.emptyMap(); + addOpens = Collections.emptyMap(); + // `runtimeDependencies` to be initialized by the caller. + } + + /** + * Sets this instance to the default configuration to use when no {@value #FILENAME} is present. + */ + public void setToDefaults() { + addModules.add(TEST_MODULE_PATH); + addReads.add(TEST_MODULE_PATH); + } + + /** + * Loads the content of the given stream of characters. + * This method does not close the given reader. + * + * @param source stream of characters to read + * @throws IOException if an I/O error occurred while loading the file + */ + public void load(Reader source) throws IOException { + var reader = new StreamTokenizer(source); + reader.slashSlashComments(true); + reader.slashStarComments(true); + expectToken(reader, "patch-module"); + moduleName = nextName(reader, true); + expectToken(reader, '{'); + while (reader.nextToken() == StreamTokenizer.TT_WORD) { + switch (reader.sval) { + case "add-modules": + readModuleList(reader, addModules, ADD_MODULES_SPECIAL_CASES); + break; + case "limit-modules": + readModuleList(reader, limitModules, Set.of()); + break; + case "add-reads": + readModuleList(reader, addReads, Set.of(TEST_MODULE_PATH)); + break; + case "add-exports": + readQualified(reader, addExports, ADD_EXPORTS_SPECIAL_CASES); + break; + case "add-opens": + readQualified(reader, addOpens, Set.of()); + break; + default: + throw new ModuleInfoPatchException("Unknown keyword \"" + reader.sval + '"', reader); + } + } + if (reader.ttype != '}') { + throw new ModuleInfoPatchException("Not a token", reader); + } + if (reader.nextToken() != StreamTokenizer.TT_EOF) { + throw new ModuleInfoPatchException("Expected end of file but found \"" + reader.sval + '"', reader); + } + } + + /** + * Skips a token which is expected to be equal to the given value. + * + * @param reader the reader from which to skip a token + * @param expected the expected token value + * @throws IOException if an I/O error occurred while loading the file + * @throws ModuleInfoPatchException if the next token does not have the expected value + */ + private static void expectToken(StreamTokenizer reader, String expected) throws IOException { + if (reader.nextToken() != StreamTokenizer.TT_WORD || !expected.equals(reader.sval)) { + throw new ModuleInfoPatchException("Expected \"" + expected + '"', reader); + } + } + + /** + * Skips a token which is expected to be equal to the given value. + * The expected character must be flagged as an ordinary character in the reader. + * + * @param reader the reader from which to skip a token + * @param expected the expected character value + * @throws IOException if an I/O error occurred while loading the file + * @throws ModuleInfoPatchException if the next token does not have the expected value + */ + private static void expectToken(StreamTokenizer reader, char expected) throws IOException { + if (reader.nextToken() != expected) { + throw new ModuleInfoPatchException("Expected \"" + expected + '"', reader); + } + } + + /** + * Returns the next package or module name. + * This method verifies that the name is non-empty and a valid Java identifier. + * + * @param reader the reader from which to get the package or module name + * @param module {@code true} is expecting a module name, {@code false} if expecting a package name + * @return the package or module name + * @throws IOException if an I/O error occurred while loading the file + * @throws ModuleInfoPatchException if the next token is not a package or module name + */ + private static String nextName(StreamTokenizer reader, boolean module) throws IOException { + if (reader.nextToken() != StreamTokenizer.TT_WORD) { + throw new ModuleInfoPatchException("Expected a " + (module ? "module" : "package") + " name", reader); + } + return ensureValidName(reader, reader.sval.strip(), module); + } + + /** + * Verifies that the given name is a valid package or module identifier. + * + * @param reader the reader from which to get the line number if an exception needs to be thrown + * @param name the name to verify + * @param module {@code true} is expecting a module name, {@code false} if expecting a package name + * @throws ModuleInfoPatchException if the next token is not a package or module name + * @return the given name + */ + private static String ensureValidName(StreamTokenizer reader, String name, boolean module) { + int length = name.length(); + boolean expectFirstChar = true; + int c; + for (int i = 0; i < length; i += Character.charCount(c)) { + c = name.codePointAt(i); + if (expectFirstChar) { + if (Character.isJavaIdentifierStart(c)) { + expectFirstChar = false; + } else { + break; // Will throw exception because `expectFirstChar` is true. + } + } else if (!Character.isJavaIdentifierPart(c)) { + expectFirstChar = true; + if (c != '.') { + break; // Will throw exception because `expectFirstChar` is true. + } + } + } + if (expectFirstChar) { // Also true if the name is empty + throw new ModuleInfoPatchException( + "Invalid " + (module ? "module" : "package") + " name \"" + name + '"', reader); + } + return name; + } + + /** + * Reads a list of modules and stores the values in the given set. + * + * @param reader the reader from which to get the module names + * @param target where to store the module names + * @param specialCases special values to accept + * @return {@code target} or a new set if the target was initially null + * @throws IOException if an I/O error occurred while loading the file + * @throws ModuleInfoPatchException if the next token is not a module name + */ + private static void readModuleList(StreamTokenizer reader, Set target, Set specialCases) + throws IOException { + do { + while (reader.nextToken() == StreamTokenizer.TT_WORD) { + String module = reader.sval.strip(); + if (!specialCases.contains(module)) { + module = ensureValidName(reader, module, true); + } + target.add(module); + } + } while (reader.ttype == ','); + if (reader.ttype != ';') { + throw new ModuleInfoPatchException("Missing ';' character", reader); + } + } + + /** + * Reads a package name followed by a list of modules names. + * Used for qualified exports or qualified opens. + * + * @param reader the reader from which to get the module names + * @param target where to store the module names + * @param specialCases special values to accept + * @throws IOException if an I/O error occurred while loading the file + * @throws ModuleInfoPatchException if the next token is not a module name + */ + private static void readQualified(StreamTokenizer reader, Map> target, Set specialCases) + throws IOException { + String packageName = nextName(reader, false); + expectToken(reader, "to"); + readModuleList(reader, modulesForPackage(target, packageName), specialCases); + } + + /** + * {@return the set of modules associated to the given package name}. + * + * @param target the map where to store the set of modules + * @param packageName the package name for which to get a set of modules + */ + private static Set modulesForPackage(Map> target, String packageName) { + return target.computeIfAbsent(packageName, (key) -> new LinkedHashSet<>()); + } + + /** + * Bit mask for {@link #replaceTestModulePath(DependencyResolverResult)} internal usage. + */ + private static final int COMPILE = 1; + + /** + * Bit mask for {@link #replaceTestModulePath(DependencyResolverResult)} internal usage. + */ + private static final int RUNTIME = 2; + + /** + * Potentially adds the same value to compile and runtime sets. + * Whether to add a value is specified by the {@code scope} bitmask, + * which can contain a combination of {@link #COMPILE} and {@link #RUNTIME}. + * + * @param compile the collection where to add the value if the {@link #COMPILE} bit is set + * @param runtime the collection where to add the value if the {@link #RUNTIME} bit is set + * @param scope a combination of {@link #COMPILE} and {@link #RUNTIME} bits + * @param module the value to potentially add + * @return whether at least one collection has been modified + */ + private static boolean addModuleName(Set compile, Set runtime, int scope, String module) { + boolean modified = false; + if ((scope & COMPILE) != 0) { + modified = compile.add(module); + } + if ((scope & RUNTIME) != 0 && compile != runtime) { + modified |= runtime.add(module); + } + return modified; + } + + /** + * Potentially adds the same value to compile and runtime exports. + * Whether to add a value is specified by the {@code scope} bitmask, + * which can contain a combination of {@link #COMPILE} and {@link #RUNTIME}. + * + * @param packageName name of the package to export + * @param scope a combination of {@link #COMPILE} and {@link #RUNTIME} bits + * @param module the module for which to export a package + * @return whether at least one collection has been modified + */ + private boolean addExport(String packageName, int scope, String module) { + Set compile = modulesForPackage(addExports, packageName); + Set runtime = compile; + if (runtimeDependencies != this) { + runtime = modulesForPackage(runtimeDependencies.addExports, packageName); + } + return addModuleName(compile, runtime, scope, module); + } + + /** + * Replaces all occurrences of {@link #SUBPROJECT_MODULES} by the actual module names. + * + * @param sourceDirectories the test source directories for all modules in the project + */ + public void replaceProjectModules(final List sourceDirectories) { + for (Map.Entry> entry : addExports.entrySet()) { + if (entry.getValue().remove(SUBPROJECT_MODULES)) { + for (final SourceDirectory source : sourceDirectories) { + final String module = source.moduleName; + if (module != null && !module.equals(moduleName)) { + addExport(entry.getKey(), COMPILE | RUNTIME, module); + } + } + } + } + } + + /** + * Replaces all occurrences of {@link #TEST_MODULE_PATH} by the actual module names. + * These dependencies are automatically added to the {@code --add-modules} option once for all modules, + * then added to the {@code add-reads} option if the user specified the {@code TEST-MODULE-PATH} value. + * The latter is on a per-module basis. These options are also added implicitly if the user did not put + * a {@value #FILENAME} file in the test. + * + * @param dependencyResolution the result of resolving the dependencies, or {@code null} if none + * @throws IOException if an error occurred while reading information from a dependency + */ + public void replaceTestModulePath(final DependencyResolverResult dependencyResolution) throws IOException { + final var exportsToTestModulePath = new LinkedHashSet(); // Packages to export. + for (Map.Entry> entry : addExports.entrySet()) { + if (entry.getValue().remove(TEST_MODULE_PATH)) { + exportsToTestModulePath.add(entry.getKey()); + } + } + final boolean addAllTestModulePath = addModules.remove(TEST_MODULE_PATH); + final boolean readAllTestModulePath = addReads.remove(TEST_MODULE_PATH); + if (!addAllTestModulePath && !readAllTestModulePath && exportsToTestModulePath.isEmpty()) { + return; // Nothing to do. + } + if (dependencyResolution == null) { + // Note: we could log a warning, but we would need to ensure that it is logged only once. + return; + } + /* + * At this point, all `TEST-MODULE-PATCH` special values have been removed, but the actual module names + * have not yet been added. The module names may be added in two different instances. This instance is + * used for compile-time dependencies, while the `runtime` instance is used for runtime dependencies. + * The latter is created only if at least one dependency is different. + */ + final var done = new HashMap(); // Added modules and their dependencies. + for (Map.Entry entry : + dependencyResolution.getDependencies().entrySet()) { + + final int scope; // As a bitmask. + switch (entry.getKey().getScope()) { + case TEST: + scope = COMPILE | RUNTIME; + break; + case TEST_ONLY: + scope = COMPILE; + if (runtimeDependencies == this) { + runtimeDependencies = new ModuleInfoPatch(this); + } + break; + case TEST_RUNTIME: + scope = RUNTIME; + if (runtimeDependencies == this) { + runtimeDependencies = new ModuleInfoPatch(this); + } + break; + default: + continue; // Skip non-test dependencies because they should already be in the main module-info. + } + Path dependencyPath = entry.getValue(); + String module = dependencyResolution.getModuleName(dependencyPath).orElse(null); + if (module == null) { + if (readAllTestModulePath) { + addModuleName(addReads, runtimeDependencies.addReads, scope, "ALL-UNNAMED"); + } + } else if (mergeBit(done, module, scope)) { + boolean modified = false; + if (addAllTestModulePath) { + modified |= addModuleName(addModules, runtimeDependencies.addModules, scope, module); + } + if (readAllTestModulePath) { + modified |= addModuleName(addReads, runtimeDependencies.addReads, scope, module); + } + for (String packageName : exportsToTestModulePath) { + modified |= addExport(packageName, scope, module); + } + /* + * For making the options simpler, we do not add `--add-modules` or `--add-reads` + * options for modules that are required by a module that we already added. This + * simplification is not necessary, but makes the command-line easier to read. + */ + if (modified) { + dependencyResolution.getModuleDescriptor(dependencyPath).ifPresent((descriptor) -> { + for (ModuleDescriptor.Requires r : descriptor.requires()) { + done.merge(r.name(), scope, (o, n) -> o | n); + } + }); + } + } + } + } + + /** + * Sets the given bit in a map of bit masks. + * + * @param map the map where to set a bit + * @param key key of the entry for which to set a bit + * @param bit the bit to set + * @return whether the map changed as a result of this operation + */ + private static boolean mergeBit(final Map map, final String key, final int bit) { + Integer mask = map.putIfAbsent(key, bit); + if (mask != null) { + if ((mask & bit) != 0) { + return false; + } + map.put(key, mask | bit); + } + return true; + } + + /** + * Returns a patch for another module with the same {@code --add-reads} options. All other options are empty. + * This is used when a {@code ModuleInfoPatch} instance has been created for the implicit options and the + * caller wants to replicate these default values to other modules declared in the {@code }. + * + *

Constraint

+ * This method should be invoked after {@link #replaceTestModulePath(DependencyResolverResult)}, + * otherwise the runtime dependencies derived from {@code TEST-MODULE-PaTH} may not be correct. + * + * @param otherModule the other module to patch, or {@code null} or empty if none + * @return patch for the other module, or {@code null} if {@code otherModule} was null or empty + */ + public ModuleInfoPatch patchWithSameReads(String otherModule) { + if (otherModule == null || otherModule.isBlank()) { + return null; + } + var other = new ModuleInfoPatch(addReads, otherModule); + other.runtimeDependencies = + (runtimeDependencies == this) ? other : new ModuleInfoPatch(runtimeDependencies.addReads, otherModule); + return other; + } + + /** + * {@return the name of the module to patch, or null if unspecified and no default}. + */ + public String getModuleName() { + return moduleName; + } + + /** + * Writes the values of the given option if the values is is non-null. + * + * @param option the option for which to write the values + * @param prefix prefix to write, followed by {@code '='}, before the value, or empty if none + * @param compile the values to write for the compiler, or {@code null} if none + * @param runtime the values to write for the Java launcher + * @param configuration where to write the option values for the compiler + * @param out where to write the option values for the Java launcher + */ + private static void write( + String option, + String prefix, + Set compile, + Set runtime, + Options configuration, + BufferedWriter out) + throws IOException { + Set values = runtime; + do { + if (!values.isEmpty()) { + var buffer = new StringJoiner(",", (prefix != null) ? prefix + '=' : "", ""); + for (String value : values) { + buffer.add(value); + } + if (values == compile) { + configuration.addIfNonBlank("--" + option, buffer.toString()); + } + if (values == runtime) { + out.append("--").append(option).append(' ').append(buffer.toString()); + out.newLine(); + } + } + } while (values != compile && (values = compile) != null); + } + + /** + * Writes options that are qualified by module name and package name. + * + * @param option the option for which to write the values + * @param compile the values to write for the compiler, or {@code null} if none + * @param runtime the values to write for the Java launcher + * @param configuration where to write the option values for the compiler + * @param out where to write the option values for the Java launcher + */ + private void write( + String option, + Map> compile, + Map> runtime, + Options configuration, + BufferedWriter out) + throws IOException { + Map> values = runtime; + do { + for (Map.Entry> entry : values.entrySet()) { + String prefix = moduleName + '/' + entry.getKey(); + Set otherModules = entry.getValue(); + write( + option, + prefix, + (values == compile) ? otherModules : null, + (values == runtime) ? otherModules : Set.of(), + configuration, + out); + } + } while (values != compile && (values = compile) != null); + } + + /** + * Writes the options. + * + * @param compile where to write the compile-time options + * @param runtime where to write the runtime options + */ + public void writeTo(final Options compile, final BufferedWriter runtime) throws IOException { + write("add-modules", null, addModules, runtimeDependencies.addModules, compile, runtime); + write("limit-modules", null, limitModules, runtimeDependencies.limitModules, compile, runtime); + if (moduleName != null) { + write("add-reads", moduleName, addReads, runtimeDependencies.addReads, compile, runtime); + write("add-exports", addExports, runtimeDependencies.addExports, compile, runtime); + write("add-opens", null, runtimeDependencies.addOpens, compile, runtime); + } + addModules.clear(); // Add modules only once (this set is shared by other instances). + limitModules.clear(); + } +} diff --git a/src/main/java/org/apache/maven/plugin/compiler/ModuleInfoPatchException.java b/src/main/java/org/apache/maven/plugin/compiler/ModuleInfoPatchException.java new file mode 100644 index 000000000..a2fc33b83 --- /dev/null +++ b/src/main/java/org/apache/maven/plugin/compiler/ModuleInfoPatchException.java @@ -0,0 +1,50 @@ +/* + * 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.plugin.compiler; + +import java.io.StreamTokenizer; + +/** + * Thrown when a {@code module-info-patch.maven} file cannot be parsed. + * + * @author Martin Desruisseaux + */ +@SuppressWarnings("serial") +public class ModuleInfoPatchException extends CompilationFailureException { + /** + * Creates a new exception with the given message. + * + * @param message the short message + */ + public ModuleInfoPatchException(String message) { + super(message); + } + + /** + * Creates a new exception with the given message followed by "at line" and the line number. + * This is not in public API because the use of {@link StreamTokenizer} is an implementation + * details that may change in any future version. + * + * @param message the short message + * @param reader the reader used for parsing the file + */ + ModuleInfoPatchException(String message, StreamTokenizer reader) { + super(message + " at line " + reader.lineno()); + } +} diff --git a/src/main/java/org/apache/maven/plugin/compiler/TestCompilerMojo.java b/src/main/java/org/apache/maven/plugin/compiler/TestCompilerMojo.java index fa5ac6cf6..691b12faf 100644 --- a/src/main/java/org/apache/maven/plugin/compiler/TestCompilerMojo.java +++ b/src/main/java/org/apache/maven/plugin/compiler/TestCompilerMojo.java @@ -201,7 +201,10 @@ public class TestCompilerMojo extends AbstractCompilerMojo { *

This field exists in this class only for transferring this information * to {@link ToolExecutorForTest#hasTestModuleInfo}, which is the class that * needs this information.

+ * + * @deprecated Avoid {@code module-info.java} in tests. */ + @Deprecated(since = "4.0.0") transient boolean hasTestModuleInfo; /** @@ -398,10 +401,8 @@ final boolean hasModuleDeclaration(final List roots) throws IOE message.a("Overwriting the ") .warning(MODULE_INFO + JAVA_FILE_SUFFIX) .a(" file in the test directory is deprecated. Use ") - .info("--add-reads") - .a(", ") - .info("--add-modules") - .a(" and related options instead."); + .info(ModuleInfoPatch.FILENAME) + .a(" instead."); logger.warn(message.toString()); if (SUPPORT_LEGACY) { return useModulePath; diff --git a/src/main/java/org/apache/maven/plugin/compiler/ToolExecutor.java b/src/main/java/org/apache/maven/plugin/compiler/ToolExecutor.java index 4177459b4..e49236df7 100644 --- a/src/main/java/org/apache/maven/plugin/compiler/ToolExecutor.java +++ b/src/main/java/org/apache/maven/plugin/compiler/ToolExecutor.java @@ -409,7 +409,7 @@ private void setDependencyPaths(final StandardJavaFileManager fileManager) throw } } else if (key instanceof JavaPathType.Modular type) { /* - * Source code of test classes, handled as a "dependency". + * Main code to be tested by the test classes. This is handled as a "dependency". * Placed on: --patch-module-path. */ Optional location = type.rawType().location(); diff --git a/src/main/java/org/apache/maven/plugin/compiler/ToolExecutorForTest.java b/src/main/java/org/apache/maven/plugin/compiler/ToolExecutorForTest.java index 6be8b699e..ff48b85f8 100644 --- a/src/main/java/org/apache/maven/plugin/compiler/ToolExecutorForTest.java +++ b/src/main/java/org/apache/maven/plugin/compiler/ToolExecutorForTest.java @@ -22,21 +22,20 @@ import javax.tools.JavaCompiler; import javax.tools.JavaFileObject; +import java.io.BufferedReader; +import java.io.BufferedWriter; import java.io.IOException; import java.io.InputStream; import java.io.Writer; import java.lang.module.ModuleDescriptor; import java.nio.file.Files; import java.nio.file.Path; -import java.util.HashSet; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; -import java.util.StringJoiner; -import org.apache.maven.api.Dependency; import org.apache.maven.api.JavaPathType; import org.apache.maven.api.PathType; import org.apache.maven.api.ProjectScope; @@ -69,15 +68,21 @@ class ToolExecutorForTest extends ToolExecutor { * in which case the main classes are placed on the class path, but this is deprecated. * This flag may be removed in a future version if we remove support of this practice. * + * @deprecated Use {@code "claspath-jar"} dependency type instead, and avoid {@code module-info.java} in tests. + * * @see TestCompilerMojo#useModulePath */ + @Deprecated(since = "4.0.0") private final boolean useModulePath; /** * Whether a {@code module-info.java} file is defined in the test sources. * In such case, it has precedence over the {@code module-info.java} in main sources. * This is defined for compatibility with Maven 3, but not recommended. + * + * @deprecated Avoid {@code module-info.java} in tests. */ + @Deprecated(since = "4.0.0") private final boolean hasTestModuleInfo; /** @@ -232,12 +237,15 @@ final String inferModuleNameIfMissing(String moduleName) throws IOException { } /** - * Generates the {@code --add-modules} and {@code --add-reads} options for the dependencies that are not - * in the main compilation. This method is invoked only if {@code hasModuleDeclaration} is {@code true}. + * Completes the given configuration with module options the first time that this method is invoked. + * If at least one {@value ModuleInfoPatch#FILENAME} file is found in a root directory of test sources, + * then these files are parsed and the options that they declare are added to the given configuration. + * Otherwise, if {@link #hasModuleDeclaration} is {@code true}, then this method generates the + * {@code --add-modules} and {@code --add-reads} options for dependencies that are not in the main compilation. + * If this method is invoked more than once, all invocations after the first one have no effect. * - * @param dependencyResolution the project dependencies * @param configuration where to add the options - * @throws IOException if the module information of a dependency cannot be read + * @throws IOException if the module information of a dependency or the module-info patch cannot be read */ @SuppressWarnings({"checkstyle:MissingSwitchDefault", "fallthrough"}) private void addModuleOptions(final Options configuration) throws IOException { @@ -245,79 +253,79 @@ private void addModuleOptions(final Options configuration) throws IOException { return; } addedModuleOptions = true; - if (!hasModuleDeclaration || dependencyResolution == null) { - return; - } - if (SUPPORT_LEGACY && useModulePath && hasTestModuleInfo) { - /* - * Do not add any `--add-reads` parameters. The developers should put - * everything needed in the `module-info`, including test dependencies. - */ - return; - } - final var done = new HashSet(); // Added modules and their dependencies. - final var addModules = new StringJoiner(","); - StringJoiner addReads = null; - boolean hasUnnamed = false; - for (Map.Entry entry : - dependencyResolution.getDependencies().entrySet()) { - boolean compile = false; - switch (entry.getKey().getScope()) { - case TEST: - case TEST_ONLY: - compile = true; - // Fall through - case TEST_RUNTIME: - if (compile) { - // Needs to be initialized even if `name` is null. - if (addReads == null) { - addReads = new StringJoiner(","); - } - } - Path path = entry.getValue(); - String name = dependencyResolution.getModuleName(path).orElse(null); - if (name == null) { - hasUnnamed = true; - } else if (done.add(name)) { - addModules.add(name); - if (compile) { - addReads.add(name); - } - /* - * For making the options simpler, we do not add `--add-modules` or `--add-reads` - * options for modules that are required by a module that we already added. This - * simplification is not necessary, but makes the command-line easier to read. - */ - dependencyResolution.getModuleDescriptor(path).ifPresent((descriptor) -> { - for (ModuleDescriptor.Requires r : descriptor.requires()) { - done.add(r.name()); - } - }); + ModuleInfoPatch info = null; + ModuleInfoPatch defaultInfo = null; + final var patches = new LinkedHashMap(); + for (SourceDirectory source : sourceDirectories) { + Path file = source.root.resolve(ModuleInfoPatch.FILENAME); + String module; + if (Files.notExists(file)) { + if (SUPPORT_LEGACY && useModulePath && hasTestModuleInfo && hasModuleDeclaration) { + /* + * Do not add any `--add-reads` parameters. The developers should put + * everything needed in the `module-info`, including test dependencies. + */ + continue; + } + /* + * No `patch-module-info` file. Generate a default module patch instance for the + * `--add-modules TEST-MODULE-PATH` and `--add-reads TEST-MODULE-PATH` options. + * We generate that patch only for the first module. If there is more modules + * without `patch-module-info`, we will copy the `defaultInfo` instance. + */ + module = source.moduleName; + if (module == null) { + module = getMainModuleName(); + if (module.isEmpty()) { + continue; } - break; + } + if (defaultInfo != null) { + patches.putIfAbsent(module, null); // Remember that we will need to compute a value later. + continue; + } + defaultInfo = new ModuleInfoPatch(module, info); + defaultInfo.setToDefaults(); + info = defaultInfo; + } else { + info = new ModuleInfoPatch(getMainModuleName(), info); + try (BufferedReader reader = Files.newBufferedReader(file)) { + info.load(reader); + } + module = info.getModuleName(); } - } - if (!done.isEmpty()) { - configuration.addIfNonBlank("--add-modules", addModules.toString()); - } - if (addReads != null) { - if (hasUnnamed) { - addReads.add("ALL-UNNAMED"); + if (patches.put(module, info) != null) { + throw new ModuleInfoPatchException("\"module-info-patch " + module + "\" is defined more than once."); } - String reads = addReads.toString(); - addReads(configuration, getMainModuleName(), reads); - for (SourceDirectory root : sourceDirectories) { - addReads(configuration, root.moduleName, reads); + } + /* + * Replace all occurrences of `TEST-MODULE-PATH` by the actual dependency paths. + * Add `--add-modules` and `--add-reads` options with default values equivalent to + * `TEST-MODULE-PATH` for every module that do not have a `module-info-patch` file. + */ + for (Map.Entry entry : patches.entrySet()) { + info = entry.getValue(); + if (info != null) { + info.replaceProjectModules(sourceDirectories); + info.replaceTestModulePath(dependencyResolution); + } else { + // `defaultInfo` cannot be null if this `info` value is null. + entry.setValue(defaultInfo.patchWithSameReads(entry.getKey())); } } - } - - /** - * Adds an {@code --add-reads} compiler option if the given module name is non-null and non-blank. - */ - private static void addReads(Options configuration, String moduleName, String reads) { - if (moduleName != null && !moduleName.isBlank()) { - configuration.addIfNonBlank("--add-reads", moduleName + '=' + reads); + /* + * Write the runtime dependencies in the `META-INF/maven/module-info-patch.args` file. + * Note that we unconditionally write in the root output directory, not in the module directory, + * because a single option file applies to all modules. + */ + if (!patches.isEmpty()) { + Path directory = // TODO: replace by Path.resolve(String, String...) with JDK22. + Files.createDirectories(outputDirectory.resolve("META-INF").resolve("maven")); + try (BufferedWriter out = Files.newBufferedWriter(directory.resolve("module-info-patch.args"))) { + for (ModuleInfoPatch m : patches.values()) { + m.writeTo(configuration, out); + } + } } } diff --git a/src/test/java/org/apache/maven/plugin/compiler/ModuleInfoPatchTest.java b/src/test/java/org/apache/maven/plugin/compiler/ModuleInfoPatchTest.java new file mode 100644 index 000000000..3d17e3a8f --- /dev/null +++ b/src/test/java/org/apache/maven/plugin/compiler/ModuleInfoPatchTest.java @@ -0,0 +1,94 @@ +/* + * 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.plugin.compiler; + +import javax.tools.OptionChecker; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; +import java.io.StringWriter; + +import org.apache.maven.api.plugin.Log; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; + +/** + * Tests {@link ModuleInfoPatch}. + * + * @author Martin Desruisseaux + */ +public class ModuleInfoPatchTest implements OptionChecker { + /** + * Test reading a file. + * + * @throws IOException if an I/O error occurred while loading the file + */ + @Test + public void testRead() throws IOException { + var info = new ModuleInfoPatch(null, null); + try (Reader r = + new InputStreamReader(ModuleInfoPatchTest.class.getResourceAsStream("module-info-patch.maven"))) { + info.load(r); + } + var config = new Options(this, Mockito.mock(Log.class)); + var out = new StringWriter(); + try (var buffered = new BufferedWriter(out)) { + info.writeTo(config, buffered); + } + assertArrayEquals( + new String[] { + "--add-modules", + "ALL-MODULE-PATH", + "--limit-modules", + "org.junit.jupiter.api", + "--add-reads", + "org.mymodule=org.junit.jupiter.api", + "--add-exports", + "org.mymodule/org.mypackage=org.someone,org.another", + "--add-exports", + "org.mymodule/org.foo=TEST-MODULE-PATH" + }, + config.options.toArray()); + + assertArrayEquals( + new String[] { + "--add-modules ALL-MODULE-PATH", + "--limit-modules org.junit.jupiter.api", + "--add-reads org.mymodule=org.junit.jupiter.api", + "--add-exports org.mymodule/org.mypackage=org.someone,org.another", + "--add-exports org.mymodule/org.foo=TEST-MODULE-PATH", + "--add-opens org.mymodule/org.foo=org.junit.jupiter.api" + }, + out.toString().split(System.lineSeparator())); + } + + /** + * {@return the number of arguments the given option takes}. + * + * @param option an option + */ + @Override + public int isSupportedOption(String option) { + return 1; + } +} diff --git a/src/test/resources/org/apache/maven/plugin/compiler/module-info-patch.maven b/src/test/resources/org/apache/maven/plugin/compiler/module-info-patch.maven new file mode 100644 index 000000000..df2ef1aa0 --- /dev/null +++ b/src/test/resources/org/apache/maven/plugin/compiler/module-info-patch.maven @@ -0,0 +1,53 @@ +/* + * 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. + */ + +/* + * Syntax: all keywords inside `patch-module` are Java compiler or Java launcher options without + * the leading `--` characters. Each option value ends at the `;` character, which is mandatory. + * + * Some options expect a value of the form `module/package=other-module(,other-module)*`. + * For these options, the `module` part will be the name immediately after `patch-module` + * and shall not be repeated inside the block. The `=` sign is replaced by the `to` keyword, + * as in `module-info.java` files. + * + * This block accepts only options that do not require a path to source or binary files. + * Options with path values should be handled as Maven dependencies or sources instead. + */ +patch-module org.mymodule { + + add-modules ALL-MODULE-PATH; // For testing purpose, but a valid value would rather be TEST-MODULE-PATH. + + limit-modules org.junit.jupiter.api; + + // Similar to `requires` in module-info. + // Accept also TEST-MODULE-PATH (Maven-specific). + add-reads org.junit.jupiter.api; + + // Similar to `exports` in module-info. + add-exports org.mypackage + to org.someone, + org.another; + + add-exports org.foo + to TEST-MODULE-PATH; // Maven specific. Note: a standard alternative is ALL-UNNAMED. + + // Not used by the compiler, but useful for test executions. + add-opens org.foo + to org.junit.jupiter.api; +}