View Javadoc
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 }