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 }