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}