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.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 }