Skip to content

Commit 5b62bd0

Browse files
committed
Add a parser for module-info-patch.maven files where --add-exports and similar options can be specified more easily.
1 parent be18137 commit 5b62bd0

File tree

7 files changed

+961
-80
lines changed

7 files changed

+961
-80
lines changed

src/main/java/org/apache/maven/plugin/compiler/ModuleInfoPatch.java

Lines changed: 675 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.maven.plugin.compiler;
20+
21+
import java.io.StreamTokenizer;
22+
23+
/**
24+
* Thrown when a {@code module-info-patch.maven} file cannot be parsed.
25+
*
26+
* @author Martin Desruisseaux
27+
*/
28+
@SuppressWarnings("serial")
29+
public class ModuleInfoPatchException extends CompilationFailureException {
30+
/**
31+
* Creates a new exception with the given message.
32+
*
33+
* @param message the short message
34+
*/
35+
public ModuleInfoPatchException(String message) {
36+
super(message);
37+
}
38+
39+
/**
40+
* Creates a new exception with the given message followed by "at line" and the line number.
41+
* This is not in public API because the use of {@link StreamTokenizer} is an implementation
42+
* details that may change in any future version.
43+
*
44+
* @param message the short message
45+
* @param reader the reader used for parsing the file
46+
*/
47+
ModuleInfoPatchException(String message, StreamTokenizer reader) {
48+
super(message + " at line " + reader.lineno());
49+
}
50+
}

