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}