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;
+        }
+    }
+}


Reply via email to