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