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.tools.DiagnosticListener;
22  import javax.tools.JavaCompiler;
23  import javax.tools.JavaFileObject;
24  
25  import java.io.BufferedReader;
26  import java.io.BufferedWriter;
27  import java.io.IOException;
28  import java.io.InputStream;
29  import java.io.Writer;
30  import java.lang.module.ModuleDescriptor;
31  import java.nio.file.Files;
32  import java.nio.file.Path;
33  import java.util.LinkedHashMap;
34  import java.util.LinkedHashSet;
35  import java.util.List;
36  import java.util.Map;
37  import java.util.Set;
38  
39  import org.apache.maven.api.JavaPathType;
40  import org.apache.maven.api.PathType;
41  import org.apache.maven.api.ProjectScope;
42  
43  import static org.apache.maven.plugin.compiler.AbstractCompilerMojo.SUPPORT_LEGACY;
44  
45  /**
46   * A task which configures and executes the Java compiler for the test classes.
47   * This executor contains additional configurations compared to the base class.
48   *
49   * @author Martin Desruisseaux
50   */
51  class ToolExecutorForTest extends ToolExecutor {
52      /**
53       * The output directory of the main classes.
54       * This directory will be added to the class-path or module-path.
55       *
56       * @see TestCompilerMojo#mainOutputDirectory
57       */
58      protected final Path mainOutputDirectory;
59  
60      /**
61       * Path to the {@code module-info.class} file of the main code, or {@code null} if that file does not exist.
62       */
63      private final Path mainModulePath;
64  
65      /**
66       * Whether to place the main classes on the module path when {@code module-info} is present.
67       * The default and recommended value is {@code true}. The user may force to {@code false},
68       * in which case the main classes are placed on the class path, but this is deprecated.
69       * This flag may be removed in a future version if we remove support of this practice.
70       *
71       * @deprecated Use {@code "claspath-jar"} dependency type instead, and avoid {@code module-info.java} in tests.
72       *
73       * @see TestCompilerMojo#useModulePath
74       */
75      @Deprecated(since = "4.0.0")
76      private final boolean useModulePath;
77  
78      /**
79       * Whether a {@code module-info.java} file is defined in the test sources.
80       * In such case, it has precedence over the {@code module-info.java} in main sources.
81       * This is defined for compatibility with Maven 3, but not recommended.
82       *
83       * @deprecated Avoid {@code module-info.java} in tests.
84       */
85      @Deprecated(since = "4.0.0")
86      private final boolean hasTestModuleInfo;
87  
88      /**
89       * Whether the tests are declared in their own module. If {@code true},
90       * then the {@code module-info.java} file of the test declares a name
91       * different than the {@code module-info.java} file of the main code.
92       */
93      private boolean testInItsOwnModule;
94  
95      /**
96       * Whether the {@code module-info} of the tests overwrites the main {@code module-info}.
97       * This is a deprecated practice, but is accepted if {@link #SUPPORT_LEGACY} is true.
98       */
99      private boolean overwriteMainModuleInfo;
100 
101     /**
102      * Name of the main module to compile, or {@code null} if not yet determined.
103      * If the project is not modular, then this field contains an empty string.
104      *
105      * TODO: use "*" as a sentinel value for modular source hierarchy.
106      *
107      * @see #getMainModuleName()
108      */
109     private String moduleName;
110 
111     /**
112      * Whether {@link #addModuleOptions(Options)} has already been invoked.
113      * The options shall be completed only once, otherwise conflicts may occur.
114      */
115     private boolean addedModuleOptions;
116 
117     /**
118      * If non-null, the {@code module} part to remove in {@code target/test-classes/module/package}.
119      * This {@code module} directory is generated by {@code javac} for some compiler options.
120      * We keep that directory when the project is configured with the new {@code <source>} element,
121      * but have to remove it for compatibility reason if the project is compiled in the old way.
122      *
123      * @deprecated Exists only for compatibility with the Maven 3 way to do a modular project.
124      * Is likely to cause confusion, for example with incremental builds.
125      * New projects should use the {@code <source>} elements instead.
126      */
127     @Deprecated(since = "4.0.0")
128     private String directoryLevelToRemove;
129 
130     /**
131      * Creates a new task by taking a snapshot of the current configuration of the given <abbr>MOJO</abbr>.
132      * This constructor creates the {@linkplain #outputDirectory output directory} if it does not already exist.
133      *
134      * @param mojo the <abbr>MOJO</abbr> from which to take a snapshot
135      * @param listener where to send compilation warnings, or {@code null} for the Maven logger
136      * @throws MojoException if this constructor identifies an invalid parameter in the <abbr>MOJO</abbr>
137      * @throws IOException if an error occurred while creating the output directory or scanning the source directories
138      */
139     @SuppressWarnings("deprecation")
140     ToolExecutorForTest(TestCompilerMojo mojo, DiagnosticListener<? super JavaFileObject> listener) throws IOException {
141         super(mojo, listener);
142         mainOutputDirectory = mojo.mainOutputDirectory;
143         mainModulePath = mojo.mainModulePath;
144         useModulePath = mojo.useModulePath;
145         hasTestModuleInfo = mojo.hasTestModuleInfo;
146         /*
147          * If we are compiling the test classes of a modular project, add the `--patch-modules` options.
148          * In this case, the option values are directories of main class files of the patched module.
149          */
150         final var patchedModules = new LinkedHashMap<String, Set<Path>>();
151         for (SourceDirectory dir : sourceDirectories) {
152             String moduleToPatch = dir.moduleName;
153             if (moduleToPatch == null) {
154                 moduleToPatch = getMainModuleName();
155                 if (moduleToPatch.isEmpty()) {
156                     continue; // No module-info found.
157                 }
158                 if (SUPPORT_LEGACY) {
159                     String testModuleName = mojo.getTestModuleName(sourceDirectories);
160                     if (testModuleName != null) {
161                         overwriteMainModuleInfo = testModuleName.equals(getMainModuleName());
162                         if (!overwriteMainModuleInfo) {
163                             testInItsOwnModule = true;
164                             continue; // The test classes are in their own module.
165                         }
166                     }
167                 }
168                 directoryLevelToRemove = moduleToPatch;
169             }
170             patchedModules.put(moduleToPatch, new LinkedHashSet<>()); // Signal that this module exists in the test.
171         }
172         /*
173          * The values of `patchedModules` are empty lists. Now, add the real paths to
174          * main class for each module that exists in both the main code and the test.
175          */
176         mojo.getSourceRoots(ProjectScope.MAIN).forEach((root) -> {
177             root.module().ifPresent((moduleToPatch) -> {
178                 Set<Path> paths = patchedModules.get(moduleToPatch);
179                 if (paths != null) {
180                     Path path = root.targetPath().orElseGet(() -> Path.of(moduleToPatch));
181                     path = mainOutputDirectory.resolve(path);
182                     paths.add(path);
183                 }
184             });
185         });
186         patchedModules.values().removeIf(Set::isEmpty);
187         patchedModules.forEach((moduleToPatch, paths) -> {
188             dependencies(JavaPathType.patchModule(moduleToPatch)).addAll(paths);
189         });
190         /*
191          * If there is no module to patch, we probably have a non-modular project.
192          * In such case, we need to put the main output directory on the classpath.
193          * It may also be a modular project not declared in the `<source>` element.
194          */
195         if (patchedModules.isEmpty() && Files.exists(mainOutputDirectory)) {
196             PathType pathType = JavaPathType.CLASSES;
197             if (hasModuleDeclaration) {
198                 pathType = JavaPathType.MODULES;
199                 if (!testInItsOwnModule) {
200                     String moduleToPatch = getMainModuleName();
201                     if (!moduleToPatch.isEmpty()) {
202                         pathType = JavaPathType.patchModule(moduleToPatch);
203                         directoryLevelToRemove = moduleToPatch;
204                     }
205                 }
206             }
207             prependDependency(pathType, mainOutputDirectory);
208         }
209     }
210 
211     /**
212      * {@return the module name of the main code, or an empty string if none}
213      * This method reads the module descriptor when first needed and caches the result.
214      * This used if the user did not specified an explicit {@code <module>} element in the sources.
215      *
216      * @throws IOException if the module descriptor cannot be read.
217      */
218     private String getMainModuleName() throws IOException {
219         if (moduleName == null) {
220             if (mainModulePath != null) {
221                 try (InputStream in = Files.newInputStream(mainModulePath)) {
222                     moduleName = ModuleDescriptor.read(in).name();
223                 }
224             } else {
225                 moduleName = "";
226             }
227         }
228         return moduleName;
229     }
230 
231     /**
232      * If the given module name is empty, tries to infer a default module name.
233      */
234     @Override
235     final String inferModuleNameIfMissing(String moduleName) throws IOException {
236         return (!testInItsOwnModule && moduleName.isEmpty()) ? getMainModuleName() : moduleName;
237     }
238 
239     /**
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.
246      *
247      * @param configuration where to add the options
248      * @throws IOException if the module information of a dependency or the module-info patch cannot be read
249      */
250     @SuppressWarnings({"checkstyle:MissingSwitchDefault", "fallthrough"})
251     private void addModuleOptions(final Options configuration) throws IOException {
252         if (addedModuleOptions) {
253             return;
254         }
255         addedModuleOptions = true;
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;
281                     }
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();
296             }
297             if (patches.put(module, info) != null) {
298                 throw new ModuleInfoPatchException("\"module-info-patch " + module + "\" is defined more than once.");
299             }
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()));
314             }
315         }
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             }
329         }
330     }
331 
332     /**
333      * @hidden
334      */
335     @Override
336     public boolean applyIncrementalBuild(AbstractCompilerMojo mojo, Options configuration) throws IOException {
337         addModuleOptions(configuration); // Effective only once.
338         return super.applyIncrementalBuild(mojo, configuration);
339     }
340 
341     /**
342      * @hidden
343      */
344     @Override
345     public boolean compile(JavaCompiler compiler, Options configuration, Writer otherOutput) throws IOException {
346         addModuleOptions(configuration); // Effective only once.
347         try (var r = ModuleDirectoryRemover.create(outputDirectory, directoryLevelToRemove)) {
348             return super.compile(compiler, configuration, otherOutput);
349         }
350     }
351 
352     /**
353      * Separates the compilation of {@code module-info} from other classes. This is needed when the
354      * {@code module-info} of the test classes overwrite the {@code module-info} of the main classes.
355      * In the latter case, we need to compile the test {@code module-info} first in order to substitute
356      * the main module-info by the test one before to compile the remaining test classes.
357      */
358     @Override
359     final CompilationTaskSources[] toCompilationTasks(final SourcesForRelease unit) {
360         if (!(SUPPORT_LEGACY && useModulePath && hasTestModuleInfo && overwriteMainModuleInfo)) {
361             return super.toCompilationTasks(unit);
362         }
363         CompilationTaskSources moduleInfo = null;
364         final List<Path> files = unit.files;
365         for (int i = files.size(); --i >= 0; ) {
366             if (SourceDirectory.isModuleInfoSource(files.get(i))) {
367                 moduleInfo = new CompilationTaskSources(List.of(files.remove(i)));
368                 if (files.isEmpty()) {
369                     return new CompilationTaskSources[] {moduleInfo};
370                 }
371                 break;
372             }
373         }
374         if (files.isEmpty()) {
375             return new CompilationTaskSources[0];
376         }
377         var task = new CompilationTaskSources(files) {
378             /**
379              * Substitutes the main {@code module-info.class} by the test's one, compiles test classes,
380              * then restores the original {@code module-info.class}. The test {@code module-info.class}
381              * must have been compiled separately before this method is invoked.
382              */
383             @Override
384             boolean compile(JavaCompiler.CompilationTask task) throws IOException {
385                 try (unit) {
386                     unit.substituteModuleInfos(mainOutputDirectory, outputDirectory);
387                     return super.compile(task);
388                 }
389             }
390         };
391         if (moduleInfo != null) {
392             return new CompilationTaskSources[] {moduleInfo, task};
393         } else {
394             return new CompilationTaskSources[] {task};
395         }
396     }
397 }