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() {}
+}