This is an automated email from the ASF dual-hosted git repository.

kwin pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/maven-resolver.git


The following commit(s) were added to refs/heads/master by this push:
     new 46da42c5f Use custom doclet to extract configuration metadata with the 
help of javadoc (#1965)
46da42c5f is described below

commit 46da42c5f285f9561395b9bf38e077e95b9a9766
Author: Konrad Windszus <[email protected]>
AuthorDate: Thu Jul 16 21:24:16 2026 +0200

    Use custom doclet to extract configuration metadata with the help of 
javadoc (#1965)
    
    Allow usage of enum values as types and for default values within 
configuration
---
 .../eclipse/aether/ConfigurationProperties.java    |   2 +-
 maven-resolver-tools/pom.xml                       |  47 +-
 .../eclipse/aether/tools/CollectConfiguration.java | 491 ++++++----------
 .../aether/tools/ConfigurationCollectorDoclet.java | 629 +++++++++++++++++++++
 .../tools/ConfigurationCollectorDocletTest.java    | 138 +++++
 .../aether/sample/SampleConfigurationKeys.java     |  78 +++
 6 files changed, 1040 insertions(+), 345 deletions(-)

diff --git 
a/maven-resolver-api/src/main/java/org/eclipse/aether/ConfigurationProperties.java
 
b/maven-resolver-api/src/main/java/org/eclipse/aether/ConfigurationProperties.java
index 1454a5ab8..2ba552d1e 100644
--- 
a/maven-resolver-api/src/main/java/org/eclipse/aether/ConfigurationProperties.java
+++ 
b/maven-resolver-api/src/main/java/org/eclipse/aether/ConfigurationProperties.java
@@ -531,7 +531,7 @@ public final class ConfigurationProperties {
     /**
      * The maximum and preferred HTTP version. Some transporters transparently 
fall back to lower versions if remote server does not
      * support the requested version, while other may just simply fail the 
request.
-     * Value must be a {@link HttpVersion} enum value or its String 
representation. Default is {@link #DEFAULT_HTTP_VERSION}.
+     * Value must be a {@link HttpVersion} enum value or its String 
representation.
      *
      * @configurationSource {@link 
RepositorySystemSession#getConfigProperties()}
      * @configurationType {@link ConfigurationProperties.HttpVersion}
diff --git a/maven-resolver-tools/pom.xml b/maven-resolver-tools/pom.xml
index d1ebde2bb..ec89b44fe 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>
@@ -166,6 +169,18 @@
       <artifactId>slf4j-nop</artifactId>
       <scope>runtime</scope>
     </dependency>
+
+    <!-- Test -->
+    <dependency>
+      <groupId>org.junit.jupiter</groupId>
+      <artifactId>junit-jupiter-api</artifactId>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.junit.jupiter</groupId>
+      <artifactId>junit-jupiter-engine</artifactId>
+      <scope>test</scope>
+    </dependency>
   </dependencies>
 
   <build>
@@ -202,6 +217,9 @@
             <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>
@@ -218,6 +236,7 @@
             <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>
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..387bcc952 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,51 +18,49 @@
  */
 package org.eclipse.aether.tools;
 
+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.IOException;
 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;
 
[email protected](name = "docgen", description = "Maven Documentation 
Generator")
+/**
+ * This tool is used both from <a 
href="https://github.com/apache/maven-resolver/blob/79d102b66235f33ad1e6134e18451ac3ee91b44a/maven-resolver-tools/pom.xml#L185";>Resolver</a>
+ * as well as from <a 
href="https://github.com/apache/maven/blob/7aa3c8a37b091a5a86d3dae3a7d99ce910fd6caa/pom.xml#L1043";>Maven</a>
+ * 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.
+ * It relies on javadoc with a custom doclet to extract the configuration keys 
from the source files.
+ * The doclet writes the discovered keys into an intermediate properties file, 
which is then read back and used to render the Velocity templates.
+ */
[email protected](name = "docgen", description = "Configuration 
Documentation Generator")
 public class CollectConfiguration implements Callable<Integer> {
     public static void main(String[] args) {
         new CommandLine(new CollectConfiguration()).execute(args);
@@ -70,6 +68,33 @@ public class CollectConfiguration implements 
Callable<Integer> {
 
     protected static final String KEY = "key";
 
+    /**
+     * 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");
+
+    /**
+     * Javadoc block tag marking a constant field as a configuration key.
+     */
+    protected static final String CONFIGURATION_MARKER = 
"@configurationSource";
+
+    /**
+     * Text marker used to pre-select the source files to feed the doclet when 
scanning Maven sources. Maven declares
+     * configuration keys via the {@code 
org.apache.maven.api.annotations.Config} annotation.
+     */
+    protected static final String MAVEN_CONFIGURATION_MARKER = "@Config";
+
+    /**
+     * The mode of the generator, i.e. what kind of sources are being scanned.
+     */
     public enum Mode {
         maven,
         resolver
@@ -80,7 +105,7 @@ public class CollectConfiguration implements 
Callable<Integer> {
             arity = "1",
             paramLabel = "mode",
             description = "The mode of generator (what is being scanned?), 
supported modes are 'maven', 'resolver'")
-    protected Mode mode;
+    protected Mode mode = Mode.resolver;
 
     @CommandLine.Option(
             names = {"-t", "--templates"},
@@ -102,44 +127,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 +144,129 @@ 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).
+        String marker = mode == Mode.maven ? MAVEN_CONFIGURATION_MARKER : 
CONFIGURATION_MARKER;
+        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, 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(), "--mode", 
mode.name(), "-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());
+            return Files.readString(path, 
StandardCharsets.UTF_8).contains(marker);
         } catch (IOException e) {
-            throw new UncheckedIOException(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 static final ToolProvider JAVAP = 
ToolProvider.findFirst("javap").orElseThrow();
-
-    /**
-     * 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));
+    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);
+
+        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);
             }
-        });
-        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..75a7401e4
--- /dev/null
+++ 
b/maven-resolver-tools/src/main/java/org/eclipse/aether/tools/ConfigurationCollectorDoclet.java
@@ -0,0 +1,629 @@
+/*
+ * 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.AnnotationMirror;
+import javax.lang.model.element.AnnotationValue;
+import javax.lang.model.element.Element;
+import javax.lang.model.element.ElementKind;
+import javax.lang.model.element.ExecutableElement;
+import javax.lang.model.element.TypeElement;
+import javax.lang.model.element.VariableElement;
+import javax.lang.model.util.ElementFilter;
+import javax.tools.Diagnostic;
+
+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.tree.ExpressionTree;
+import com.sun.source.tree.IdentifierTree;
+import com.sun.source.tree.MemberSelectTree;
+import com.sun.source.tree.VariableTree;
+import com.sun.source.util.DocTreePath;
+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 {
+
+    /**
+     * Fully qualified name of the Maven annotation that marks a configuration 
key when scanning Maven sources.
+     */
+    private static final String MAVEN_CONFIG_ANNOTATION = 
"org.apache.maven.api.annotations.Config";
+
+    private Reporter reporter;
+
+    private Path output;
+
+    /**
+     * The scanning mode; either {@code resolver} (Javadoc block tags) or 
{@code maven} (the {@code @Config}
+     * annotation). Defaults to {@code resolver}.
+     */
+    private String mode = "resolver";
+
+    private DocTrees docTrees;
+
+    @Override
+    public void init(Locale locale, Reporter reporter) {
+        this.reporter = reporter;
+    }
+
+    @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))),
+                new SimpleOption(
+                        List.of("--mode", "-m"),
+                        1,
+                        "The scanning mode, either 'resolver' or 'maven'",
+                        "<mode>",
+                        args -> mode = args.get(0)));
+    }
+
+    @Override
+    public SourceVersion getSupportedSourceVersion() {
+        return SourceVersion.latest();
+    }
+
+    @Override
+    public boolean run(DocletEnvironment environment) {
+        try {
+            return doRun(environment);
+        } catch (RuntimeException e) {
+            reporter.print(Diagnostic.Kind.ERROR, "Error running 
ConfigurationCollectorDoclet: " + e.getMessage());
+            e.printStackTrace(reporter.getStandardWriter());
+            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);
+                if ("maven".equals(mode)) {
+                    processMavenField(type, field, docComment, discoveredKeys);
+                } else if ("resolver".equals(mode)) {
+                    processResolverField(type, field, docComment, 
discoveredKeys);
+                } else {
+                    throw new IllegalArgumentException("Unknown mode: " + 
mode);
+                }
+            }
+        }
+
+        writeProperties(discoveredKeys);
+        return true;
+    }
+
+    private void processResolverField(
+            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(extractClassLink(field, docComment, 
blockTags.get("configurationType")));
+        String defValue = resolveDefaultValue(type, field, docComment, 
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);
+    }
+
+    /**
+     * Processes a constant field declared in Maven sources. Maven declares 
configuration keys via the
+     * {@code org.apache.maven.api.annotations.Config} annotation (rather than 
the custom Javadoc block tags used by
+     * Resolver), so the metadata is read from that annotation's attributes.
+     */
+    // TODO: move to Maven repository module and use the Maven annotation type 
directly (currently we don't have a
+    // dependency on Maven API)
+    private void processMavenField(
+            TypeElement type, VariableElement field, DocCommentTree 
docComment, List<Map<String, String>> discovered) {
+        AnnotationMirror config = getAnnotation(field, 
MAVEN_CONFIG_ANNOTATION);
+        if (config == null) {
+            return;
+        }
+
+        String source = "USER_PROPERTIES";
+        String defaultValue = "";
+        String configurationType = "java.lang.String";
+        for (Map.Entry<? extends ExecutableElement, ? extends AnnotationValue> 
attribute :
+                config.getElementValues().entrySet()) {
+            String name = attribute.getKey().getSimpleName().toString();
+            Object value = attribute.getValue().getValue();
+            switch (name) {
+                case "source":
+                    source = value instanceof VariableElement
+                            ? ((VariableElement) 
value).getSimpleName().toString()
+                            : String.valueOf(value);
+                    break;
+                case "defaultValue":
+                    defaultValue = String.valueOf(value);
+                    break;
+                case "type":
+                    configurationType = String.valueOf(value);
+                    break;
+                default:
+                    break;
+            }
+        }
+
+        source = source.toLowerCase(Locale.ROOT);
+        switch (source) {
+            case "model":
+                source = "Model properties";
+                break;
+            case "user_properties":
+                source = "User properties";
+                break;
+            case "system_properties":
+                source = "System properties";
+                break;
+            default:
+                break;
+        }
+
+        if (configurationType.startsWith("java.lang.")) {
+            configurationType = 
configurationType.substring("java.lang.".length());
+        } else if (configurationType.startsWith("java.util.")) {
+            configurationType = 
configurationType.substring("java.util.".length());
+        }
+
+        String description = docComment != null ? 
renderContent(docComment.getFullBody()) : "";
+        description = description.replace("*", "\\*");
+
+        Map<String, String> entry = new LinkedHashMap<>();
+        entry.put("key", String.valueOf(field.getConstantValue()));
+        entry.put("defaultValue", nvl(defaultValue, ""));
+        entry.put("fqName", "");
+        entry.put("description", nvl(description, ""));
+        entry.put("since", nvl(getSince(type, docComment), ""));
+        entry.put("configurationSource", source);
+        entry.put("configurationType", configurationType);
+        entry.put("supportRepoIdSuffix", "");
+        discovered.add(entry);
+    }
+
+    private AnnotationMirror getAnnotation(Element element, String fqName) {
+        for (AnnotationMirror annotation : element.getAnnotationMirrors()) {
+            Element annotationElement = 
annotation.getAnnotationType().asElement();
+            if (annotationElement instanceof TypeElement
+                    && ((TypeElement) 
annotationElement).getQualifiedName().contentEquals(fqName)) {
+                return annotation;
+            }
+        }
+        return null;
+    }
+
+    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 = 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,
+            VariableElement contextField,
+            DocCommentTree docComment,
+            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();
+                    // resolve the referenced constant using the fully 
qualified signature, so that references
+                    // to constants declared in other types (e.g. {@link 
OtherType#CONSTANT}) can be resolved
+                    VariableElement referenced = 
resolveReferencedField(contextField, docComment, link);
+                    String value = referenced != null
+                            ? lookupConstant(referenced)
+                            : lookupConstant(type, 
signature.substring(signature.indexOf('#') + 1));
+                    if (value == null) {
+                        // hard fail as in the original implementation: 
default value constants must be resolvable
+                        throw new IllegalArgumentException("Could not look up 
{@link " + signature
+                                + "} for configuration " + 
type.getQualifiedName());
+                    }
+                    return value;
+                }
+            }
+        }
+        return renderContent(content);
+    }
+
+    /**
+     * Resolves the {@link VariableElement} a {@code {@link ...}} reference 
points to using the fully qualified
+     * signature (so references into other types are supported). Returns 
{@code null} if the reference cannot be
+     * resolved to a field.
+     */
+    private VariableElement resolveReferencedField(
+            VariableElement contextField, DocCommentTree docComment, LinkTree 
link) {
+        if (contextField == null || docComment == null) {
+            return null;
+        }
+        DocTreePath rootPath = new DocTreePath(docTrees.getPath(contextField), 
docComment);
+        DocTreePath refPath = DocTreePath.getPath(rootPath, 
link.getReference());
+        if (refPath == null) {
+            return null;
+        }
+        Element element = docTrees.getElement(refPath);
+        return element instanceof VariableElement ? (VariableElement) element 
: null;
+    }
+
+    private String lookupConstant(TypeElement type, String constantName) {
+        for (VariableElement field : 
ElementFilter.fieldsIn(type.getEnclosedElements())) {
+            if (field.getSimpleName().contentEquals(constantName)) {
+                String value = lookupConstant(field);
+                if (value != null) {
+                    return value;
+                }
+            }
+        }
+        return null;
+    }
+
+    private String lookupConstant(VariableElement field) {
+        if (field.getConstantValue() != null) {
+            Object value = field.getConstantValue();
+            if (value instanceof String) {
+                return "\"" + value + "\"";
+            } else {
+                return String.valueOf(field.getConstantValue());
+            }
+        }
+        // enum constants don't expose a constant value, fall back to the enum 
value's name
+        if (field.getKind() == ElementKind.ENUM_CONSTANT) {
+            return field.getSimpleName().toString();
+        }
+        // the field may indirectly reference an enum variable, e.g. 
"SomeEnum.VALUE";
+        // resolve it from the field's initializer
+        return resolveEnumReference(field);
+    }
+
+    /**
+     * Resolves an enum constant that a field is initialized with, including 
the enum type in the result
+     * (e.g. a field declared as {@code SomeEnum FOO = SomeEnum.VALUE} 
resolves to {@code SomeEnum.VALUE}).
+     * Returns {@code null} if the field's initializer is not a simple enum 
reference.
+     */
+    private String resolveEnumReference(VariableElement field) {
+        if (!(docTrees.getTree(field) instanceof VariableTree variableTree)) {
+            return null;
+        }
+        ExpressionTree initializer = variableTree.getInitializer();
+        String enumConstant = null;
+        if (initializer instanceof MemberSelectTree memberSelectTree) {
+            // e.g. SomeEnum.VALUE -> VALUE
+            enumConstant = memberSelectTree.getIdentifier().toString();
+        } else if (initializer instanceof IdentifierTree identifierTree) {
+            // e.g. statically imported VALUE -> VALUE
+            enumConstant = identifierTree.getName().toString();
+        }
+        if (enumConstant == null) {
+            return null;
+        }
+        return enumConstant;
+    }
+
+    private String extractClassLink(
+            VariableElement contextField, DocCommentTree docComment, List<? 
extends DocTree> content) {
+        if (content == null || content.isEmpty()) {
+            throw new IllegalArgumentException("Missing content for 
@configurationDefaultValue");
+        }
+        for (DocTree tree : content) {
+            // just use the first link, ignore any other content (e.g. text) 
in the tag
+            if (tree instanceof LinkTree link) {
+                String signature = link.getReference().getSignature();
+                if (signature.contains("#")) {
+                    throw new IllegalArgumentException(
+                            "Expected a class link in 
@configurationDefaultValue, but got a member reference: "
+                                    + signature);
+                }
+                return resolveReferencedType(contextField, docComment, link, 
signature);
+            }
+        }
+        throw new IllegalArgumentException("No valid {@link ...} reference 
found in @configurationDefaultValue");
+    }
+
+    /**
+     * Resolves the fully qualified class name a {@code {@link ...}} class 
reference points to (so that simple names
+     * declared via imports are expanded). Falls back to the raw signature if 
the reference cannot be resolved to a
+     * type.
+     */
+    private String resolveReferencedType(
+            VariableElement contextField, DocCommentTree docComment, LinkTree 
link, String signature) {
+        if (contextField == null || docComment == null) {
+            return signature;
+        }
+        DocTreePath rootPath = new DocTreePath(docTrees.getPath(contextField), 
docComment);
+        DocTreePath refPath = DocTreePath.getPath(rootPath, 
link.getReference());
+        if (refPath == null) {
+            return signature;
+        }
+        Element element = docTrees.getElement(refPath);
+        return element instanceof TypeElement
+                ? ((TypeElement) element).getQualifiedName().toString()
+                : signature;
+    }
+
+    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 sinceTree) {
+                return renderContent(sinceTree.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;
+        }
+    }
+}
diff --git 
a/maven-resolver-tools/src/test/java/org/eclipse/aether/tools/ConfigurationCollectorDocletTest.java
 
b/maven-resolver-tools/src/test/java/org/eclipse/aether/tools/ConfigurationCollectorDocletTest.java
new file mode 100644
index 000000000..238360b08
--- /dev/null
+++ 
b/maven-resolver-tools/src/test/java/org/eclipse/aether/tools/ConfigurationCollectorDocletTest.java
@@ -0,0 +1,138 @@
+/*
+ * 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.tools.DocumentationTool;
+import javax.tools.JavaFileObject;
+import javax.tools.StandardJavaFileManager;
+import javax.tools.ToolProvider;
+
+import java.io.InputStream;
+import java.io.Reader;
+import java.io.StringWriter;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class ConfigurationCollectorDocletTest {
+
+    /**
+     * Classpath location of the fixture source declaring configuration keys 
of type {@link Boolean}, {@link String}
+     * and a custom enum, using the same Javadoc block tags that the doclet 
extracts.
+     */
+    private static final String FIXTURE = 
"/org/eclipse/aether/sample/SampleConfigurationKeys.java";
+
+    @Test
+    void extractsBooleanStringAndEnumConfigurations(@TempDir Path tempDir) 
throws Exception {
+        Path sourceDir = 
Files.createDirectories(tempDir.resolve("org/eclipse/aether/sample"));
+        Path sourceFile = sourceDir.resolve("SampleConfigurationKeys.java");
+        try (InputStream in = 
ConfigurationCollectorDocletTest.class.getResourceAsStream(FIXTURE)) {
+            assertNotNull(in, "fixture source not found on classpath: " + 
FIXTURE);
+            Files.copy(in, sourceFile);
+        }
+        Path output = tempDir.resolve("configuration-keys.properties");
+
+        runDoclet(sourceFile, output);
+
+        Map<String, Map<String, String>> keys = readKeys(output);
+        assertEquals(4, keys.size(), "expected four configuration keys");
+
+        Map<String, String> bool = keys.get("sample.bool");
+        assertNotNull(bool, "boolean key missing");
+        assertEquals("Boolean", bool.get("configurationType"));
+        assertEquals("true", bool.get("defaultValue"));
+        assertEquals("1.2.3", bool.get("since"));
+        assertEquals("No", bool.get("supportRepoIdSuffix"));
+        assertEquals("Java System Properties", 
bool.get("configurationSource"));
+        assertEquals("A boolean flag.", bool.get("description"));
+
+        Map<String, String> string = keys.get("sample.string");
+        assertNotNull(string, "string key missing");
+        assertEquals("String", string.get("configurationType"));
+        assertEquals("\"hello\"", string.get("defaultValue"));
+        assertEquals("Yes", string.get("supportRepoIdSuffix"));
+        assertEquals("", string.get("since"), "no @since expected");
+
+        Map<String, String> enumKey = keys.get("sample.enum");
+        assertNotNull(enumKey, "enum key missing");
+        
assertEquals("org.eclipse.aether.sample.SampleConfigurationKeys.SampleEnum", 
enumKey.get("configurationType"));
+        assertEquals("VALUE_A", enumKey.get("defaultValue"));
+        // no @configurationRepoIdSuffix -> defaults to "No"
+        assertEquals("No", enumKey.get("supportRepoIdSuffix"));
+
+        Map<String, String> enum2Key = keys.get("sample.enum2");
+        assertNotNull(enum2Key, "enum key missing");
+        
assertEquals("org.eclipse.aether.sample.SampleConfigurationKeys.SampleEnum", 
enum2Key.get("configurationType"));
+        assertEquals("VALUE_B", enum2Key.get("defaultValue"));
+        // no @configurationRepoIdSuffix -> defaults to "No"
+        assertEquals("No", enum2Key.get("supportRepoIdSuffix"));
+    }
+
+    private static void runDoclet(Path sourceFile, Path output) throws 
Exception {
+        DocumentationTool documentationTool = 
ToolProvider.getSystemDocumentationTool();
+        try (StandardJavaFileManager fileManager =
+                documentationTool.getStandardFileManager(null, null, 
StandardCharsets.UTF_8)) {
+            Iterable<? extends JavaFileObject> units =
+                    
fileManager.getJavaFileObjectsFromFiles(List.of(sourceFile.toFile()));
+            List<String> options = List.of("--output", output.toString(), 
"-encoding", "UTF-8");
+            StringWriter out = new StringWriter();
+            DocumentationTool.DocumentationTask task = 
documentationTool.getTask(
+                    out, fileManager, null, 
ConfigurationCollectorDoclet.class, options, units);
+            assertTrue(task.call(), "doclet run should succeed, output:\n" + 
out);
+        }
+    }
+
+    private static Map<String, Map<String, String>> readKeys(Path output) 
throws Exception {
+        Properties properties = new Properties();
+        try (Reader reader = Files.newBufferedReader(output, 
StandardCharsets.UTF_8)) {
+            properties.load(reader);
+        }
+        int count = Integer.parseInt(properties.getProperty("keys.count", 
"0"));
+        List<String> fields = new ArrayList<>(List.of(
+                "key",
+                "defaultValue",
+                "fqName",
+                "description",
+                "since",
+                "configurationSource",
+                "configurationType",
+                "supportRepoIdSuffix"));
+        Map<String, Map<String, String>> result = new LinkedHashMap<>();
+        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, ""));
+            }
+            result.put(entry.get("key"), entry);
+        }
+        return result;
+    }
+}
diff --git 
a/maven-resolver-tools/src/test/resources/org/eclipse/aether/sample/SampleConfigurationKeys.java
 
