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.lang.model.SourceVersion;
22 import javax.tools.DiagnosticListener;
23 import javax.tools.JavaCompiler;
24 import javax.tools.JavaFileObject;
25
26 import java.io.BufferedReader;
27 import java.io.BufferedWriter;
28 import java.io.IOException;
29 import java.io.InputStream;
30 import java.io.UncheckedIOException;
31 import java.io.Writer;
32 import java.lang.module.ModuleDescriptor;
33 import java.nio.file.Files;
34 import java.nio.file.Path;
35 import java.util.HashMap;
36 import java.util.LinkedHashMap;
37 import java.util.List;
38 import java.util.Map;
39 import java.util.NavigableMap;
40 import java.util.TreeMap;
41 import java.util.stream.Stream;
42
43 import static org.apache.maven.plugin.compiler.AbstractCompilerMojo.SUPPORT_LEGACY;
44 import static org.apache.maven.plugin.compiler.DirectoryHierarchy.META_INF;
45 import static org.apache.maven.plugin.compiler.SourceDirectory.CLASS_FILE_SUFFIX;
46 import static org.apache.maven.plugin.compiler.SourceDirectory.MODULE_INFO;
47
48 /**
49 * A task which configures and executes the Java compiler for the test classes.
50 * This executor contains additional configurations compared to the base class.
51 *
52 * @author Martin Desruisseaux
53 */
54 class ToolExecutorForTest extends ToolExecutor {
55 /**
56 * The output directory of the main classes.
57 * This directory will be added to the class-path or module-path.
58 *
59 * @see TestCompilerMojo#mainOutputDirectory
60 */
61 private final Path mainOutputDirectory;
62
63 /**
64 * The main output directory of each module. This is usually {@code mainOutputDirectory/<module>},
65 * except if some modules are defined only for some Java versions higher than the base version.
66 */
67 private final Map<String, Path> mainOutputDirectoryForModules;
68
69 /**
70 * Whether to place the main classes on the module path when {@code module-info} is present.
71 * The default and recommended value is {@code true}. The user may force to {@code false},
72 * in which case the main classes are placed on the class path, but this is deprecated.
73 * This flag may be removed in a future version if we remove support of this practice.
74 *
75 * @deprecated Use {@code "claspath-jar"} dependency type instead, and avoid {@code module-info.java} in tests.
76 *
77 * @see TestCompilerMojo#useModulePath
78 */
79 @Deprecated(since = "4.0.0")
80 private final boolean useModulePath;
81
82 /**
83 * Whether a {@code module-info.java} file is defined in the test sources.
84 * In such case, it has precedence over the {@code module-info.java} in main sources.
85 * This is defined for compatibility with Maven 3, but not recommended.
86 *
87 * @deprecated Avoid {@code module-info.java} in tests.
88 */
89 @Deprecated(since = "4.0.0")
90 private final boolean hasTestModuleInfo;
91
92 /**
93 * Name of the module when using package hierarchy, or {@code null} if not applicable.
94 * This is used for setting {@code --patch-module} option during compilation of tests.
95 * This field is null in a class-path project or in a multi-module project.
96 *
97 * <p>This field exists mostly for compatibility with the Maven 3 way to build a modular project.
98 * It is recommended to use the {@code <sources>} element instead. We may remove this field in a
99 * future version if we abandon compatibility with the Maven 3 way to build modular projects.</p>
100 *
101 * @deprecated Declare modules in {@code <source>} elements instead.
102 */
103 @Deprecated(since = "4.0.0")
104 private String moduleNameFromPackageHierarchy;
105
106 /**
107 * Whether {@link #addModuleOptions(Options)} has already been invoked.
108 * The options shall be completed only once, otherwise conflicts may occur.
109 */
110 private boolean addedModuleOptions;
111
112 /**
113 * If non-null, the {@code module} part to remove in {@code target/test-classes/module/package}.
114 * This {@code module} directory is generated by {@code javac} for some compiler options.
115 * We keep that directory when the project is configured with the new {@code <source>} element,
116 * but have to remove it for compatibility reason if the project is compiled in the old way.
117 *
118 * @deprecated Exists only for compatibility with the Maven 3 way to do a modular project.
119 * Is likely to cause confusion, for example with incremental builds.
120 * New projects should use the {@code <source>} elements instead.
121 */
122 @Deprecated(since = "4.0.0")
123 private String directoryLevelToRemove;
124
125 /**
126 * Creates a new task by taking a snapshot of the current configuration of the given <abbr>MOJO</abbr>.
127 * This constructor creates the {@linkplain #outputDirectory output directory} if it does not already exist.
128 *
129 * @param mojo the <abbr>MOJO</abbr> from which to take a snapshot
130 * @param listener where to send compilation warnings, or {@code null} for the Maven logger
131 * @param mainModulePath path to the {@code module-info.class} file of the main code, or {@code null} if none
132 * @throws MojoException if this constructor identifies an invalid parameter in the <abbr>MOJO</abbr>
133 * @throws IOException if an error occurred while creating the output directory or scanning the source directories
134 */
135 @SuppressWarnings("deprecation")
136 ToolExecutorForTest(
137 final TestCompilerMojo mojo,
138 final DiagnosticListener<? super JavaFileObject> listener,
139 final Path mainModulePath)
140 throws IOException {
141 super(mojo, listener);
142 /*
143 * Notable work done by the parent constructor (examples with default paths):
144 *
145 * - Set `outputDirectory` to a single "target/test-classes".
146 * - Set `sourceDirectories` to many "src/<module>/test/java".
147 * - Set `sourceFiles` to the content of `sourceDirectories`.
148 * - Set `dependencies` with class-path and module-path, but not including main output directory.
149 *
150 * We will need to add the main output directory to the class-path or module-path, but not here.
151 * It will be done by `ToolExecutor.compile(…)` if `getOutputDirectoryOfPreviousPhase()` returns
152 * a non-null value.
153 */
154 useModulePath = mojo.useModulePath;
155 hasTestModuleInfo = mojo.hasTestModuleInfo;
156 mainOutputDirectory = mojo.mainOutputDirectory;
157 mainOutputDirectoryForModules = new HashMap<>();
158 if (Files.notExists(mainOutputDirectory)) {
159 return;
160 }
161 if (mainModulePath != null) {
162 try (InputStream in = Files.newInputStream(mainModulePath)) {
163 moduleNameFromPackageHierarchy = ModuleDescriptor.read(in).name();
164 }
165 }
166 // Following is non-null only for modular project using package hierarchy.
167 final String testModuleName = mojo.moduleNameFromPackageHierarchy(sourceDirectories);
168 if (testModuleName != null) {
169 moduleNameFromPackageHierarchy = testModuleName;
170 }
171 /*
172 * If compiling the test classes of a modular project, we will need `--patch-modules` options.
173 * In this case, the option values are directories of main class files of the patched module.
174 * This block only prepares an empty map for each module. Maps are filled in the next block.
175 */
176 final var patchedModules = new LinkedHashMap<String, NavigableMap<SourceVersion, Path>>();
177 for (SourceDirectory dir : sourceDirectories) {
178 String moduleToPatch = dir.moduleName;
179 if (moduleToPatch == null) {
180 moduleToPatch = moduleNameFromPackageHierarchy;
181 if (moduleToPatch == null) {
182 continue; // No module-info found.
183 }
184 /*
185 * Modular project using package hierarchy (Maven 3 way).
186 * We will need to move directories after compilation for reproducing the Maven 3 output.
187 */
188 directoryLevelToRemove = moduleToPatch;
189 }
190 if (testModuleName != null && !moduleToPatch.equals(testModuleName)) {
191 // Mix of package hierarchy and module source hierarchy.
192 throw new CompilationFailureException(
193 "The \"" + testModuleName + "\" module must be declared in a <module> element of <sources>.");
194 }
195 patchedModules.put(moduleToPatch, new TreeMap<>()); // Signal that this module exists in the test.
196 }
197 // Shortcut for class-path projects.
198 if (patchedModules.isEmpty()) {
199 return;
200 }
201 /*
202 * The values of `patchedModules` are empty maps. Now, add the real paths to the
203 * main classes for each module that exists in both the main code and the tests.
204 * Note that a module may exist only in the `META-INF/versions-modular/` directory.
205 */
206 addDirectoryIfModule(
207 mainOutputDirectory, moduleNameFromPackageHierarchy, SourceVersion.RELEASE_0, patchedModules);
208 addModuleDirectories(mainOutputDirectory, SourceVersion.RELEASE_0, patchedModules);
209 Path versionsDirectory = DirectoryHierarchy.MODULE_SOURCE.outputDirectoryForReleases(mainOutputDirectory);
210 if (Files.exists(versionsDirectory)) {
211 List<Path> asList;
212 try (Stream<Path> paths = Files.list(versionsDirectory)) {
213 asList = paths.toList();
214 }
215 for (Path path : asList) {
216 SourceVersion version;
217 try {
218 version = SourceDirectory.parse(path.getFileName().toString());
219 } catch (UnsupportedVersionException e) {
220 logger.debug(e);
221 continue;
222 }
223 addModuleDirectories(path, version, patchedModules);
224 }
225 }
226 /*
227 * At this point, we finished to scan the main output directory for modules.
228 * Remembers the directories of each module. They are usually sub-directories
229 * of the main directory, but could also be in `META-INF/versions-modular/`.
230 */
231 patchedModules.forEach((moduleToPatch, directories) -> {
232 Map.Entry<SourceVersion, Path> base = directories.firstEntry();
233 if (base != null) {
234 mainOutputDirectoryForModules.putIfAbsent(moduleToPatch, base.getValue());
235 }
236 });
237 }
238
239 /**
240 * Performs a shallow scan of the given directory for modules.
241 * This method searches for {@code module-info.class} files.
242 *
243 * <p>The keys of the {@code addTo} map are module names. Values are paths for all versions where
244 * {@code module-info.class} has been found. Note that this is not an exhaustive list of paths for
245 * all versions, because most {@code versions} directories do not have a {@code module-info.class} file.
246 * Therefore, the {@code SortedMap} will usually contain only the base directory. But we check versions
247 * anyway because sometime, a module does not exist in the base directory and is first defined only for
248 * a higher version.</p>
249 *
250 * <p>This method adds paths to existing entries only, and ignores modules that are not already in the map.
251 * This is done that way for collecting modules that are both in the main code and in the tests.</p>
252 *
253 * @param directory the directory to scan
254 * @param version target Java version of the directory to add
255 * @param addTo where to add the module paths
256 * @throws IOException if an error occurred while scanning the directories
257 */
258 private void addModuleDirectories(
259 Path directory, SourceVersion version, Map<String, NavigableMap<SourceVersion, Path>> addTo)
260 throws IOException {
261
262 try (Stream<Path> paths = Files.list(directory)) {
263 paths.forEach(
264 (path) -> addDirectoryIfModule(path, path.getFileName().toString(), version, addTo));
265 } catch (UncheckedIOException e) {
266 throw e.getCause();
267 }
268 }
269
270 /**
271 * Adds the given directory in {@code addTo} if the directory contains a {@code module-info.class} file.
272 *
273 * @param directory the directory to scan
274 * @param moduleName name of the module to add
275 * @param version target Java version of the directory to add
276 * @param addTo where to add the module paths
277 */
278 private static void addDirectoryIfModule(
279 Path directory,
280 String moduleName,
281 SourceVersion version,
282 Map<String, NavigableMap<SourceVersion, Path>> addTo) {
283
284 NavigableMap<SourceVersion, Path> versions = addTo.get(moduleName);
285 if (versions != null && Files.isRegularFile(directory.resolve(MODULE_INFO + CLASS_FILE_SUFFIX))) {
286 versions.putIfAbsent(version, directory);
287 }
288 }
289
290 /**
291 * Completes the given configuration with module options the first time that this method is invoked.
292 * If at least one {@value ModuleInfoPatch#FILENAME} file is found in a root directory of test sources,
293 * then these files are parsed and the options that they declare are added to the given configuration.
294 * Otherwise, if {@link #hasModuleDeclaration} is {@code true}, then this method generates the
295 * {@code --add-modules} and {@code --add-reads} options for dependencies that are not in the main compilation.
296 * If this method is invoked more than once, all invocations after the first one have no effect.
297 *
298 * @param configuration where to add the options
299 * @throws IOException if the module information of a dependency or the module-info patch cannot be read
300 */
301 @SuppressWarnings({"checkstyle:MissingSwitchDefault", "fallthrough"})
302 private void addModuleOptions(final Options configuration) throws IOException {
303 if (addedModuleOptions) {
304 return;
305 }
306 addedModuleOptions = true;
307 ModuleInfoPatch info = null;
308 ModuleInfoPatch defaultInfo = null;
309 final var patches = new LinkedHashMap<String, ModuleInfoPatch>();
310 for (SourceDirectory source : sourceDirectories) {
311 Path file = source.root.resolve(ModuleInfoPatch.FILENAME);
312 String moduleName;
313 if (Files.notExists(file)) {
314 if (SUPPORT_LEGACY && useModulePath && hasTestModuleInfo && hasModuleDeclaration) {
315 /*
316 * Do not add any `--add-reads` parameters. The developers should put
317 * everything needed in the `module-info`, including test dependencies.
318 */
319 continue;
320 }
321 /*
322 * No `patch-module-info` file. Generate a default module patch instance for the
323 * `--add-modules TEST-MODULE-PATH` and `--add-reads TEST-MODULE-PATH` options.
324 * We generate that patch only for the first module. If there is more modules
325 * without `patch-module-info`, we will copy the `defaultInfo` instance.
326 */
327 moduleName = source.moduleName;
328 if (moduleName == null) {
329 moduleName = moduleNameFromPackageHierarchy;
330 if (moduleName == null) {
331 continue;
332 }
333 }
334 if (defaultInfo != null) {
335 patches.putIfAbsent(moduleName, null); // Remember that we will need to compute a value later.
336 continue;
337 }
338 defaultInfo = new ModuleInfoPatch(moduleName, info);
339 defaultInfo.setToDefaults();
340 info = defaultInfo;
341 } else {
342 info = new ModuleInfoPatch(moduleNameFromPackageHierarchy, info);
343 try (BufferedReader reader = Files.newBufferedReader(file)) {
344 info.load(reader);
345 }
346 moduleName = info.getModuleName();
347 }
348 if (patches.put(moduleName, info) != null) {
349 throw new ModuleInfoPatchException(
350 "\"module-info-patch " + moduleName + "\" is defined more than once.");
351 }
352 }
353 /*
354 * Replace all occurrences of `TEST-MODULE-PATH` by the actual dependency paths.
355 * Add `--add-modules` and `--add-reads` options with default values equivalent to
356 * `TEST-MODULE-PATH` for every module that do not have a `module-info-patch` file.
357 */
358 for (Map.Entry<String, ModuleInfoPatch> entry : patches.entrySet()) {
359 info = entry.getValue();
360 if (info != null) {
361 info.replaceProjectModules(sourceDirectories);
362 info.replaceTestModulePath(dependencyResolution);
363 } else {
364 // `defaultInfo` cannot be null if this `info` value is null.
365 entry.setValue(defaultInfo.patchWithSameReads(entry.getKey()));
366 }
367 }
368 /*
369 * Write the runtime dependencies in the `META-INF/maven/module-info-patch.args` file.
370 * Note that we unconditionally write in the root output directory, not in the module directory,
371 * because a single option file applies to all modules.
372 */
373 if (!patches.isEmpty()) {
374 Path directory = // TODO: replace by Path.resolve(String, String...) with JDK22.
375 Files.createDirectories(outputDirectory.resolve(META_INF).resolve("maven"));
376 try (BufferedWriter out = Files.newBufferedWriter(directory.resolve("module-info-patch.args"))) {
377 for (ModuleInfoPatch m : patches.values()) {
378 m.writeTo(configuration, out);
379 }
380 }
381 }
382 }
383
384 /**
385 * @hidden
386 */
387 @Override
388 public boolean applyIncrementalBuild(AbstractCompilerMojo mojo, Options configuration) throws IOException {
389 addModuleOptions(configuration); // Effective only once.
390 return super.applyIncrementalBuild(mojo, configuration);
391 }
392
393 /**
394 * @hidden
395 */
396 @Override
397 public boolean compile(JavaCompiler compiler, Options configuration, Writer otherOutput) throws IOException {
398 addModuleOptions(configuration); // Effective only once.
399 try (var r = ModuleDirectoryRemover.create(outputDirectory, directoryLevelToRemove)) {
400 return super.compile(compiler, configuration, otherOutput);
401 }
402 }
403
404 /**
405 * Returns the output directory of the main classes. This is the directory to prepend to
406 * the class-path or module-path before to compile the classes managed by this executor.
407 *
408 * @return the directory to prepend to the class-path or module-path
409 */
410 @Override
411 Path getOutputDirectoryOfPreviousPhase() {
412 return mainOutputDirectory;
413 }
414
415 /**
416 * Returns the directory of the classes compiled for the specified module.
417 * If the project is multi-release, this method returns the directory for the base version.
418 *
419 * @param outputDirectory the output directory which is the root of modules
420 * @param moduleName the name of the module for which the class directory is desired
421 * @return directories of classes for the given module
422 */
423 @Override
424 Path resolveModuleOutputDirectory(Path outputDirectory, String moduleName) {
425 if (outputDirectory.equals(mainOutputDirectory)) {
426 Path path = mainOutputDirectoryForModules.get(moduleName);
427 if (path != null) {
428 return path;
429 }
430 }
431 return super.resolveModuleOutputDirectory(outputDirectory, moduleName);
432 }
433
434 /**
435 * Name of the module when using package hierarchy, or {@code null} if not applicable.
436 * This is null in a class-path project or in a multi-module project.
437 *
438 * @deprecated This information exists only for compatibility with the Maven 3 way to build a modular project.
439 */
440 @Override
441 @Deprecated(since = "4.0.0")
442 final String moduleNameFromPackageHierarchy() {
443 return moduleNameFromPackageHierarchy;
444 }
445 }