blob: 66567001c15c3f585dd57e485d0787f0496b7b0e [file]
/*
* 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.
*
* <h2>Global options</h2>
* 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<String> 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<String> 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<String> 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<String> limitModules;
/**
* Values parsed from the {@value #FILENAME} file for {@code --add-reads} option.
* Option values will be prefixed by {@link #moduleName}.
*/
private final Set<String> 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<String, Set<String>> 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<String, Set<String>> 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<String> 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<String> target, Set<String> 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<String, Set<String>> target, Set<String> 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<String> modulesForPackage(Map<String, Set<String>> 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<String> compile, Set<String> 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<String> compile = modulesForPackage(addExports, packageName);
Set<String> 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<SourceDirectory> sourceDirectories) {
for (Map.Entry<String, Set<String>> 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<String>(); // Packages to export.
for (Map.Entry<String, Set<String>> 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<String, Integer>(); // Added modules and their dependencies.
for (Map.Entry<Dependency, Path> 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<String, Integer> 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 <sources>}.
*
* <h4>Constraint</h4>
* This method should be invoked <em>after</em> {@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<String> compile,
Set<String> runtime,
Options configuration,
BufferedWriter out)
throws IOException {
Set<String> 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<String, Set<String>> compile,
Map<String, Set<String>> runtime,
Options configuration,
BufferedWriter out)
throws IOException {
Map<String, Set<String>> values = runtime;
do {
for (Map.Entry<String, Set<String>> entry : values.entrySet()) {
String prefix = moduleName + '/' + entry.getKey();
Set<String> 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();
}
}