b/maven-resolver-tools/src/test/resources/org/eclipse/aether/sample/SampleConfigurationKeys.java
new file mode 100644
index 000000000..573fe3e00
--- /dev/null
+++ 
b/maven-resolver-tools/src/test/resources/org/eclipse/aether/sample/SampleConfigurationKeys.java
@@ -0,0 +1,78 @@
+/*
+ * 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.sample;
+
+/**
+ * Sample source declaring configuration keys of type {@link Boolean}, {@link 
String} and a custom enum, using the same
+ * Javadoc block tags that {@code ConfigurationCollectorDoclet} extracts. Used 
as a fixture by the doclet test.
+ */
+public final class SampleConfigurationKeys {
+
+    /**
+     * A boolean flag.
+     *
+     * @since 1.2.3
+     * @configurationSource {@link System#getProperty(String,String)}
+     * @configurationType {@link java.lang.Boolean}
+     * @configurationDefaultValue {@link #DEFAULT_BOOL}
+     * @configurationRepoIdSuffix No
+     */
+    public static final String BOOL_KEY = "sample.bool";
+
+    public static final boolean DEFAULT_BOOL = true;
+
+    /**
+     * A string value.
+     *
+     * @configurationSource {@link System#getProperty(String,String)}
+     * @configurationType {@link java.lang.String}
+     * @configurationDefaultValue {@link #DEFAULT_STRING}
+     * @configurationRepoIdSuffix Yes
+     */
+    public static final String STRING_KEY = "sample.string";
+
+    public static final String DEFAULT_STRING = "hello";
+
+    /**
+     * An enum value. The type is a custom enum with a default value declared 
as a variable referencing an enum value.
+     *
+     * @configurationSource {@link System#getProperty(String,String)}
+     * @configurationType {@link SampleEnum}
+     * @configurationDefaultValue {@link #DEFAULT_ENUM}
+     */
+    public static final String ENUM_KEY = "sample.enum";
+
+    public static final SampleEnum DEFAULT_ENUM = SampleEnum.VALUE_A;
+
+    public enum SampleEnum {
+        VALUE_A,
+        VALUE_B
+    }
+
+    /**
+     * An enum value. The type is a custom enum with a default value 
referencing the enum value directly.
+     *
+     * @configurationSource {@link System#getProperty(String,String)}
+     * @configurationType {@link SampleEnum}
+     * @configurationDefaultValue {@link SampleEnum#VALUE_B}
+     */
+    public static final String ENUM2_KEY = "sample.enum2";
+
+    private SampleConfigurationKeys() {}
+}

Reply via email to