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.JavaFileObject;
23
24 import java.nio.file.Files;
25 import java.nio.file.Path;
26 import java.util.ArrayList;
27 import java.util.List;
28 import java.util.Objects;
29 import java.util.Optional;
30 import java.util.stream.Stream;
31
32 import org.apache.maven.api.Language;
33 import org.apache.maven.api.SourceRoot;
34 import org.apache.maven.api.Version;
35
36 /**
37 * A single root directory of source files, associated with module name and release version.
38 * The module names are used when compiling a Module Source Hierarchy.
39 * The release version is used for multi-versions JAR files.
40 *
41 * <p>This class contains also the output directory, because this information is needed
42 * for determining whether a source file need to be recompiled.</p>
43 *
44 * @author Martin Desruisseaux
45 */
46 final class SourceDirectory {
47 /**
48 * The module-info filename, without extension.
49 */
50 static final String MODULE_INFO = "module-info";
51
52 /**
53 * File suffix of source code.
54 */
55 static final String JAVA_FILE_SUFFIX = ".java";
56
57 /**
58 * File suffix of compiler classes.
59 */
60 static final String CLASS_FILE_SUFFIX = ".class";
61
62 /**
63 * The root directory of all source files. Whether the path is relative or absolute depends on the paths given to
64 * the {@link #fromProject fromProject(…)} or {@link #fromPluginConfiguration fromPluginConfiguration(…)} methods.
65 * This class preserves the relative/absolute characteristic of the user-specified directories in order to behave
66 * as intended by users in operations such as {@linkplain Path#relativize relativization}, especially in regard of
67 * symbolic links. In practice, this path is often an absolute path.
68 */
69 final Path root;
70
71 /**
72 * Filter for selecting files below the {@linkplain #root} directory, or an empty list for the default filter.
73 * For the Java language, the default filter is {@code "*.java"}. The filters are used by {@link PathFilter}.
74 *
75 * <p>This field differs from {@link PathFilter#includes} in that it is specified in the {@code <source>} element,
76 * while the latter is specified in the plugin configuration. The filter specified here can be different for each
77 * source directory, while the plugin configuration applies to all source directories.</p>
78 *
79 * @see PathFilter#includes
80 */
81 final List<String> includes;
82
83 /**
84 * Filter for excluding files below the {@linkplain #root} directory, or an empty list for no exclusion.
85 * See {@link #includes} for the difference between this field and {@link PathFilter#excludes}.
86 *
87 * @see PathFilter#excludes
88 */
89 final List<String> excludes;
90
91 /**
92 * Kind of source files in this directory. This is usually {@link JavaFileObject.Kind#SOURCE}.
93 * This information is used for building a default include filter such as {@code "glob:*.java}
94 * if the user didn't specified an explicit filter. The default include filter may change for
95 * each root directory.
96 */
97 final JavaFileObject.Kind fileKind;
98
99 /**
100 * Name of the module for which source directories are provided, or {@code null} if none.
101 * This name is supplied to the constructor instead of parsed from {@code module-info.java}
102 * file because the latter may not exist in this directory. For example, in a multi-release
103 * project, the module-info may be declared in another directory for the base version.
104 *
105 * @see #getModuleInfo()
106 */
107 final String moduleName;
108
109 /**
110 * Path to the {@code module-info} file, or {@code null} if none. This flag is set when
111 * walking through the directory content. This is related, but not strictly equivalent,
112 * to whether the {@link #moduleName} is non-null.
113 *
114 * @see #getModuleInfo()
115 */
116 private Path moduleInfo;
117
118 /**
119 * The Java release for which source directories are provided, or {@code null} for the default release.
120 * This is used for multi-versions JAR files. Note that a non-null value does not mean that the classes
121 * will be put in a {@code META-INF/versions/} subdirectory, because this version may be the base version.
122 *
123 * @see #getSpecificVersion()
124 */
125 final SourceVersion release;
126
127 /**
128 * Whether the {@linkplain #release} is a version other than the base version.
129 * This flag is initially unknown (conservatively assumed false) and is set after the base version is known.
130 * Note that a null {@linkplain #release} is considered more recent than all non-null releases (because null
131 * stands for the default, which is usually the runtime version), and therefore is considered versioned if
132 * some non-null releases exist.
133 *
134 * @see #completeIfVersioned(SourceVersion)
135 */
136 private boolean isVersioned;
137
138 /**
139 * The directory where to store the compilation results.
140 * This is the MOJO output directory with sub-directories appended according the following rules, in that order:
141 *
142 * <ol>
143 * <li>If {@link #moduleName} is non-null, then the module name is appended.</li>
144 * <li>If {@link #isVersioned} is {@code true}, then the next elements in the paths are
145 * {@code "META-INF/versions/<n>"} where {@code <n>} is the release number.</li>
146 * </ol>
147 *
148 * @see #getOutputDirectory()
149 */
150 private Path outputDirectory;
151
152 /**
153 * Kind of output files in the output directory.
154 * This is usually {@link JavaFileObject.Kind#CLASS}.
155 */
156 final JavaFileObject.Kind outputFileKind;
157
158 /**
159 * Creates a new source directory.
160 *
161 * @param root the root directory of all source files
162 * @param includes patterns for selecting files below the root directory, or an empty list for the default filter
163 * @param excludes patterns for excluding files below the root directory, or an empty list for no exclusion
164 * @param fileKind kind of source files in this directory (usually {@code SOURCE})
165 * @param moduleName name of the module for which source directories are provided, or {@code null} if none
166 * @param release Java release for which source directories are provided, or {@code null} for the default release
167 * @param outputDirectory the directory where to store the compilation results
168 * @param outputFileKind Kind of output files in the output directory (usually {@ codeCLASS})
169 */
170 @SuppressWarnings("checkstyle:ParameterNumber")
171 private SourceDirectory(
172 Path root,
173 List<String> includes,
174 List<String> excludes,
175 JavaFileObject.Kind fileKind,
176 String moduleName,
177 SourceVersion release,
178 Path outputDirectory,
179 JavaFileObject.Kind outputFileKind) {
180 this.root = Objects.requireNonNull(root);
181 this.includes = Objects.requireNonNull(includes);
182 this.excludes = Objects.requireNonNull(excludes);
183 this.fileKind = Objects.requireNonNull(fileKind);
184 this.moduleName = moduleName;
185 this.release = release;
186 if (moduleName != null) {
187 outputDirectory = outputDirectory.resolve(moduleName);
188 }
189 this.outputDirectory = outputDirectory;
190 this.outputFileKind = outputFileKind;
191 }
192
193 /**
194 * Potentially adds the {@code META-INF/versions/} part of the path to the output directory.
195 * This method can be invoked only after the base version has been determined, which happens
196 * after all other source directories have been built.
197 */
198 private void completeIfVersioned(SourceVersion baseVersion) {
199 @SuppressWarnings("LocalVariableHidesMemberVariable")
200 SourceVersion release = this.release;
201 isVersioned = (release != baseVersion);
202 if (isVersioned) {
203 if (release == null) {
204 release = SourceVersion.latestSupported();
205 // `this.release` intentionally left to null.
206 }
207 outputDirectory = outputDirectoryForReleases(outputDirectory, release);
208 }
209 }
210
211 /**
212 * Returns the directory where to write the compilation for a specific Java release.
213 *
214 * @param outputDirectory usually the value of {@link #outputDirectory}
215 * @param release the release, or {@code null} for the default release
216 */
217 static Path outputDirectoryForReleases(Path outputDirectory, SourceVersion release) {
218 if (release == null) {
219 release = SourceVersion.latestSupported();
220 }
221 String version = release.name(); // TODO: replace by runtimeVersion() in Java 18.
222 version = version.substring(version.lastIndexOf('_') + 1);
223 return outputDirectoryForReleases(outputDirectory).resolve(version);
224 }
225
226 /**
227 * Returns the directory where to write the compilation for a specific Java release.
228 * The caller shall add the version number to the returned path.
229 */
230 static Path outputDirectoryForReleases(Path outputDirectory) {
231 // TODO: use Path.resolve(String, String...) with Java 22.
232 return outputDirectory.resolve("META-INF").resolve("versions");
233 }
234
235 /**
236 * {@return the target version as an object from the Java tools API}
237 *
238 * @param root the source directory for which to get the target version
239 * @throws UnsupportedVersionException if the version string cannot be parsed
240 */
241 static Optional<SourceVersion> targetVersion(final SourceRoot root) {
242 return root.targetVersion().map(Version::toString).map(SourceDirectory::parse);
243 }
244
245 /**
246 * Parses the given version string.
247 * This method parses the version with {@link Runtime.Version#parse(String)}.
248 * Therefore, for Java 8, the version shall be "8", not "1.8".
249 *
250 * @param version the version to parse, or null or empty if none
251 * @return the parsed version, or {@code null} if the given string was null or empty
252 * @throws UnsupportedVersionException if the version string cannot be parsed
253 */
254 private static SourceVersion parse(final String version) {
255 if (version == null || version.isBlank()) {
256 return null;
257 }
258 try {
259 var parsed = Runtime.Version.parse(version);
260 return SourceVersion.valueOf("RELEASE_" + parsed.feature());
261 // TODO: Replace by return SourceVersion.valueOf(v) after upgrade to Java 18.
262 } catch (IllegalArgumentException e) {
263 throw new UnsupportedVersionException("Illegal version number: \"" + version + '"', e);
264 }
265 }
266
267 /**
268 * Gets the list of source directories from the project manager.
269 * The returned list includes only the directories that exist.
270 *
271 * @param compileSourceRoots the root paths to source files
272 * @param defaultRelease the release to use if the {@code <source>} element provides none, or {@code null}
273 * @param outputDirectory the directory where to store the compilation results
274 * @return the given list of paths wrapped as source directory objects
275 */
276 static List<SourceDirectory> fromProject(
277 Stream<SourceRoot> compileSourceRoots, String defaultRelease, Path outputDirectory) {
278 var release = parse(defaultRelease); // May be null.
279 var roots = new ArrayList<SourceDirectory>();
280 compileSourceRoots.forEach((SourceRoot source) -> {
281 Path directory = source.directory();
282 if (Files.exists(directory)) {
283 var fileKind = JavaFileObject.Kind.OTHER;
284 var outputFileKind = JavaFileObject.Kind.OTHER;
285 if (Language.JAVA_FAMILY.equals(source.language())) {
286 fileKind = JavaFileObject.Kind.SOURCE;
287 outputFileKind = JavaFileObject.Kind.CLASS;
288 }
289 roots.add(new SourceDirectory(
290 directory,
291 source.includes(),
292 source.excludes(),
293 fileKind,
294 source.module().orElse(null),
295 targetVersion(source).orElse(release),
296 outputDirectory,
297 outputFileKind));
298 }
299 });
300 roots.stream()
301 .map((dir) -> dir.release)
302 .filter(Objects::nonNull)
303 .min(SourceVersion::compareTo)
304 .ifPresent((baseVersion) -> roots.forEach((dir) -> dir.completeIfVersioned(baseVersion)));
305 return roots;
306 }
307
308 /**
309 * Converts the given list of paths to a list of source directories.
310 * The returned list includes only the directories that exist.
311 * Used only when the compiler plugin is configured with the {@code compileSourceRoots} option.
312 *
313 * @param compileSourceRoots the root paths to source files
314 * @param moduleName name of the module for which source directories are provided, or {@code null} if none
315 * @param defaultRelease the release to use, or {@code null} of unspecified
316 * @param outputDirectory the directory where to store the compilation results
317 * @return the given list of paths wrapped as source directory objects
318 */
319 static List<SourceDirectory> fromPluginConfiguration(
320 List<String> compileSourceRoots, String moduleName, String defaultRelease, Path outputDirectory) {
321 var release = parse(defaultRelease); // May be null.
322 var roots = new ArrayList<SourceDirectory>(compileSourceRoots.size());
323 for (String file : compileSourceRoots) {
324 Path directory = Path.of(file);
325 if (Files.exists(directory)) {
326 roots.add(new SourceDirectory(
327 directory,
328 List.of(),
329 List.of(),
330 JavaFileObject.Kind.SOURCE,
331 moduleName,
332 release,
333 outputDirectory,
334 JavaFileObject.Kind.CLASS));
335 }
336 }
337 return roots;
338 }
339
340 /**
341 * Returns whether the given file is a {@code module-info.java} file.
342 * TODO: we could make this method non-static and verify that the given
343 * file is in the root of this directory.
344 */
345 static boolean isModuleInfoSource(Path file) {
346 return (MODULE_INFO + JAVA_FILE_SUFFIX).equals(file.getFileName().toString());
347 }
348
349 /**
350 * Invoked for each source files in this directory.
351 */
352 void visit(Path sourceFile) {
353 if (isModuleInfoSource(sourceFile)) {
354 // Paranoiac check: only one file should exist, but if many, keep the one closest to the root.
355 if (moduleInfo == null || moduleInfo.getNameCount() >= sourceFile.getNameCount()) {
356 moduleInfo = sourceFile;
357 }
358 }
359 }
360
361 /**
362 * Path to the {@code module-info.java} source file, or empty if none.
363 * This information is accurate only after {@link PathFilter} finished
364 * to walk through all source files in a directory.
365 */
366 public Optional<Path> getModuleInfo() {
367 return Optional.ofNullable(moduleInfo);
368 }
369
370 /**
371 * {@return the Java version of the sources in this directory if different than the base version}
372 * The value returned by this method is related to the {@code META-INF/versions/} subdirectory in
373 * the path returned by {@link #getOutputDirectory()}. If this method returns an empty value, then
374 * there is no such subdirectory (which doesn't mean that the user did not specified a Java version).
375 * If non-empty, the returned value is the value of <var>n</var> in {@code META-INF/versions/n}.
376 */
377 public Optional<SourceVersion> getSpecificVersion() {
378 return Optional.ofNullable(isVersioned ? release : null);
379 }
380
381 /**
382 * {@return the directory where to store the compilation results}
383 * This is the <abbr>MOJO</abbr> output directory potentially completed with
384 * sub-directories for module name and {@code META-INF/versions} versioning.
385 */
386 public Path getOutputDirectory() {
387 return outputDirectory;
388 }
389
390 /**
391 * Compares the given object with this source directory for equality.
392 *
393 * @param obj the object to compare
394 * @return whether the two objects have the same path, module name and release version
395 */
396 @Override
397 public boolean equals(Object obj) {
398 if (obj instanceof SourceDirectory other) {
399 return root.equals(other.root)
400 && includes.equals(other.includes)
401 && excludes.equals(other.excludes)
402 && fileKind == other.fileKind
403 && Objects.equals(moduleName, other.moduleName)
404 && release == other.release
405 && outputDirectory.equals(other.outputDirectory)
406 && outputFileKind == other.outputFileKind;
407 }
408 return false;
409 }
410
411 /**
412 * {@return a hash code value for this root directory}
413 */
414 @Override
415 public int hashCode() {
416 return Objects.hash(root, moduleName, release);
417 }
418
419 /**
420 * {@return a string representation of this root directory for debugging purposes}
421 */
422 @Override
423 public String toString() {
424 var sb = new StringBuilder(100).append('"').append(root).append('"');
425 if (moduleName != null) {
426 sb.append(" for module \"").append(moduleName).append('"');
427 }
428 if (release != null) {
429 sb.append(" on Java release ").append(release);
430 }
431 return sb.toString();
432 }
433 }