001/*
002 * Licensed to the Apache Software Foundation (ASF) under one
003 * or more contributor license agreements.  See the NOTICE file
004 * distributed with this work for additional information
005 * regarding copyright ownership.  The ASF licenses this file
006 * to you under the Apache License, Version 2.0 (the
007 * "License"); you may not use this file except in compliance
008 * with the License.  You may obtain a copy of the License at
009 *
010 *   http://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing,
013 * software distributed under the License is distributed on an
014 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
015 * KIND, either express or implied.  See the License for the
016 * specific language governing permissions and limitations
017 * under the License.
018 */
019package org.eclipse.aether.tools;
020
021import javax.tools.DiagnosticCollector;
022import javax.tools.DocumentationTool;
023import javax.tools.JavaFileObject;
024import javax.tools.StandardJavaFileManager;
025import javax.tools.StandardLocation;
026import javax.tools.ToolProvider;
027
028import java.io.File;
029import java.io.IOException;
030import java.io.PrintWriter;
031import java.io.Reader;
032import java.io.Writer;
033import java.net.URI;
034import java.net.URISyntaxException;
035import java.net.URL;
036import java.net.URLClassLoader;
037import java.nio.charset.StandardCharsets;
038import java.nio.file.Files;
039import java.nio.file.Path;
040import java.util.ArrayList;
041import java.util.Arrays;
042import java.util.Comparator;
043import java.util.LinkedHashMap;
044import java.util.List;
045import java.util.Map;
046import java.util.Objects;
047import java.util.Properties;
048import java.util.concurrent.Callable;
049import java.util.stream.Collectors;
050import java.util.stream.Stream;
051
052import org.apache.velocity.VelocityContext;
053import org.apache.velocity.app.VelocityEngine;
054import org.apache.velocity.runtime.resource.loader.ClasspathResourceLoader;
055import org.codehaus.plexus.util.io.CachingWriter;
056import picocli.CommandLine;
057
058/**
059 * This tool is used both from <a href="https://github.com/apache/maven-resolver/blob/79d102b66235f33ad1e6134e18451ac3ee91b44a/maven-resolver-tools/pom.xml#L185">Resolver</a>
060 * as well as from <a href="https://github.com/apache/maven/blob/7aa3c8a37b091a5a86d3dae3a7d99ce910fd6caa/pom.xml#L1043">Maven</a>
061 * 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.
062 * It relies on javadoc with a custom doclet to extract the configuration keys from the source files.
063 * The doclet writes the discovered keys into an intermediate properties file, which is then read back and used to render the Velocity templates.
064 */
065@CommandLine.Command(name = "docgen", description = "Configuration Documentation Generator")
066public class CollectConfiguration implements Callable<Integer> {
067    public static void main(String[] args) {
068        new CommandLine(new CollectConfiguration()).execute(args);
069    }
070
071    protected static final String KEY = "key";
072
073    /**
074     * The metadata fields collected per configuration key and written to / read from the intermediate properties file.
075     */
076    protected static final List<String> FIELDS = List.of(
077            KEY,
078            "defaultValue",
079            "fqName",
080            "description",
081            "since",
082            "configurationSource",
083            "configurationType",
084            "configurationTypeJavadocUrl",
085            "supportRepoIdSuffix",
086            "deprecated");
087
088    /**
089     * Javadoc block tag marking a constant field as a configuration key.
090     */
091    protected static final String CONFIGURATION_MARKER = "@configurationSource";
092
093    /**
094     * Text marker used to pre-select the source files to feed the doclet when scanning Maven sources. Maven declares
095     * configuration keys via the {@code org.apache.maven.api.annotations.Config} annotation.
096     */
097    protected static final String MAVEN_CONFIGURATION_MARKER = "@Config";
098
099    /**
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}