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            "see");
088
089    /**
090     * Javadoc block tag marking a constant field as a configuration key.
091     */
092    protected static final String CONFIGURATION_MARKER = "@configurationSource";
093
094    /**
095     * Text marker used to pre-select the source files to feed the doclet when scanning Maven sources. Maven declares
096     * configuration keys via the {@code org.apache.maven.api.annotations.Config} annotation.
097     */
098    protected static final String MAVEN_CONFIGURATION_MARKER = "@Config";
099
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}