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.eclipse.aether.tools;
20  
21  import javax.tools.DiagnosticCollector;
22  import javax.tools.DocumentationTool;
23  import javax.tools.JavaFileObject;
24  import javax.tools.StandardJavaFileManager;
25  import javax.tools.StandardLocation;
26  import javax.tools.ToolProvider;
27  
28  import java.io.File;
29  import java.io.IOException;
30  import java.io.PrintWriter;
31  import java.io.Reader;
32  import java.io.Writer;
33  import java.net.URI;
34  import java.net.URISyntaxException;
35  import java.net.URL;
36  import java.net.URLClassLoader;
37  import java.nio.charset.StandardCharsets;
38  import java.nio.file.Files;
39  import java.nio.file.Path;
40  import java.util.ArrayList;
41  import java.util.Arrays;
42  import java.util.Comparator;
43  import java.util.LinkedHashMap;
44  import java.util.List;
45  import java.util.Map;
46  import java.util.Objects;
47  import java.util.Properties;
48  import java.util.concurrent.Callable;
49  import java.util.stream.Collectors;
50  import java.util.stream.Stream;
51  
52  import org.apache.velocity.VelocityContext;
53  import org.apache.velocity.app.VelocityEngine;
54  import org.apache.velocity.runtime.resource.loader.ClasspathResourceLoader;
55  import org.codehaus.plexus.util.io.CachingWriter;
56  import picocli.CommandLine;
57  
58  /**
59   * This tool is used both from <a href="https://github.com/apache/maven-resolver/blob/79d102b66235f33ad1e6134e18451ac3ee91b44a/maven-resolver-tools/pom.xml#L185">Resolver</a>
60   * as well as from <a href="https://github.com/apache/maven/blob/7aa3c8a37b091a5a86d3dae3a7d99ce910fd6caa/pom.xml#L1043">Maven</a>
61   * to generate documentation for configuration keys. It scans the source files under a given root directory, collects the configuration keys declared in them and renders them into Velocity templates.
62   * It relies on javadoc with a custom doclet to extract the configuration keys from the source files.
63   * The doclet writes the discovered keys into an intermediate properties file, which is then read back and used to render the Velocity templates.
64   */
65  @CommandLine.Command(name = "docgen", description = "Configuration Documentation Generator")
66  public class CollectConfiguration implements Callable<Integer> {
67      public static void main(String[] args) {
68          new CommandLine(new CollectConfiguration()).execute(args);
69      }
70  
71      protected static final String KEY = "key";
72  
73      /**
74       * The metadata fields collected per configuration key and written to / read from the intermediate properties file.
75       */
76      protected static final List<String> FIELDS = List.of(
77              KEY,
78              "defaultValue",
79              "fqName",
80              "description",
81              "since",
82              "configurationSource",
83              "configurationType",
84              "configurationTypeJavadocUrl",
85              "supportRepoIdSuffix",
86              "deprecated");
87  
88      /**
89       * Javadoc block tag marking a constant field as a configuration key.
90       */
91      protected static final String CONFIGURATION_MARKER = "@configurationSource";
92  
93      /**
94       * Text marker used to pre-select the source files to feed the doclet when scanning Maven sources. Maven declares
95       * configuration keys via the {@code org.apache.maven.api.annotations.Config} annotation.
96       */
97      protected static final String MAVEN_CONFIGURATION_MARKER = "@Config";
98  
99      /**
100      * The mode of the generator, i.e. what kind of sources are being scanned.
101      */
102     public enum Mode {
103         maven,
104         resolver
105     }
106 
107     @CommandLine.Option(
108             names = {"-m", "--mode"},
109             arity = "1",
110             paramLabel = "mode",
111             description = "The mode of generator (what is being scanned?), supported modes are 'maven', 'resolver'")
112     protected Mode mode = Mode.resolver;
113 
114     @CommandLine.Option(
115             names = {"-t", "--templates"},
116             arity = "1",
117             split = ",",
118             paramLabel = "template",
119             description = "The template names to write content out without '.vm' extension")
120     protected List<String> templates;
121 
122     @CommandLine.Option(
123             names = "--internal-javadoc-url",
124             paramLabel = "url",
125             description = "The base URL for Javadoc generated by this project")
126     protected URI internalJavadocUrl = URI.create("apidocs/");
127 
128     @CommandLine.Option(
129             names = "--internal-javadoc-version",
130             paramLabel = "version",
131             description = "The Javadoc tool version used to generate the internal site")
132     protected String internalJavadocVersion = "21";
133 
134     @CommandLine.Option(
135             names = "--external-javadoc-url",
136             split = ",",
137             paramLabel = "url",
138             description = "External Javadoc base URLs used for validated links")
139     protected List<URI> externalJavadocUrls = new ArrayList<>();
140 
141     @CommandLine.Option(
142             names = "--internal-javadoc-source-tree",
143             paramLabel = "directory",
144             description =
145                     "Recursively searches each directory tree for .java files and uses each distinct nearest ancestor "
146                             + "ending in src/main/java as an aggregate Javadoc source root. May be repeated; defaults to "
147                             + "the source processing root")
148     protected List<Path> internalJavadocSourceTrees = new ArrayList<>();
149 
150     @CommandLine.Parameters(index = "0", description = "The root directory to process sources from")
151     protected Path rootDirectory;
152 
153     @CommandLine.Parameters(index = "1", description = "The directory to generate output(s) to")
154     protected Path outputDirectory;
155 
156     @Override
157     public Integer call() {
158         try {
159             rootDirectory = rootDirectory.toAbsolutePath().normalize();
160             outputDirectory = outputDirectory.toAbsolutePath().normalize();
161 
162             System.out.println("Processing sources from " + rootDirectory);
163             Path intermediateFile = Files.createTempFile("configuration-keys", ".properties");
164             try {
165                 runDoclet(intermediateFile);
166                 List<Map<String, String>> discoveredKeys = readDiscoveredKeys(intermediateFile);
167                 discoveredKeys.sort(Comparator.comparing(e -> e.get(KEY)));
168                 render(discoveredKeys);
169             } finally {
170                 Files.deleteIfExists(intermediateFile);
171             }
172             return 0;
173         } catch (Exception e) {
174             e.printStackTrace(System.err);
175             return 1;
176         }
177     }
178 
179     /**
180      * Collects the source files under {@link #rootDirectory} and runs {@link ConfigurationCollectorDoclet} against them,
181      * having it write the discovered configuration keys into the given intermediate properties file.
182      */
183     protected void runDoclet(Path intermediateFile) throws Exception {
184         // Only feed javadoc the files that actually declare configuration keys. This keeps the set of types that
185         // javadoc must resolve small, avoiding failures caused by unrelated sources referencing dependencies that
186         // are not on this module's classpath (e.g. gson, jetty).
187         String marker = mode == Mode.maven ? MAVEN_CONFIGURATION_MARKER : CONFIGURATION_MARKER;
188         List<Path> javaSourceFiles = findMainJavaSourceFiles(rootDirectory);
189         List<File> sourceFiles = javaSourceFiles.stream()
190                 .filter(p -> !p.getFileName().toString().equals("module-info.java"))
191                 .filter(p -> !p.toString().replace('\\', '/').contains("/maven-resolver-tools/"))
192                 .filter(p -> fileContains(p, marker))
193                 .map(Path::toFile)
194                 .collect(Collectors.toList());
195         if (sourceFiles.isEmpty()) {
196             throw new IllegalStateException(
197                     "No Java sources declaring configuration keys found under " + rootDirectory);
198         }
199         List<Path> internalJavadocSourceFiles;
200         if (internalJavadocSourceTrees.isEmpty()) {
201             internalJavadocSourceFiles = javaSourceFiles;
202         } else {
203             internalJavadocSourceFiles = new ArrayList<>();
204             for (Path sourceTree : internalJavadocSourceTrees) {
205                 internalJavadocSourceFiles.addAll(findMainJavaSourceFiles(sourceTree));
206             }
207         }
208         // The Javadoc report may be aggregated from a wider source tree than the configuration declarations.
209         List<Path> internalJavadocSourceRoots = internalJavadocSourceFiles.stream()
210                 .map(CollectConfiguration::findMainJavaSourceRoot)
211                 .filter(Objects::nonNull)
212                 .distinct()
213                 .toList();
214 
215         DocumentationTool documentationTool = ToolProvider.getSystemDocumentationTool();
216         DiagnosticCollector<JavaFileObject> diagnostics = new DiagnosticCollector<>();
217         try (StandardJavaFileManager fileManager =
218                 documentationTool.getStandardFileManager(diagnostics, null, StandardCharsets.UTF_8)) {
219             // Configure the classpath on the file manager (the -classpath option is not honored when a file manager
220             // is supplied to getTask()). Note that under exec:java the project dependencies are on the context
221             // classloader, not on the JVM's java.class.path.
222             fileManager.setLocation(StandardLocation.CLASS_PATH, resolveClasspath());
223 
224             Iterable<? extends JavaFileObject> compilationUnits = fileManager.getJavaFileObjectsFromFiles(sourceFiles);
225 
226             List<String> options = new ArrayList<>(Arrays.asList(
227                     "--output",
228                     intermediateFile.toString(),
229                     "--mode",
230                     mode.name(),
231                     "--internal-javadoc-url",
232                     internalJavadocUrl.toString(),
233                     "--internal-javadoc-version",
234                     internalJavadocVersion,
235                     "-encoding",
236                     "UTF-8"));
237             for (Path internalJavadocSourceRoot : internalJavadocSourceRoots) {
238                 options.add("--internal-javadoc-source-root");
239                 options.add(internalJavadocSourceRoot.toString());
240             }
241             for (URI externalJavadocUrl : externalJavadocUrls) {
242                 options.add("--external-javadoc-url");
243                 options.add(externalJavadocUrl.toString());
244             }
245 
246             Writer out = new PrintWriter(System.err);
247             DocumentationTool.DocumentationTask task = documentationTool.getTask(
248                     out, fileManager, diagnostics, ConfigurationCollectorDoclet.class, options, compilationUnits);
249             boolean ok = task.call();
250             out.flush();
251             if (!ok) {
252                 diagnostics.getDiagnostics().forEach(d -> System.err.println(d));
253                 throw new IllegalStateException("Javadoc doclet execution failed");
254             }
255         }
256     }
257 
258     private static boolean fileContains(Path path, String marker) {
259         try {
260             return Files.readString(path, StandardCharsets.UTF_8).contains(marker);
261         } catch (IOException e) {
262             return false;
263         }
264     }
265 
266     private static List<Path> findMainJavaSourceFiles(Path sourceTree) throws IOException {
267         try (Stream<Path> stream = Files.walk(sourceTree)) {
268             return stream.map(Path::toAbsolutePath)
269                     .filter(p -> p.getFileName().toString().endsWith(".java"))
270                     .filter(p -> findMainJavaSourceRoot(p) != null)
271                     .toList();
272         }
273     }
274 
275     private static Path findMainJavaSourceRoot(Path sourceFile) {
276         Path suffix = Path.of("src", "main", "java");
277         for (Path current = sourceFile.getParent(); current != null; current = current.getParent()) {
278             if (current.endsWith(suffix)) {
279                 return current;
280             }
281         }
282         return null;
283     }
284 
285     /**
286      * Resolves the classpath to use for symbol resolution during the javadoc run. Under {@code exec:java} the project
287      * dependencies live on the context classloader (a {@link URLClassLoader}), not on the JVM's
288      * {@code java.class.path}, so both sources are combined.
289      */
290     private static List<File> resolveClasspath() {
291         List<File> classpath = new ArrayList<>();
292         for (ClassLoader cl = Thread.currentThread().getContextClassLoader(); cl != null; cl = cl.getParent()) {
293             if (cl instanceof URLClassLoader) {
294                 for (URL url : ((URLClassLoader) cl).getURLs()) {
295                     if ("file".equals(url.getProtocol())) {
296                         try {
297                             classpath.add(new File(url.toURI()));
298                         } catch (URISyntaxException e) {
299                             classpath.add(new File(url.getPath()));
300                         }
301                     }
302                 }
303             }
304         }
305         for (String element : System.getProperty("java.class.path").split(File.pathSeparator)) {
306             classpath.add(new File(element));
307         }
308         return classpath;
309     }
310 
311     /**
312      * Reads back the intermediate properties file produced by {@link ConfigurationCollectorDoclet} into the list of
313      * maps consumed by the Velocity templates.
314      */
315     static List<Map<String, String>> readDiscoveredKeys(Path intermediateFile) throws Exception {
316         Properties properties = new Properties();
317         try (Reader reader = Files.newBufferedReader(intermediateFile, StandardCharsets.UTF_8)) {
318             properties.load(reader);
319         }
320         int count = Integer.parseInt(properties.getProperty("keys.count", "0"));
321         List<Map<String, String>> discoveredKeys = new ArrayList<>(count);
322         for (int i = 0; i < count; i++) {
323             Map<String, String> entry = new LinkedHashMap<>();
324             for (String field : FIELDS) {
325                 entry.put(field, properties.getProperty("keys." + i + "." + field, ""));
326             }
327             discoveredKeys.add(entry);
328         }
329         return discoveredKeys;
330     }
331 
332     protected void render(List<Map<String, String>> discoveredKeys) throws Exception {
333         Properties properties = new Properties();
334         properties.setProperty("resource.loaders", "classpath");
335         properties.setProperty("resource.loader.classpath.class", ClasspathResourceLoader.class.getName());
336         VelocityEngine velocityEngine = new VelocityEngine();
337         velocityEngine.init(properties);
338 
339         VelocityContext context = new VelocityContext();
340         context.put("keys", discoveredKeys);
341 
342         for (String template : templates) {
343             Path output = outputDirectory.resolve(template);
344             Files.createDirectories(output.getParent());
345             System.out.println("Writing out to " + output);
346             try (Writer fileWriter = new CachingWriter(output, StandardCharsets.UTF_8)) {
347                 velocityEngine.getTemplate(template + ".vm").merge(context, fileWriter);
348             }
349         }
350     }
351 }