This is an automated email from the ASF dual-hosted git repository. kwin pushed a commit to branch feature/doclet-for-config-documentation in repository https://gitbox.apache.org/repos/asf/maven-resolver.git
commit cb0080c5dad9722a860489e04c8dc9dae53f5cc9 Author: Konrad Windszus <[email protected]> AuthorDate: Thu Jul 16 10:17:12 2026 +0200 Use custom doclet to extract configuration metadata with the help of javadoc Remove unused "maven" mode --- maven-resolver-tools/pom.xml | 37 +- .../eclipse/aether/tools/CollectConfiguration.java | 477 ++++++--------------- .../aether/tools/ConfigurationCollectorDoclet.java | 392 +++++++++++++++++ 3 files changed, 552 insertions(+), 354 deletions(-) diff --git a/maven-resolver-tools/pom.xml b/maven-resolver-tools/pom.xml index 677b73a59..ba1da627d 100644 --- a/maven-resolver-tools/pom.xml +++ b/maven-resolver-tools/pom.xml @@ -33,7 +33,6 @@ <properties> <javaVersion>17</javaVersion> - <roasterVersion>2.31.1.Final</roasterVersion> </properties> <dependencies> @@ -63,6 +62,12 @@ <artifactId>maven-resolver-named-locks</artifactId> <scope>provided</scope> </dependency> + <dependency> + <groupId>org.apache.maven.resolver</groupId> + <artifactId>maven-resolver-named-locks-ipc</artifactId> + <version>${project.version}</version> + <scope>provided</scope> + </dependency> <dependency> <groupId>org.apache.maven.resolver</groupId> <artifactId>maven-resolver-named-locks-hazelcast</artifactId> @@ -103,6 +108,12 @@ <artifactId>maven-resolver-transport-jetty</artifactId> <scope>provided</scope> </dependency> + <dependency> + <groupId>org.apache.maven.resolver</groupId> + <artifactId>maven-resolver-transport-minio</artifactId> + <version>${project.version}</version> + <scope>provided</scope> + </dependency> <dependency> <groupId>org.apache.maven.resolver</groupId> <artifactId>maven-resolver-transport-wagon</artifactId> @@ -119,26 +130,18 @@ <scope>provided</scope> </dependency> + <!-- Needed so the javadoc doclet can resolve javax.inject.* referenced by the scanned resolver sources. --> <dependency> - <groupId>org.jboss.forge.roaster</groupId> - <artifactId>roaster-api</artifactId> - <version>${roasterVersion}</version> - </dependency> - <dependency> - <groupId>org.jboss.forge.roaster</groupId> - <artifactId>roaster-jdt</artifactId> - <version>${roasterVersion}</version> + <groupId>javax.inject</groupId> + <artifactId>javax.inject</artifactId> + <scope>provided</scope> </dependency> + <dependency> <groupId>org.codehaus.plexus</groupId> <artifactId>plexus-utils</artifactId> <version>${plexusUtils4Version}</version> </dependency> - <dependency> - <groupId>org.ow2.asm</groupId> - <artifactId>asm</artifactId> - <version>9.10.1</version> - </dependency> <dependency> <groupId>org.apache.velocity</groupId> <artifactId>velocity-engine-core</artifactId> @@ -183,8 +186,10 @@ <phase>package</phase> <configuration> <mainClass>org.eclipse.aether.tools.CollectConfiguration</mainClass> + <!-- 'test' scope classpath includes the provided-scope resolver artifacts, which the javadoc doclet + needs to resolve the types referenced by the scanned configuration sources. --> + <classpathScope>test</classpathScope> <arguments> - <argument>--mode=resolver</argument> <argument>--templates=configuration.md</argument> <argument>${basedir}/..</argument> <argument>${basedir}/../target/generated-site/markdown/</argument> @@ -199,8 +204,8 @@ <phase>package</phase> <configuration> <mainClass>org.eclipse.aether.tools.CollectConfiguration</mainClass> + <classpathScope>test</classpathScope> <arguments> - <argument>--mode=resolver</argument> <argument>--templates=configuration.properties,configuration.yaml</argument> <argument>${basedir}/..</argument> <argument>${basedir}/target/</argument> diff --git a/maven-resolver-tools/src/main/java/org/eclipse/aether/tools/CollectConfiguration.java b/maven-resolver-tools/src/main/java/org/eclipse/aether/tools/CollectConfiguration.java index 74e1dabfc..dc5f53678 100644 --- a/maven-resolver-tools/src/main/java/org/eclipse/aether/tools/CollectConfiguration.java +++ b/maven-resolver-tools/src/main/java/org/eclipse/aether/tools/CollectConfiguration.java @@ -18,48 +18,38 @@ */ package org.eclipse.aether.tools; -import java.io.IOException; +import javax.tools.DiagnosticCollector; +import javax.tools.DocumentationTool; +import javax.tools.JavaFileObject; +import javax.tools.StandardJavaFileManager; +import javax.tools.StandardLocation; +import javax.tools.ToolProvider; + +import java.io.File; import java.io.PrintWriter; -import java.io.StringWriter; -import java.io.UncheckedIOException; +import java.io.Reader; import java.io.Writer; +import java.net.URISyntaxException; +import java.net.URL; +import java.net.URLClassLoader; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; import java.util.ArrayList; +import java.util.Arrays; import java.util.Comparator; -import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Properties; import java.util.concurrent.Callable; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import java.util.spi.ToolProvider; +import java.util.stream.Collectors; import java.util.stream.Stream; import org.apache.velocity.VelocityContext; import org.apache.velocity.app.VelocityEngine; import org.apache.velocity.runtime.resource.loader.ClasspathResourceLoader; import org.codehaus.plexus.util.io.CachingWriter; -import org.jboss.forge.roaster.Roaster; -import org.jboss.forge.roaster._shade.org.eclipse.jdt.core.dom.AST; -import org.jboss.forge.roaster._shade.org.eclipse.jdt.core.dom.ASTNode; -import org.jboss.forge.roaster._shade.org.eclipse.jdt.core.dom.Javadoc; -import org.jboss.forge.roaster.model.JavaDoc; -import org.jboss.forge.roaster.model.JavaDocCapable; -import org.jboss.forge.roaster.model.JavaDocTag; -import org.jboss.forge.roaster.model.JavaType; -import org.jboss.forge.roaster.model.impl.JavaDocImpl; -import org.jboss.forge.roaster.model.source.FieldSource; -import org.jboss.forge.roaster.model.source.JavaClassSource; -import org.jboss.forge.roaster.model.source.JavaDocSource; -import org.objectweb.asm.AnnotationVisitor; -import org.objectweb.asm.ClassReader; -import org.objectweb.asm.ClassVisitor; -import org.objectweb.asm.FieldVisitor; -import org.objectweb.asm.Opcodes; import picocli.CommandLine; @CommandLine.Command(name = "docgen", description = "Maven Documentation Generator") @@ -70,17 +60,23 @@ public class CollectConfiguration implements Callable<Integer> { protected static final String KEY = "key"; - public enum Mode { - maven, - resolver - } + /** + * The metadata fields collected per configuration key and written to / read from the intermediate properties file. + */ + protected static final List<String> FIELDS = List.of( + KEY, + "defaultValue", + "fqName", + "description", + "since", + "configurationSource", + "configurationType", + "supportRepoIdSuffix"); - @CommandLine.Option( - names = {"-m", "--mode"}, - arity = "1", - paramLabel = "mode", - description = "The mode of generator (what is being scanned?), supported modes are 'maven', 'resolver'") - protected Mode mode; + /** + * Javadoc block tag marking a constant field as a configuration key. + */ + protected static final String CONFIGURATION_MARKER = "@configurationSource"; @CommandLine.Option( names = {"-t", "--templates"}, @@ -102,44 +98,15 @@ public class CollectConfiguration implements Callable<Integer> { rootDirectory = rootDirectory.toAbsolutePath().normalize(); outputDirectory = outputDirectory.toAbsolutePath().normalize(); - ArrayList<Map<String, String>> discoveredKeys = new ArrayList<>(); - try (Stream<Path> stream = Files.walk(rootDirectory)) { - if (mode == Mode.maven) { - System.out.println("Processing Maven sources from " + rootDirectory); - stream.map(Path::toAbsolutePath) - .filter(p -> p.getFileName().toString().endsWith(".class")) - .filter(p -> p.toString().contains("/target/classes/")) - .forEach(p -> processMavenClass(p, discoveredKeys)); - } else if (mode == Mode.resolver) { - System.out.println("Processing Resolver sources from " + rootDirectory); - stream.map(Path::toAbsolutePath) - .filter(p -> p.getFileName().toString().endsWith(".java")) - .filter(p -> p.toString().contains("/src/main/java/")) - .filter(p -> !p.toString().endsWith("/module-info.java")) - .forEach(p -> processResolverClass(p, discoveredKeys)); - } else { - throw new IllegalStateException("Unsupported mode " + mode); - } - } - - discoveredKeys.sort(Comparator.comparing(e -> e.get(KEY))); - - Properties properties = new Properties(); - properties.setProperty("resource.loaders", "classpath"); - properties.setProperty("resource.loader.classpath.class", ClasspathResourceLoader.class.getName()); - VelocityEngine velocityEngine = new VelocityEngine(); - velocityEngine.init(properties); - - VelocityContext context = new VelocityContext(); - context.put("keys", discoveredKeys); - - for (String template : templates) { - Path output = outputDirectory.resolve(template); - Files.createDirectories(output.getParent()); - System.out.println("Writing out to " + output); - try (Writer fileWriter = new CachingWriter(output, StandardCharsets.UTF_8)) { - velocityEngine.getTemplate(template + ".vm").merge(context, fileWriter); - } + System.out.println("Processing sources from " + rootDirectory); + Path intermediateFile = Files.createTempFile("configuration-keys", ".properties"); + try { + runDoclet(intermediateFile); + List<Map<String, String>> discoveredKeys = readDiscoveredKeys(intermediateFile); + discoveredKeys.sort(Comparator.comparing(e -> e.get(KEY))); + render(discoveredKeys); + } finally { + Files.deleteIfExists(intermediateFile); } return 0; } catch (Exception e) { @@ -148,294 +115,128 @@ public class CollectConfiguration implements Callable<Integer> { } } - protected void processMavenClass(Path path, List<Map<String, String>> discoveredKeys) { - try { - ClassReader classReader = new ClassReader(Files.newInputStream(path)); - classReader.accept( - new ClassVisitor(Opcodes.ASM9) { - @Override - public FieldVisitor visitField( - int fieldAccess, - String fieldName, - String fieldDescriptor, - String fieldSignature, - Object fieldValue) { - return new FieldVisitor(Opcodes.ASM9) { - @Override - public AnnotationVisitor visitAnnotation( - String annotationDescriptor, boolean annotationVisible) { - if (annotationDescriptor.equals("Lorg/apache/maven/api/annotations/Config;")) { - return new AnnotationVisitor(Opcodes.ASM9) { - final Map<String, Object> values = new HashMap<>(); - - @Override - public void visit(String name, Object value) { - values.put(name, value); - } - - @Override - public void visitEnum(String name, String descriptor, String value) { - values.put(name, value); - } - - @Override - public void visitEnd() { - JavaType<?> jtype = parse(Paths.get(path.toString() - .replace("/target/classes/", "/src/main/java/") - .replace(".class", ".java"))); - FieldSource<JavaClassSource> f = - ((JavaClassSource) jtype).getField(fieldName); - - String fqName = null; - String desc = cloneJavadoc(f.getJavaDoc()) - .removeAllTags() - .getFullText() - .replace("*", "\\*"); - String since = getSince(f); - String source = (values.get("source") != null - ? (String) values.get("source") - : "USER_PROPERTIES") // TODO: enum - .toLowerCase(); - source = switch (source) { - case "model" -> "Model properties"; - case "user_properties" -> "User properties"; - default -> source; - }; - String type = (values.get("type") != null - ? (String) values.get("type") - : "java.lang.String"); - if (type.startsWith("java.lang.")) { - type = type.substring("java.lang.".length()); - } else if (type.startsWith("java.util.")) { - type = type.substring("java.util.".length()); - } - discoveredKeys.add(Map.of( - KEY, - fieldValue.toString(), - "defaultValue", - values.get("defaultValue") != null - ? values.get("defaultValue") - .toString() - : "", - "fqName", - nvl(fqName, ""), - "description", - desc, - "since", - nvl(since, ""), - "configurationSource", - source, - "configurationType", - type)); - } - }; - } - return null; - } - }; - } - }, - 0); - } catch (IOException e) { - throw new RuntimeException(e); + /** + * Collects the source files under {@link #rootDirectory} and runs {@link ConfigurationCollectorDoclet} against them, + * having it write the discovered configuration keys into the given intermediate properties file. + */ + protected void runDoclet(Path intermediateFile) throws Exception { + // Only feed javadoc the files that actually declare configuration keys. This keeps the set of types that + // javadoc must resolve small, avoiding failures caused by unrelated sources referencing dependencies that + // are not on this module's classpath (e.g. gson, jetty). + List<File> sourceFiles; + try (Stream<Path> stream = Files.walk(rootDirectory)) { + sourceFiles = stream.map(Path::toAbsolutePath) + .filter(p -> p.getFileName().toString().endsWith(".java")) + .filter(p -> p.toString().contains("/src/main/java/")) + .filter(p -> !p.toString().endsWith("/module-info.java")) + .filter(p -> !p.toString().contains("/maven-resolver-tools/")) + .filter(p -> fileContains(p, CONFIGURATION_MARKER)) + .map(Path::toFile) + .collect(Collectors.toList()); } - } - - protected void processResolverClass(Path path, List<Map<String, String>> discoveredKeys) { - JavaType<?> type = parse(path); - if (type instanceof JavaClassSource javaClassSource) { - javaClassSource.getFields().stream() - .filter(this::hasConfigurationSource) - .forEach(f -> { - Map<String, String> constants = extractConstants(Paths.get(path.toString() - .replace("/src/main/java/", "/target/classes/") - .replace(".java", ".class"))); - - String name = f.getName(); - String key = constants.get(name); - String fqName = f.getOrigin().getCanonicalName() + "." + name; - String configurationType = getConfigurationType(f); - String defValue = getTag(f, "@configurationDefaultValue"); - if (defValue != null && defValue.startsWith("{@link #") && defValue.endsWith("}")) { - // constant "lookup" - String lookupValue = constants.get(defValue.substring(8, defValue.length() - 1)); - if (lookupValue == null) { - // currently we hard fail if javadoc cannot be looked up - // workaround: at cost of redundancy, but declare constants in situ for now - // (in same class) - throw new IllegalArgumentException( - "Could not look up " + defValue + " for configuration " + fqName); - } - defValue = lookupValue; - if ("java.lang.Long".equals(configurationType) - && (defValue.endsWith("l") || defValue.endsWith("L"))) { - defValue = defValue.substring(0, defValue.length() - 1); - } - } - discoveredKeys.add(Map.of( - KEY, - key, - "defaultValue", - nvl(defValue, ""), - "fqName", - fqName, - "description", - cleanseJavadoc(f), - "since", - nvl(getSince(f), ""), - "configurationSource", - getConfigurationSource(f), - "configurationType", - configurationType, - "supportRepoIdSuffix", - toYesNo(getTag(f, "@configurationRepoIdSuffix")))); - }); + if (sourceFiles.isEmpty()) { + throw new IllegalStateException( + "No Java sources declaring configuration keys found under " + rootDirectory); } - } - - protected JavaDocSource<Object> cloneJavadoc(JavaDocSource<?> javaDoc) { - Javadoc jd = (Javadoc) javaDoc.getInternal(); - return new JavaDocImpl<>(javaDoc.getOrigin(), (Javadoc) - ASTNode.copySubtree(AST.newAST(jd.getAST().apiLevel(), false), jd)); - } - protected String cleanseJavadoc(FieldSource<JavaClassSource> javaClassSource) { - JavaDoc<FieldSource<JavaClassSource>> javaDoc = javaClassSource.getJavaDoc(); - String[] text = javaDoc.getFullText().split("\n"); - StringBuilder result = new StringBuilder(); - for (String line : text) { - if (!line.startsWith("@") && !line.trim().isEmpty()) { - result.append(line); + DocumentationTool documentationTool = ToolProvider.getSystemDocumentationTool(); + DiagnosticCollector<JavaFileObject> diagnostics = new DiagnosticCollector<>(); + try (StandardJavaFileManager fileManager = + documentationTool.getStandardFileManager(diagnostics, null, StandardCharsets.UTF_8)) { + // Configure the classpath on the file manager (the -classpath option is not honored when a file manager + // is supplied to getTask()). Note that under exec:java the project dependencies are on the context + // classloader, not on the JVM's java.class.path. + fileManager.setLocation(StandardLocation.CLASS_PATH, resolveClasspath()); + + Iterable<? extends JavaFileObject> compilationUnits = fileManager.getJavaFileObjectsFromFiles(sourceFiles); + + List<String> options = + new ArrayList<>(Arrays.asList("--output", intermediateFile.toString(), "-encoding", "UTF-8")); + + Writer out = new PrintWriter(System.err); + DocumentationTool.DocumentationTask task = documentationTool.getTask( + out, fileManager, diagnostics, ConfigurationCollectorDoclet.class, options, compilationUnits); + boolean ok = task.call(); + out.flush(); + if (!ok) { + diagnostics.getDiagnostics().forEach(d -> System.err.println(d)); + throw new IllegalStateException("Javadoc doclet execution failed"); } } - return cleanseTags(result.toString()); } - protected String cleanseTags(String text) { - // {@code XXX} -> <pre>XXX</pre> - // {@link XXX} -> ??? pre for now - Pattern pattern = Pattern.compile("(\\{@\\w\\w\\w\\w (.+?)})"); - Matcher matcher = pattern.matcher(text); - if (!matcher.find()) { - return text; - } - int prevEnd = 0; - StringBuilder result = new StringBuilder(); - do { - result.append(text, prevEnd, matcher.start(1)); - result.append("<code>"); - result.append(matcher.group(2)); - result.append("</code>"); - prevEnd = matcher.end(1); - } while (matcher.find()); - result.append(text, prevEnd, text.length()); - return result.toString(); - } - - protected JavaType<?> parse(Path path) { + private static boolean fileContains(Path path, String marker) { try { - return Roaster.parse(path.toFile()); - } catch (IOException e) { - throw new UncheckedIOException(e); + return Files.readString(path, StandardCharsets.UTF_8).contains(marker); + } catch (Exception e) { + return false; } } - protected String toYesNo(String value) { - return "yes".equalsIgnoreCase(value) || "true".equalsIgnoreCase(value) ? "Yes" : "No"; - } - - protected String nvl(String string, String def) { - return string == null ? def : string; - } - - protected boolean hasConfigurationSource(JavaDocCapable<?> javaDocCapable) { - return getTag(javaDocCapable, "@configurationSource") != null; - } - - protected String getConfigurationType(JavaDocCapable<?> javaDocCapable) { - String type = getTag(javaDocCapable, "@configurationType"); - if (type != null) { - String linkPrefix = "{@link "; - String linkSuffix = "}"; - if (type.startsWith(linkPrefix) && type.endsWith(linkSuffix)) { - type = type.substring(linkPrefix.length(), type.length() - linkSuffix.length()); - } - String javaLangPackage = "java.lang."; - if (type.startsWith(javaLangPackage)) { - type = type.substring(javaLangPackage.length()); + /** + * Resolves the classpath to use for symbol resolution during the javadoc run. Under {@code exec:java} the project + * dependencies live on the context classloader (a {@link URLClassLoader}), not on the JVM's + * {@code java.class.path}, so both sources are combined. + */ + private static List<File> resolveClasspath() { + List<File> classpath = new ArrayList<>(); + for (ClassLoader cl = Thread.currentThread().getContextClassLoader(); cl != null; cl = cl.getParent()) { + if (cl instanceof URLClassLoader) { + for (URL url : ((URLClassLoader) cl).getURLs()) { + if ("file".equals(url.getProtocol())) { + try { + classpath.add(new File(url.toURI())); + } catch (URISyntaxException e) { + classpath.add(new File(url.getPath())); + } + } + } } } - return nvl(type, "n/a"); - } - - protected String getConfigurationSource(JavaDocCapable<?> javaDocCapable) { - String source = getTag(javaDocCapable, "@configurationSource"); - if ("{@link RepositorySystemSession#getConfigProperties()}".equals(source)) { - return "Session Configuration"; - } else if ("{@link System#getProperty(String,String)}".equals(source)) { - return "Java System Properties"; - } else { - return source; + for (String element : System.getProperty("java.class.path").split(File.pathSeparator)) { + classpath.add(new File(element)); } + return classpath; } - protected String getSince(JavaDocCapable<?> javaDocCapable) { - List<JavaDocTag> tags; - if (javaDocCapable != null) { - if (javaDocCapable instanceof FieldSource<?> fieldSource) { - tags = fieldSource.getJavaDoc().getTags("@since"); - if (tags.isEmpty()) { - return getSince(fieldSource.getOrigin()); - } else { - return tags.get(0).getValue(); - } - } else if (javaDocCapable instanceof JavaClassSource classSource) { - tags = classSource.getJavaDoc().getTags("@since"); - if (!tags.isEmpty()) { - return tags.get(0).getValue(); - } - } + /** + * Reads back the intermediate properties file produced by {@link ConfigurationCollectorDoclet} into the list of + * maps consumed by the Velocity templates. + */ + protected List<Map<String, String>> readDiscoveredKeys(Path intermediateFile) throws Exception { + Properties properties = new Properties(); + try (Reader reader = Files.newBufferedReader(intermediateFile, StandardCharsets.UTF_8)) { + properties.load(reader); } - return null; - } - - protected String getTag(JavaDocCapable<?> javaDocCapable, String tagName) { - List<JavaDocTag> tags; - if (javaDocCapable != null) { - if (javaDocCapable instanceof FieldSource<?> fieldSource) { - tags = fieldSource.getJavaDoc().getTags(tagName); - if (tags.isEmpty()) { - return getTag(fieldSource.getOrigin(), tagName); - } else { - return tags.get(0).getValue(); - } + int count = Integer.parseInt(properties.getProperty("keys.count", "0")); + List<Map<String, String>> discoveredKeys = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + Map<String, String> entry = new LinkedHashMap<>(); + for (String field : FIELDS) { + entry.put(field, properties.getProperty("keys." + i + "." + field, "")); } + discoveredKeys.add(entry); } - return null; + return discoveredKeys; } - protected static final Pattern CONSTANT_PATTERN = Pattern.compile(".*static final.* ([A-Z0-9_]+) = (.*);"); + protected void render(List<Map<String, String>> discoveredKeys) throws Exception { + Properties properties = new Properties(); + properties.setProperty("resource.loaders", "classpath"); + properties.setProperty("resource.loader.classpath.class", ClasspathResourceLoader.class.getName()); + VelocityEngine velocityEngine = new VelocityEngine(); + velocityEngine.init(properties); - protected static final ToolProvider JAVAP = ToolProvider.findFirst("javap").orElseThrow(); + VelocityContext context = new VelocityContext(); + context.put("keys", discoveredKeys); - /** - * Builds "constant table" for one single class. - * <p> - * Limitations: - * - works only for single class (no inherited constants) - * - does not work for fields that are Enum.name() - * - more to come - */ - protected static Map<String, String> extractConstants(Path file) { - StringWriter out = new StringWriter(); - JAVAP.run(new PrintWriter(out), new PrintWriter(System.err), "-constants", file.toString()); - Map<String, String> result = new HashMap<>(); - out.getBuffer().toString().lines().forEach(l -> { - Matcher matcher = CONSTANT_PATTERN.matcher(l); - if (matcher.matches()) { - result.put(matcher.group(1), matcher.group(2)); + for (String template : templates) { + Path output = outputDirectory.resolve(template); + Files.createDirectories(output.getParent()); + System.out.println("Writing out to " + output); + try (Writer fileWriter = new CachingWriter(output, StandardCharsets.UTF_8)) { + velocityEngine.getTemplate(template + ".vm").merge(context, fileWriter); } - }); - return result; + } } } diff --git a/maven-resolver-tools/src/main/java/org/eclipse/aether/tools/ConfigurationCollectorDoclet.java b/maven-resolver-tools/src/main/java/org/eclipse/aether/tools/ConfigurationCollectorDoclet.java new file mode 100644 index 000000000..02f606fe8 --- /dev/null +++ b/maven-resolver-tools/src/main/java/org/eclipse/aether/tools/ConfigurationCollectorDoclet.java @@ -0,0 +1,392 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.eclipse.aether.tools; + +import javax.lang.model.SourceVersion; +import javax.lang.model.element.TypeElement; +import javax.lang.model.element.VariableElement; +import javax.lang.model.util.ElementFilter; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Properties; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import com.sun.source.doctree.DocCommentTree; +import com.sun.source.doctree.DocTree; +import com.sun.source.doctree.LinkTree; +import com.sun.source.doctree.LiteralTree; +import com.sun.source.doctree.SinceTree; +import com.sun.source.doctree.TextTree; +import com.sun.source.doctree.UnknownBlockTagTree; +import com.sun.source.util.DocTrees; +import jdk.javadoc.doclet.Doclet; +import jdk.javadoc.doclet.DocletEnvironment; +import jdk.javadoc.doclet.Reporter; + +/** + * A custom Javadoc {@link Doclet} that scans constant fields for configuration metadata declared via custom Javadoc + * block tags (e.g. {@code @configurationSource}) and writes the discovered keys into an intermediate + * {@link Properties} file. That file is subsequently consumed by {@link CollectConfiguration} to render the + * documentation via Velocity templates. + * <p> + * The intermediate file uses an indexed layout: + * <pre> + * keys.count=N + * keys.0.key=... + * keys.0.description=... + * ... + * </pre> + */ +public class ConfigurationCollectorDoclet implements Doclet { + + private Path output; + private DocTrees docTrees; + + @Override + public void init(Locale locale, Reporter reporter) { + // no state to initialize + } + + @Override + public String getName() { + return "ConfigurationCollector"; + } + + @Override + public Set<? extends Option> getSupportedOptions() { + return Set.of(new SimpleOption( + List.of("--output", "-o"), + 1, + "The intermediate properties file to write discovered keys to", + "<file>", + args -> output = Paths.get(args.get(0)))); + } + + @Override + public SourceVersion getSupportedSourceVersion() { + return SourceVersion.latest(); + } + + @Override + public boolean run(DocletEnvironment environment) { + try { + return doRun(environment); + } catch (RuntimeException e) { + e.printStackTrace(System.err); + return false; + } + } + + private boolean doRun(DocletEnvironment environment) { + if (output == null) { + throw new IllegalStateException("Missing required --output option"); + } + docTrees = environment.getDocTrees(); + List<Map<String, String>> discoveredKeys = new ArrayList<>(); + + Set<TypeElement> types = ElementFilter.typesIn(environment.getIncludedElements()); + for (TypeElement type : types) { + for (VariableElement field : ElementFilter.fieldsIn(type.getEnclosedElements())) { + if (field.getConstantValue() == null) { + continue; + } + DocCommentTree docComment = docTrees.getDocCommentTree(field); + processField(type, field, docComment, discoveredKeys); + } + } + + writeProperties(discoveredKeys); + return true; + } + + private void processField( + TypeElement type, VariableElement field, DocCommentTree docComment, List<Map<String, String>> discovered) { + if (docComment == null) { + return; + } + Map<String, List<? extends DocTree>> blockTags = collectBlockTags(docComment); + if (!blockTags.containsKey("configurationSource")) { + return; + } + + String configurationType = getConfigurationType(renderContent(blockTags.get("configurationType"))); + String defValue = resolveDefaultValue(type, blockTags.get("configurationDefaultValue")); + + Map<String, String> entry = new LinkedHashMap<>(); + entry.put("key", String.valueOf(field.getConstantValue())); + entry.put("defaultValue", nvl(defValue, "")); + entry.put("fqName", type.getQualifiedName() + "." + field.getSimpleName()); + entry.put("description", cleanseJavadoc(renderContent(docComment.getFullBody()))); + entry.put("since", nvl(getSince(type, docComment), "")); + entry.put("configurationSource", getConfigurationSource(renderContent(blockTags.get("configurationSource")))); + entry.put("configurationType", configurationType); + entry.put("supportRepoIdSuffix", toYesNo(renderContent(blockTags.get("configurationRepoIdSuffix")))); + discovered.add(entry); + } + + private void writeProperties(List<Map<String, String>> discoveredKeys) { + Properties properties = new Properties(); + properties.setProperty("keys.count", String.valueOf(discoveredKeys.size())); + for (int i = 0; i < discoveredKeys.size(); i++) { + Map<String, String> entry = discoveredKeys.get(i); + for (Map.Entry<String, String> field : entry.entrySet()) { + properties.setProperty("keys." + i + "." + field.getKey(), field.getValue()); + } + } + try { + if (output.getParent() != null) { + Files.createDirectories(output.getParent()); + } + try (Writer writer = new BufferedWriter(Files.newBufferedWriter(output, StandardCharsets.UTF_8))) { + properties.store(writer, "Generated by ConfigurationCollectorDoclet - DO NOT EDIT"); + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + // --- Javadoc extraction helpers ------------------------------------------------------------------------------- + + private Map<String, List<? extends DocTree>> collectBlockTags(DocCommentTree docComment) { + Map<String, List<? extends DocTree>> result = new LinkedHashMap<>(); + for (DocTree tag : docComment.getBlockTags()) { + if (tag instanceof UnknownBlockTagTree) { + UnknownBlockTagTree unknown = (UnknownBlockTagTree) tag; + result.put(unknown.getTagName(), unknown.getContent()); + } + } + return result; + } + + private String resolveDefaultValue(TypeElement type, List<? extends DocTree> content) { + if (content == null || content.isEmpty()) { + return null; + } + for (DocTree tree : content) { + if (tree instanceof LinkTree) { + LinkTree link = (LinkTree) tree; + if (link.getReference() != null) { + String signature = link.getReference().getSignature(); + String constantName = signature.substring(signature.indexOf('#') + 1); + String value = lookupConstant(type, constantName); + if (value == null) { + // hard fail as in the original implementation: default value constants must be resolvable + throw new IllegalArgumentException("Could not look up {@link #" + constantName + + "} for configuration " + type.getQualifiedName()); + } + return value; + } + } + } + return renderContent(content); + } + + private String lookupConstant(TypeElement type, String constantName) { + for (VariableElement field : ElementFilter.fieldsIn(type.getEnclosedElements())) { + if (field.getSimpleName().contentEquals(constantName) && field.getConstantValue() != null) { + return String.valueOf(field.getConstantValue()); + } + } + return null; + } + + private String renderContent(List<? extends DocTree> content) { + if (content == null) { + return null; + } + StringBuilder sb = new StringBuilder(); + for (DocTree tree : content) { + sb.append(renderTree(tree)); + } + return sb.toString().trim(); + } + + private String renderTree(DocTree tree) { + switch (tree.getKind()) { + case TEXT: + return ((TextTree) tree).getBody(); + case LINK: + case LINK_PLAIN: + LinkTree link = (LinkTree) tree; + String ref = link.getReference() != null ? link.getReference().getSignature() : ""; + return "{@link " + ref + "}"; + case CODE: + return "{@code " + ((LiteralTree) tree).getBody().getBody() + "}"; + case LITERAL: + return "{@literal " + ((LiteralTree) tree).getBody().getBody() + "}"; + default: + return tree.toString(); + } + } + + private String getSince(TypeElement type, DocCommentTree docComment) { + String since = getSinceTag(docComment); + if (since == null && type != null) { + // fall back to the enclosing type's @since + since = getSinceTag(docTrees.getDocCommentTree(type)); + } + return since; + } + + private String getSinceTag(DocCommentTree docComment) { + if (docComment == null) { + return null; + } + for (DocTree tag : docComment.getBlockTags()) { + if (tag instanceof SinceTree) { + return renderContent(((SinceTree) tag).getBody()); + } + } + return null; + } + + private String getConfigurationType(String type) { + if (type != null) { + String linkPrefix = "{@link "; + String linkSuffix = "}"; + if (type.startsWith(linkPrefix) && type.endsWith(linkSuffix)) { + type = type.substring(linkPrefix.length(), type.length() - linkSuffix.length()); + } + String javaLangPackage = "java.lang."; + if (type.startsWith(javaLangPackage)) { + type = type.substring(javaLangPackage.length()); + } + } + return nvl(type, "n/a"); + } + + private String getConfigurationSource(String source) { + if ("{@link RepositorySystemSession#getConfigProperties()}".equals(source)) { + return "Session Configuration"; + } else if ("{@link System#getProperty(String,String)}".equals(source)) { + return "Java System Properties"; + } else { + return source; + } + } + + private String cleanseJavadoc(String fullText) { + String[] lines = fullText.split("\n"); + StringBuilder result = new StringBuilder(); + for (String line : lines) { + if (!line.startsWith("@") && !line.trim().isEmpty()) { + result.append(line); + } + } + return cleanseTags(result.toString()); + } + + private String cleanseTags(String text) { + // {@code XXX} -> <code>XXX</code> + // {@link XXX} -> <code>XXX</code> + Pattern pattern = Pattern.compile("(\\{@\\w\\w\\w\\w (.+?)})"); + Matcher matcher = pattern.matcher(text); + if (!matcher.find()) { + return text; + } + int prevEnd = 0; + StringBuilder result = new StringBuilder(); + do { + result.append(text, prevEnd, matcher.start(1)); + result.append("<code>"); + result.append(matcher.group(2)); + result.append("</code>"); + prevEnd = matcher.end(1); + } while (matcher.find()); + result.append(text, prevEnd, text.length()); + return result.toString(); + } + + private String toYesNo(String value) { + return "yes".equalsIgnoreCase(value) || "true".equalsIgnoreCase(value) ? "Yes" : "No"; + } + + private String nvl(String string, String def) { + return string == null ? def : string; + } + + /** + * Minimal {@link Option} implementation. + */ + private static final class SimpleOption implements Option { + private final List<String> names; + private final int argumentCount; + private final String description; + private final String parameters; + private final java.util.function.Consumer<List<String>> processor; + + SimpleOption( + List<String> names, + int argumentCount, + String description, + String parameters, + java.util.function.Consumer<List<String>> processor) { + this.names = names; + this.argumentCount = argumentCount; + this.description = description; + this.parameters = parameters; + this.processor = processor; + } + + @Override + public int getArgumentCount() { + return argumentCount; + } + + @Override + public String getDescription() { + return description; + } + + @Override + public Kind getKind() { + return Kind.STANDARD; + } + + @Override + public List<String> getNames() { + return names; + } + + @Override + public String getParameters() { + return parameters; + } + + @Override + public boolean process(String option, List<String> arguments) { + processor.accept(arguments); + return true; + } + } +}