src/main/java/org/apache/maven/plugin/compiler/TestCompilerMojo.java

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,10 @@ public class TestCompilerMojo extends AbstractCompilerMojo {
201201
* <p>This field exists in this class only for transferring this information
202202
* to {@link ToolExecutorForTest#hasTestModuleInfo}, which is the class that
203203
* needs this information.</p>
204+
*
205+
* @deprecated Avoid {@code module-info.java} in tests.
204206
*/
207+
@Deprecated(since = "4.0.0")
205208
transient boolean hasTestModuleInfo;
206209

207210
/**
@@ -398,10 +401,8 @@ final boolean hasModuleDeclaration(final List<SourceDirectory> roots) throws IOE
398401
message.a("Overwriting the ")
399402
.warning(MODULE_INFO + JAVA_FILE_SUFFIX)
400403
.a(" file in the test directory is deprecated. Use ")
401-
.info("--add-reads")
402-
.a(", ")
403-
.info("--add-modules")
404-
.a(" and related options instead.");
404+
.info(ModuleInfoPatch.FILENAME)
405+
.a(" instead.");
405406
logger.warn(message.toString());
406407
if (SUPPORT_LEGACY) {
407408
return useModulePath;

src/main/java/org/apache/maven/plugin/compiler/ToolExecutor.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,7 @@ private void setDependencyPaths(final StandardJavaFileManager fileManager) throw
409409
}
410410
} else if (key instanceof JavaPathType.Modular type) {
411411
/*
412-
* Source code of test classes, handled as a "dependency".
412+
* Main code to be tested by the test classes. This is handled as a "dependency".
413413
* Placed on: --patch-module-path.
414414
*/
415415
Optional<JavaFileManager.Location> location = type.rawType().location();

src/main/java/org/apache/maven/plugin/compiler/ToolExecutorForTest.java

Lines changed: 83 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -22,21 +22,20 @@
2222
import javax.tools.JavaCompiler;
2323
import javax.tools.JavaFileObject;
2424

25+
import java.io.BufferedReader;
26+
import java.io.BufferedWriter;
2527
import java.io.IOException;
2628
import java.io.InputStream;
2729
import java.io.Writer;
2830
import java.lang.module.ModuleDescriptor;
2931
import java.nio.file.Files;
3032
import java.nio.file.Path;
31-
import java.util.HashSet;
3233
import java.util.LinkedHashMap;
3334
import java.util.LinkedHashSet;
3435
import java.util.List;
3536
import java.util.Map;
3637
import java.util.Set;
37-
import java.util.StringJoiner;
3838

39-
import org.apache.maven.api.Dependency;
4039
import org.apache.maven.api.JavaPathType;
4140
import org.apache.maven.api.PathType;
4241
import org.apache.maven.api.ProjectScope;
@@ -69,15 +68,21 @@ class ToolExecutorForTest extends ToolExecutor {
6968
* in which case the main classes are placed on the class path, but this is deprecated.
7069
* This flag may be removed in a future version if we remove support of this practice.
7170
*
71+
* @deprecated Use {@code "claspath-jar"} dependency type instead, and avoid {@code module-info.java} in tests.
72+
*
7273
* @see TestCompilerMojo#useModulePath
7374
*/
75+
@Deprecated(since = "4.0.0")
7476
private final boolean useModulePath;
7577

7678
/**
7779
* Whether a {@code module-info.java} file is defined in the test sources.
7880
* In such case, it has precedence over the {@code module-info.java} in main sources.
7981
* This is defined for compatibility with Maven 3, but not recommended.
82+
*
83+
* @deprecated Avoid {@code module-info.java} in tests.
8084
*/
85+
@Deprecated(since = "4.0.0")
8186
private final boolean hasTestModuleInfo;
8287

8388
/**
@@ -232,92 +237,95 @@ final String inferModuleNameIfMissing(String moduleName) throws IOException {
232237
}
233238

234239
/**
235-
* Generates the {@code --add-modules} and {@code --add-reads} options for the dependencies that are not
236-
* in the main compilation. This method is invoked only if {@code hasModuleDeclaration} is {@code true}.
240+
* Completes the given configuration with module options the first time that this method is invoked.
241+
* If at least one {@value ModuleInfoPatch#FILENAME} file is found in a root directory of test sources,
242+
* then these files are parsed and the options that they declare are added to the given configuration.
243+
* Otherwise, if {@link #hasModuleDeclaration} is {@code true}, then this method generates the
244+
* {@code --add-modules} and {@code --add-reads} options for dependencies that are not in the main compilation.
245+
* If this method is invoked more than once, all invocations after the first one have no effect.
237246
*
238-
* @param dependencyResolution the project dependencies
239247
* @param configuration where to add the options
240-
* @throws IOException if the module information of a dependency cannot be read
248+
* @throws IOException if the module information of a dependency or the module-info patch cannot be read
241249
*/
242250
@SuppressWarnings({"checkstyle:MissingSwitchDefault", "fallthrough"})
243251
private void addModuleOptions(final Options configuration) throws IOException {
244252
if (addedModuleOptions) {
245253
return;
246254
}
247255
addedModuleOptions = true;
248-
if (!hasModuleDeclaration || dependencyResolution == null) {
249-
return;
250-
}
251-
if (SUPPORT_LEGACY && useModulePath && hasTestModuleInfo) {
252-
/*
253-
* Do not add any `--add-reads` parameters. The developers should put
254-
* everything needed in the `module-info`, including test dependencies.
255-
*/
256-
return;
257-
}
258-
final var done = new HashSet<String>(); // Added modules and their dependencies.
259-
final var addModules = new StringJoiner(",");
260-
StringJoiner addReads = null;
261-
boolean hasUnnamed = false;
262-
for (Map.Entry<Dependency, Path> entry :
263-
dependencyResolution.getDependencies().entrySet()) {
264-
boolean compile = false;
265-
switch (entry.getKey().getScope()) {
266-
case TEST:
267-
case TEST_ONLY:
268-
compile = true;
269-
// Fall through
270-
case TEST_RUNTIME:
271-
if (compile) {
272-
// Needs to be initialized even if `name` is null.
273-
if (addReads == null) {
274-
addReads = new StringJoiner(",");
275-
}
276-
}
277-
Path path = entry.getValue();
278-
String name = dependencyResolution.getModuleName(path).orElse(null);
279-
if (name == null) {
280-
hasUnnamed = true;
281-
} else if (done.add(name)) {
282-
addModules.add(name);
283-
if (compile) {
284-
addReads.add(name);
285-
}
286-
/*
287-
* For making the options simpler, we do not add `--add-modules` or `--add-reads`
288-
* options for modules that are required by a module that we already added. This
289-
* simplification is not necessary, but makes the command-line easier to read.
290-
*/
291-
dependencyResolution.getModuleDescriptor(path).ifPresent((descriptor) -> {
292-
for (ModuleDescriptor.Requires r : descriptor.requires()) {
293-
done.add(r.name());
294-
}
295-
});
256+
ModuleInfoPatch info = null;
257+
ModuleInfoPatch defaultInfo = null;
258+
final var patches = new LinkedHashMap<String, ModuleInfoPatch>();
259+
for (SourceDirectory source : sourceDirectories) {
260+
Path file = source.root.resolve(ModuleInfoPatch.FILENAME);
261+
String module;
262+
if (Files.notExists(file)) {
263+
if (SUPPORT_LEGACY && useModulePath && hasTestModuleInfo && hasModuleDeclaration) {
264+
/*
265+
* Do not add any `--add-reads` parameters. The developers should put
266+
* everything needed in the `module-info`, including test dependencies.
267+
*/
268+
continue;
269+
}
270+
/*
271+
* No `patch-module-info` file. Generate a default module patch instance for the
272+
* `--add-modules TEST-MODULE-PATH` and `--add-reads TEST-MODULE-PATH` options.
273+
* We generate that patch only for the first module. If there is more modules
274+
* without `patch-module-info`, we will copy the `defaultInfo` instance.
275+
*/
276+
module = source.moduleName;
277+
if (module == null) {
278+
module = getMainModuleName();
279+
if (module.isEmpty()) {
280+
continue;
296281
}
297-
break;
282+
}
283+
if (defaultInfo != null) {
284+
patches.putIfAbsent(module, null); // Remember that we will need to compute a value later.
285+
continue;
286+
}
287+
defaultInfo = new ModuleInfoPatch(module, info);
288+
defaultInfo.setToDefaults();
289+
info = defaultInfo;
290+
} else {
291+
info = new ModuleInfoPatch(getMainModuleName(), info);
292+
try (BufferedReader reader = Files.newBufferedReader(file)) {
293+
info.load(reader);
294+
}
295+
module = info.getModuleName();
298296
}
299-
}
300-
if (!done.isEmpty()) {
301-
configuration.addIfNonBlank("--add-modules", addModules.toString());
302-
}
303-
if (addReads != null) {
304-
if (hasUnnamed) {
305-
addReads.add("ALL-UNNAMED");
297+
if (patches.put(module, info) != null) {
298+
throw new ModuleInfoPatchException("\"module-info-patch " + module + "\" is defined more than once.");
306299
}
307-
String reads = addReads.toString();
308-
addReads(configuration, getMainModuleName(), reads);
309-
for (SourceDirectory root : sourceDirectories) {
310-
addReads(configuration, root.moduleName, reads);
300+
}
301+
/*
302+
* Replace all occurrences of `TEST-MODULE-PATH` by the actual dependency paths.
303+
* Add `--add-modules` and `--add-reads` options with default values equivalent to
304+
* `TEST-MODULE-PATH` for every module that do not have a `module-info-patch` file.
305+
*/
306+
for (Map.Entry<String, ModuleInfoPatch> entry : patches.entrySet()) {
307+
info = entry.getValue();
308+
if (info != null) {
309+
info.replaceProjectModules(sourceDirectories);
310+
info.replaceTestModulePath(dependencyResolution);
311+
} else {
312+
// `defaultInfo` cannot be null if this `info` value is null.
313+
entry.setValue(defaultInfo.patchWithSameReads(entry.getKey()));
311314
}
312315
}
313-
}
314-
315-
/**
316-
* Adds an {@code --add-reads} compiler option if the given module name is non-null and non-blank.
317-
*/
318-
private static void addReads(Options configuration, String moduleName, String reads) {
319-
if (moduleName != null && !moduleName.isBlank()) {
320-
configuration.addIfNonBlank("--add-reads", moduleName + '=' + reads);
316+
/*
317+
* Write the runtime dependencies in the `META-INF/maven/module-info-patch.args` file.
318+
* Note that we unconditionally write in the root output directory, not in the module directory,
319+
* because a single option file applies to all modules.
320+
*/
321+
if (!patches.isEmpty()) {
322+
Path directory = // TODO: replace by Path.resolve(String, String...) with JDK22.
323+
Files.createDirectories(outputDirectory.resolve("META-INF").resolve("maven"));
324+
try (BufferedWriter out = Files.newBufferedWriter(directory.resolve("module-info-patch.args"))) {
325+
for (ModuleInfoPatch m : patches.values()) {
326+
m.writeTo(configuration, out);
327+
}
328+
}
321329
}
322330
}
323331

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.maven.plugin.compiler;
20+
21+
import javax.tools.OptionChecker;
22+
23+
import java.io.BufferedWriter;
24+
import java.io.IOException;
25+
import java.io.InputStreamReader;
26+
import java.io.Reader;
27+
import java.io.StringWriter;
28+
29+
import org.apache.maven.api.plugin.Log;
30+
import org.junit.jupiter.api.Test;
31+
import org.mockito.Mockito;
32+
33+
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
34+
35+
/**
36+
* Tests {@link ModuleInfoPatch}.
37+
*
38+
* @author Martin Desruisseaux
39+
*/
40+
public class ModuleInfoPatchTest implements OptionChecker {
41+
/**
42+
* Test reading a file.
43+
*
44+
* @throws IOException if an I/O error occurred while loading the file
45+
*/
46+
@Test
47+
public void testRead() throws IOException {
48+
var info = new ModuleInfoPatch(null, null);
49+
try (Reader r =
50+
new InputStreamReader(ModuleInfoPatchTest.class.getResourceAsStream("module-info-patch.maven"))) {
51+
info.load(r);
52+
}
53+
var config = new Options(this, Mockito.mock(Log.class));
54+
var out = new StringWriter();
55+
try (var buffered = new BufferedWriter(out)) {
56+
info.writeTo(config, buffered);
57+
}
58+
assertArrayEquals(
59+
new String[] {
60+
"--add-modules",
61+
"ALL-MODULE-PATH",
62+
"--limit-modules",
63+
"org.junit.jupiter.api",
64+
"--add-reads",
65+
"org.mymodule=org.junit.jupiter.api",
66+
"--add-exports",
67+
"org.mymodule/org.mypackage=org.someone,org.another",
68+
"--add-exports",
69+
"org.mymodule/org.foo=TEST-MODULE-PATH"
70+
},
71+
config.options.toArray());
72+
73+
assertArrayEquals(
74+
new String[] {
75+
"--add-modules ALL-MODULE-PATH",
76+
"--limit-modules org.junit.jupiter.api",
77+
"--add-reads org.mymodule=org.junit.jupiter.api",
78+
"--add-exports org.mymodule/org.mypackage=org.someone,org.another",
79+
"--add-exports org.mymodule/org.foo=TEST-MODULE-PATH",
80+
"--add-opens org.mymodule/org.foo=org.junit.jupiter.api"
81+
},
82+
out.toString().split(System.lineSeparator()));
83+
}
84+
85+
/**
86+
* {@return the number of arguments the given option takes}.
87+
*
88+
* @param option an option
89+
*/
90+
@Override
91+
public int isSupportedOption(String option) {
92+
return 1;
93+
}
94+
}

0 commit comments

Comments
 (0)