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 #isVersioned} is {@code true}, then the relative part of the path starts with
144 * {@code "META-INF/versions/<n>"} where {@code <n>} is the release number.</li>
145 * <li>If {@link #moduleName} is non-null, then the module name is appended.</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 * @param baseVersion the Java release target by the non-versioned classes
199 */
200 private void completeIfVersioned(SourceVersion baseVersion) {
201 @SuppressWarnings("LocalVariableHidesMemberVariable")
202 SourceVersion release = this.release;
203 isVersioned = (release != baseVersion);
204 if (isVersioned) {
205 if (release == null) {
206 release = SourceVersion.latestSupported();
207 // `this.release` intentionally left to null.
208 }
209 var hierarchy = (moduleName != null) ? DirectoryHierarchy.MODULE_SOURCE : DirectoryHierarchy.PACKAGE;
210 outputDirectory = hierarchy.outputDirectoryForReleases(outputDirectory, release);
211 }
212 }
213
214 /**
215 * {@return the target version as an object from the Java tools API}
216 *
217 * @param root the source directory for which to get the target version
218 * @throws UnsupportedVersionException if the version string cannot be parsed
219 */
220 static Optional<SourceVersion> targetVersion(final SourceRoot root) {
221 return root.targetVersion().map(Version::toString).map(SourceDirectory::parse);
222 }
223
224 /**
225 * Parses the given version string.
226 * This method parses the version with {@link Runtime.Version#parse(String)}.
227 * Therefore, for Java 8, the version shall be "8", not "1.8".
228 *
229 * @param version the version to parse, or null or empty if none
230 * @return the parsed version, or {@code null} if the given string was null or empty
231 * @throws UnsupportedVersionException if the version string cannot be parsed
232 */
233 static SourceVersion parse(final String version) {
234 if (version == null || version.isBlank()) {
235 return null;
236 }
237 try {
238 var parsed = Runtime.Version.parse(version);
239 return SourceVersion.valueOf("RELEASE_" + parsed.feature());
240 // TODO: Replace by return SourceVersion.valueOf(v) after upgrade to Java 18.
241 } catch (IllegalArgumentException e) {
242 throw new UnsupportedVersionException("Illegal version number: \"" + version + '"', e);
243 }
244 }
245
246 /**
247 * Gets the list of source directories from the project manager.
248 * The returned list includes only the directories that exist.
249 *
250 * @param compileSourceRoots the root paths to source files
251 * @param defaultRelease the release to use if the {@code <source>} element provides none, or {@code null}
252 * @param outputDirectory the directory where to store the compilation results
253 * @return the given list of paths wrapped as source directory objects
254 */
255 static List<SourceDirectory> fromProject(
256 Stream<SourceRoot> compileSourceRoots, String defaultRelease, Path outputDirectory) {
257 var release = parse(defaultRelease); // May be null.
258 var roots = new ArrayList<SourceDirectory>();
259 compileSourceRoots.forEach((SourceRoot source) -> {
260 Path directory = source.directory();
261 if (Files.exists(directory)) {
262 var fileKind = JavaFileObject.Kind.OTHER;
263 var outputFileKind = JavaFileObject.Kind.OTHER;
264 if (Language.JAVA_FAMILY.equals(source.language())) {
265 fileKind = JavaFileObject.Kind.SOURCE;
266 outputFileKind = JavaFileObject.Kind.CLASS;
267 }
268 roots.add(new SourceDirectory(
269 directory,
270 source.includes(),
271 source.excludes(),
272 fileKind,
273 source.module().orElse(null),
274 targetVersion(source).orElse(release),
275 outputDirectory,
276 outputFileKind));
277 }
278 });
279 roots.stream()
280 .map((dir) -> dir.release)
281 .filter(Objects::nonNull)
282 .min(SourceVersion::compareTo)
283 .ifPresent((baseVersion) -> roots.forEach((dir) -> dir.completeIfVersioned(baseVersion)));
284 return roots;
285 }
286
287 /**
288 * Converts the given list of paths to a list of source directories.
289 * The returned list includes only the directories that exist.
290 * Used only when the compiler plugin is configured with the {@code compileSourceRoots} option.
291 *
292 * @param compileSourceRoots the root paths to source files
293 * @param moduleName name of the module for which source directories are provided, or {@code null} if none
294 * @param defaultRelease the release to use, or {@code null} of unspecified
295 * @param outputDirectory the directory where to store the compilation results
296 * @return the given list of paths wrapped as source directory objects
297 */
298 static List<SourceDirectory> fromPluginConfiguration(
299 List<String> compileSourceRoots, String moduleName, String defaultRelease, Path outputDirectory) {
300 var release = parse(defaultRelease); // May be null.
301 var roots = new ArrayList<SourceDirectory>(compileSourceRoots.size());
302 for (String file : compileSourceRoots) {
303 Path directory = Path.of(file);
304 if (Files.exists(directory)) {
305 roots.add(new SourceDirectory(
306 directory,
307 List.of(),
308 List.of(),
309 JavaFileObject.Kind.SOURCE,
310 moduleName,
311 release,
312 outputDirectory,
313 JavaFileObject.Kind.CLASS));
314 }
315 }
316 return roots;
317 }
318
319 /**
320 * Returns whether the given file is a {@code module-info.java} file.
321 * TODO: we could make this method non-static and verify that the given
322 * file is in the root of this directory.
323 */
324 static boolean isModuleInfoSource(Path file) {
325 return (MODULE_INFO + JAVA_FILE_SUFFIX).equals(file.getFileName().toString());
326 }
327
328 /**
329 * Invoked for each source files in this directory.
330 */
331 void visit(Path sourceFile) {
332 if (isModuleInfoSource(sourceFile)) {
333 // Paranoiac check: only one file should exist, but if many, keep the one closest to the root.
334 if (moduleInfo == null || moduleInfo.getNameCount() >= sourceFile.getNameCount()) {
335 moduleInfo = sourceFile;
336 }
337 }
338 }
339
340 /**
341 * Path to the {@code module-info.java} source file, or empty if none.
342 * This information is accurate only after {@link PathFilter} finished
343 * to walk through all source files in a directory.
344 */
345 public Optional<Path> getModuleInfo() {
346 return Optional.ofNullable(moduleInfo);
347 }
348
349 /**
350 * {@return the Java version of the sources in this directory if different than the base version}
351 * The value returned by this method is related to the {@code META-INF/versions/} subdirectory in
352 * the path returned by {@link #getOutputDirectory()}. If this method returns an empty value, then
353 * there is no such subdirectory (which doesn't mean that the user did not specified a Java version).
354 * If non-empty, the returned value is the value of <var>n</var> in {@code META-INF/versions/n}.
355 */
356 public Optional<SourceVersion> getSpecificVersion() {
357 return Optional.ofNullable(isVersioned ? release : null);
358 }
359
360 /**
361 * {@return the directory where to store the compilation results}
362 * This is the <abbr>MOJO</abbr> output directory potentially completed with
363 * sub-directories for module name and {@code META-INF/versions} versioning.
364 */
365 public Path getOutputDirectory() {
366 return outputDirectory;
367 }
368
369 /**
370 * Compares the given object with this source directory for equality.
371 *
372 * @param obj the object to compare
373 * @return whether the two objects have the same path, module name and release version
374 */
375 @Override
376 public boolean equals(Object obj) {
377 if (obj instanceof SourceDirectory other) {
378 return root.equals(other.root)
379 && includes.equals(other.includes)
380 && excludes.equals(other.excludes)
381 && fileKind == other.fileKind
382 && Objects.equals(moduleName, other.moduleName)
383 && release == other.release
384 && outputDirectory.equals(other.outputDirectory)
385 && outputFileKind == other.outputFileKind;
386 }
387 return false;
388 }
389
390 /**
391 * {@return a hash code value for this root directory}
392 */
393 @Override
394 public int hashCode() {
395 return Objects.hash(root, moduleName, release);
396 }
397
398 /**
399 * {@return a string representation of this root directory for debugging purposes}
400 */
401 @Override
402 public String toString() {
403 var sb = new StringBuilder(100).append('"').append(root).append('"');
404 if (moduleName != null) {
405 sb.append(" for module \"").append(moduleName).append('"');
406 }
407 if (release != null) {
408 sb.append(" on Java release ").append(release);
409 }
410 return sb.toString();
411 }
412 }