View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one
3    * or more contributor license agreements.  See the NOTICE file
4    * distributed with this work for additional information
5    * regarding copyright ownership.  The ASF licenses this file
6    * to you under the Apache License, Version 2.0 (the
7    * "License"); you may not use this file except in compliance
8    * with the License.  You may obtain a copy of the License at
9    *
10   *   http://www.apache.org/licenses/LICENSE-2.0
11   *
12   * Unless required by applicable law or agreed to in writing,
13   * software distributed under the License is distributed on an
14   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15   * KIND, either express or implied.  See the License for the
16   * specific language governing permissions and limitations
17   * under the License.
18   */
19  package org.eclipse.aether.tools;
20  
21  import javax.lang.model.SourceVersion;
22  import javax.lang.model.element.AnnotationMirror;
23  import javax.lang.model.element.AnnotationValue;
24  import javax.lang.model.element.Element;
25  import javax.lang.model.element.ElementKind;
26  import javax.lang.model.element.ExecutableElement;
27  import javax.lang.model.element.Modifier;
28  import javax.lang.model.element.ModuleElement;
29  import javax.lang.model.element.PackageElement;
30  import javax.lang.model.element.TypeElement;
31  import javax.lang.model.element.VariableElement;
32  import javax.lang.model.type.DeclaredType;
33  import javax.lang.model.type.PrimitiveType;
34  import javax.lang.model.type.TypeMirror;
35  import javax.lang.model.util.ElementFilter;
36  import javax.lang.model.util.Elements;
37  import javax.lang.model.util.SimpleElementVisitor14;
38  import javax.lang.model.util.SimpleTypeVisitor14;
39  import javax.lang.model.util.Types;
40  import javax.tools.Diagnostic;
41  
42  import java.io.IOException;
43  import java.io.PrintWriter;
44  import java.io.Writer;
45  import java.net.URI;
46  import java.nio.charset.StandardCharsets;
47  import java.nio.file.Files;
48  import java.nio.file.InvalidPathException;
49  import java.nio.file.Path;
50  import java.nio.file.Paths;
51  import java.util.ArrayList;
52  import java.util.Arrays;
53  import java.util.Collection;
54  import java.util.Collections;
55  import java.util.LinkedHashMap;
56  import java.util.List;
57  import java.util.Locale;
58  import java.util.Map;
59  import java.util.Objects;
60  import java.util.Optional;
61  import java.util.Properties;
62  import java.util.Set;
63  
64  import com.sun.source.doctree.DeprecatedTree;
65  import com.sun.source.doctree.DocCommentTree;
66  import com.sun.source.doctree.DocTree;
67  import com.sun.source.doctree.EntityTree;
68  import com.sun.source.doctree.LinkTree;
69  import com.sun.source.doctree.LiteralTree;
70  import com.sun.source.doctree.ReferenceTree;
71  import com.sun.source.doctree.SinceTree;
72  import com.sun.source.doctree.SystemPropertyTree;
73  import com.sun.source.doctree.TextTree;
74  import com.sun.source.doctree.UnknownBlockTagTree;
75  import com.sun.source.doctree.ValueTree;
76  import com.sun.source.tree.ExpressionTree;
77  import com.sun.source.tree.IdentifierTree;
78  import com.sun.source.tree.MemberSelectTree;
79  import com.sun.source.tree.VariableTree;
80  import com.sun.source.util.DocTreePath;
81  import com.sun.source.util.DocTrees;
82  import com.sun.source.util.SimpleDocTreeVisitor;
83  import jdk.javadoc.doclet.Doclet;
84  import jdk.javadoc.doclet.DocletEnvironment;
85  import jdk.javadoc.doclet.Reporter;
86  import org.apache.maven.tools.plugin.javadoc.FullyQualifiedJavadocReference;
87  import org.apache.maven.tools.plugin.javadoc.FullyQualifiedJavadocReference.MemberType;
88  import org.apache.maven.tools.plugin.javadoc.JavadocLinkGenerator;
89  
90  /**
91   * A custom Javadoc {@link Doclet} that scans constant fields for configuration metadata declared via custom Javadoc
92   * block tags (e.g. {@code @configurationSource}) and writes the discovered keys into an intermediate
93   * {@link Properties} file. That file is subsequently consumed by {@link CollectConfiguration} to render the
94   * documentation via Velocity templates.
95   * <p>
96   * The intermediate file uses an indexed layout:
97   * <pre>
98   * keys.count=N
99   * keys.0.key=...
100  * keys.0.description=...
101  * ...
102  * </pre>
103  */
104 public class ConfigurationCollectorDoclet implements Doclet {
105 
106     /**
107      * Fully qualified name of the Maven annotation that marks a configuration key when scanning Maven sources.
108      */
109     private static final String MAVEN_CONFIG_ANNOTATION = "org.apache.maven.api.annotations.Config";
110 
111     private static final MethodReference METHOD_REFERENCE_SESSION_CONFIGURATION =
112             new MethodReference("org.eclipse.aether.RepositorySystemSession", "getConfigProperties", List.of());
113     private static final MethodReference METHOD_REFERENCE_SYSTEM_PROPERTY =
114             new MethodReference("java.lang.System", "getProperty", List.of("java.lang.String", "java.lang.String"));
115 
116     private Reporter reporter;
117 
118     private Path output;
119 
120     private URI internalJavadocUrl = URI.create("apidocs/");
121 
122     private String internalJavadocVersion = "21";
123 
124     private final List<URI> externalJavadocUrls = new ArrayList<>();
125 
126     private final List<Path> internalJavadocSourceRoots = new ArrayList<>();
127 
128     private enum Mode {
129         RESOLVER,
130         MAVEN
131     }
132 
133     private record ConfigurationEntry(
134             String key,
135             String description,
136             String defaultValue,
137             String fqName,
138             String since,
139             String source,
140             String type,
141             String typeJavadocUrl,
142             boolean supportsRepoIdSuffix,
143             // is empty if not deprecated
144             String deprecated) {
145 
146         public ConfigurationEntry {
147             Objects.requireNonNull(key);
148             Objects.requireNonNull(description);
149         }
150     }
151 
152     private record ConfigurationType(String name, String javadocUrl) {}
153 
154     /**
155      * The scanning mode; either {@code resolver} (Javadoc block tags) or {@code maven} (the {@code @Config}
156      * annotation). Defaults to {@code resolver}.
157      */
158     private Mode mode = Mode.RESOLVER;
159 
160     private DocTrees docTrees;
161 
162     private Elements elements;
163 
164     private Types types;
165 
166     private JavadocLinkGenerator javadocLinkGenerator;
167 
168     @Override
169     public void init(Locale locale, Reporter reporter) {
170         this.reporter = reporter;
171     }
172 
173     @Override
174     public String getName() {
175         return "ConfigurationCollector";
176     }
177 
178     @Override
179     public Set<? extends Option> getSupportedOptions() {
180         return Set.of(
181                 new SingleArgumentOption(
182                         List.of("--output", "-o"),
183                         "The intermediate properties file to write discovered keys to",
184                         "<file>",
185                         arg -> {
186                             try {
187                                 output = Paths.get(arg);
188                             } catch (InvalidPathException e) {
189                                 throw new IllegalArgumentException("Invalid output file path: " + arg, e);
190                             }
191                         }),
192                 new SingleArgumentOption(
193                         List.of("--mode", "-m"), "The scanning mode, either 'resolver' or 'maven'", "<mode>", arg -> {
194                             try {
195                                 mode = Mode.valueOf(arg.toUpperCase(Locale.ROOT));
196                             } catch (IllegalArgumentException e) {
197                                 throw new IllegalArgumentException(
198                                         "Invalid mode: " + arg + ". Must be one of (case-insensitive): "
199                                                 + String.join(
200                                                         ", ",
201                                                         Arrays.stream(Mode.values())
202                                                                 .map(Enum::name)
203                                                                 .toArray(String[]::new)));
204                             }
205                         }),
206                 new SingleArgumentOption(
207                         List.of("--internal-javadoc-url"),
208                         "The base URL for Javadoc generated by this project",
209                         "<url>",
210                         arg -> internalJavadocUrl = parseJavadocUrl(arg)),
211                 new SingleArgumentOption(
212                         List.of("--internal-javadoc-version"),
213                         "The Javadoc tool version used to generate the internal site",
214                         "<version>",
215                         arg -> internalJavadocVersion = arg),
216                 new SingleArgumentOption(
217                         List.of("--internal-javadoc-source-root"),
218                         "A source root whose public API is included in the internal Javadoc site",
219                         "<directory>",
220                         arg -> internalJavadocSourceRoots.add(
221                                 Paths.get(arg).toAbsolutePath().normalize())),
222                 new SingleArgumentOption(
223                         List.of("--external-javadoc-url"),
224                         "An external Javadoc base URL used for validated links",
225                         "<url>",
226                         arg -> externalJavadocUrls.add(parseJavadocUrl(arg))));
227     }
228 
229     @Override
230     public SourceVersion getSupportedSourceVersion() {
231         return SourceVersion.latest();
232     }
233 
234     @Override
235     public boolean run(DocletEnvironment environment) {
236         try {
237             return doRun(environment);
238         } catch (RuntimeException e) {
239             // catch all runtime exception, as the default javadoc tool emits a confusing message about reporting
240             // something with Oracle
241             reportError("Error running ConfigurationCollectorDoclet", e);
242             return false;
243         }
244     }
245 
246     private boolean doRun(DocletEnvironment environment) {
247         if (output == null) {
248             reportError("Missing required --output option");
249             return false;
250         }
251         docTrees = environment.getDocTrees();
252         elements = environment.getElementUtils();
253         types = environment.getTypeUtils();
254         javadocLinkGenerator =
255                 new JavadocLinkGenerator(internalJavadocUrl, internalJavadocVersion, externalJavadocUrls, null);
256         List<ConfigurationEntry> configurationEntries = new ArrayList<>();
257 
258         Set<TypeElement> includedTypes = ElementFilter.typesIn(environment.getIncludedElements());
259         for (TypeElement type : includedTypes) {
260             for (VariableElement field : ElementFilter.fieldsIn(type.getEnclosedElements())) {
261                 // check if relevant metadata is present before processing the field, so that we can skip any fields
262                 // that don't have a constant value or Javadoc
263                 if (field.getConstantValue() == null) {
264                     continue;
265                 }
266                 DocCommentTree docComment = docTrees.getDocCommentTree(field);
267                 if (docComment == null) {
268                     // javadoc is mandatory for configuration keys, so skip any fields that don't have a doc comment
269                     continue;
270                 }
271                 DocTreePath rootPath = new DocTreePath(docTrees.getPath(field), docComment);
272                 try {
273                     ConfigurationEntry entry;
274                     switch (mode) {
275                         case MAVEN:
276                             entry = processMavenField(rootPath, field);
277                             break;
278                         case RESOLVER:
279                             entry = processResolverField(rootPath, field);
280                             break;
281                         default:
282                             throw new IllegalStateException("Unknown mode: " + mode);
283                     }
284                     if (entry != null) {
285                         configurationEntries.add(entry);
286                     }
287                 } catch (DocTreePathAwareRuntimeException e) {
288                     reportError(e.getDocTreePath(), e.getMessage());
289                 } catch (IllegalArgumentException e) {
290                     reportError(rootPath, e.getMessage());
291                 } catch (RuntimeException e) {
292                     // log with stacktrace for unexpected errors, but continue
293                     reportError(rootPath, e);
294                 }
295             }
296         }
297 
298         try {
299             writeProperties(configurationEntries);
300         } catch (IOException e) {
301             reportError("Failed to write properties file: " + e.getMessage());
302             return false;
303         }
304         return true;
305     }
306 
307     /**
308      * Reports an error message at a specific DocTreePath location.
309      *
310      * @param path the DocTreePath where the error occurred
311      * @param message the error message
312      */
313     private void reportError(DocTreePath path, Throwable throwable) {
314         reportError(path, throwable.getMessage());
315         reportError(throwable);
316     }
317 
318     private void reportError(Throwable throwable) {
319         // also emit stack trace
320         PrintWriter pw = reporter.getDiagnosticWriter();
321         if (pw == null) {
322             pw = new PrintWriter(System.err);
323         }
324         throwable.printStackTrace(pw);
325     }
326 
327     /**
328      * Reports an error message at a specific DocTreePath location.
329      *
330      * @param path the DocTreePath where the error occurred
331      * @param message the error message
332      */
333     private void reportError(DocTreePath path, String message) {
334         if (path != null) {
335             reporter.print(Diagnostic.Kind.ERROR, path, message);
336         } else {
337             reportError(message);
338         }
339     }
340 
341     /**
342      * Reports a global error message without location information.
343      *
344      * @param message the error message
345      * @param throwable the exception whose stack trace is printed
346      */
347     private void reportError(String message, Throwable throwable) {
348         reporter.print(Diagnostic.Kind.ERROR, message);
349         reportError(throwable);
350     }
351 
352     /**
353      * Reports a global error message without location information.
354      *
355      * @param message the error message
356      */
357     private void reportError(String message) {
358         reporter.print(Diagnostic.Kind.ERROR, message);
359     }
360 
361     /**
362      * Processes a configuration key field declared in Javadoc sources.
363      * @param path
364      * @param field
365      * @return the extracted configuration entry (or {@code null})
366      */
367     private ConfigurationEntry processResolverField(DocTreePath path, VariableElement field) {
368         Objects.requireNonNull(path);
369         Objects.requireNonNull(field);
370         Map<String, UnknownBlockTagTree> blockTags = collectBlockTags(path.getDocComment());
371         if (!blockTags.containsKey("configurationSource")) {
372             return null;
373         }
374         ConfigurationType configurationType = getConfigurationType(path, blockTags);
375         return new ConfigurationEntry(
376                 String.valueOf(field.getConstantValue()),
377                 getFullBodyContent(path),
378                 resolveDefaultValue(path, blockTags).orElse(""),
379                 getFullyQualifiedName(field),
380                 getSince(path).orElse(""),
381                 getConfigurationSource(path, blockTags).orElse(""),
382                 configurationType.name(),
383                 configurationType.javadocUrl(),
384                 isSupportsRepoIdSuffix(path, blockTags),
385                 getDeprecated(path, field).orElse(""));
386     }
387 
388     private Optional<String> getDeprecated(DocTreePath path, Element element) {
389         Objects.requireNonNull(path, "path must not be null");
390         Objects.requireNonNull(element, "field must not be null");
391 
392         // first check for deprecated annotation
393         if (element.getAnnotation(Deprecated.class) == null) {
394             // if not existing check enclosing elements recursively
395             return getDeprecated(element.getEnclosingElement());
396         }
397         Optional<? extends DocTree> deprecatedTag = path.getDocComment().getBlockTags().stream()
398                 .filter(t -> com.sun.source.doctree.DocTree.Kind.DEPRECATED == t.getKind())
399                 .findFirst();
400         if (deprecatedTag.isPresent()) {
401             return Optional.of(renderContent(DocTreePath.getPath(path, deprecatedTag.get()), RenderMode.HTML, true));
402         }
403         return Optional.of("");
404     }
405 
406     private Optional<String> getDeprecated(Element element) {
407         if (element == null) {
408             return Optional.empty();
409         }
410         DocCommentTree docCommentTree = docTrees.getDocCommentTree(element);
411         if (docCommentTree == null) {
412             if (element.getAnnotation(Deprecated.class) != null) {
413                 return Optional.of("");
414             }
415             // traverse to enclosing element
416             return getDeprecated(element.getEnclosingElement());
417         } else {
418             return getDeprecated(new DocTreePath(docTrees.getPath(element), docCommentTree), element);
419         }
420     }
421 
422     private boolean isSupportsRepoIdSuffix(DocTreePath path, Map<String, UnknownBlockTagTree> blockTags) {
423         UnknownBlockTagTree repoIdTag = blockTags.get("configurationRepoIdSuffix");
424         if (repoIdTag != null) {
425             String content = renderContent(DocTreePath.getPath(path, repoIdTag), RenderMode.PLAIN, true);
426             return "yes".equalsIgnoreCase(content) || "true".equalsIgnoreCase(content);
427         }
428         return false;
429     }
430 
431     /**
432      * Processes a constant field declared in Maven sources. Maven declares configuration keys via the
433      * {@code org.apache.maven.api.annotations.Config} annotation (rather than the custom Javadoc block tags used by
434      * Resolver), so the metadata is read from that annotation's attributes.
435      * @return the extracted configuration entry (or {@code null} if the field is not annotated with {@code @Config})
436      */
437     // TODO: move to Maven repository module and use the Maven annotation type directly (currently we don't have a
438     // dependency on Maven API)
439     private ConfigurationEntry processMavenField(DocTreePath path, VariableElement field) {
440         AnnotationMirror config = getAnnotation(field, MAVEN_CONFIG_ANNOTATION);
441         if (config == null) {
442             return null;
443         }
444 
445         String source = "USER_PROPERTIES";
446         String defaultValue = "";
447         String configurationType = "java.lang.String";
448         for (Map.Entry<? extends ExecutableElement, ? extends AnnotationValue> attribute :
449                 config.getElementValues().entrySet()) {
450             String name = attribute.getKey().getSimpleName().toString();
451             Object value = attribute.getValue().getValue();
452             switch (name) {
453                 case "source":
454                     source = value instanceof VariableElement variableElement
455                             ? variableElement.getSimpleName().toString()
456                             : String.valueOf(value);
457                     break;
458                 case "defaultValue":
459                     defaultValue = String.valueOf(value);
460                     break;
461                 case "type":
462                     configurationType = String.valueOf(value);
463                     break;
464                 default:
465                     break;
466             }
467         }
468 
469         source = source.toLowerCase(Locale.ROOT);
470         switch (source) {
471             case "model":
472                 source = "Model properties";
473                 break;
474             case "user_properties":
475                 source = "User properties";
476                 break;
477             case "system_properties":
478                 source = "System properties";
479                 break;
480             default:
481                 break;
482         }
483 
484         if (configurationType.startsWith("java.lang.")) {
485             configurationType = configurationType.substring("java.lang.".length());
486         } else if (configurationType.startsWith("java.util.")) {
487             configurationType = configurationType.substring("java.util.".length());
488         }
489         return new ConfigurationEntry(
490                 String.valueOf(field.getConstantValue()),
491                 path.getDocComment() != null ? getFullBodyContent(path) : "",
492                 Objects.toString(defaultValue, ""),
493                 getFullyQualifiedName(field),
494                 getSince(path).orElse(""),
495                 source,
496                 configurationType,
497                 "",
498                 false,
499                 getDeprecated(path, field).orElse(""));
500     }
501 
502     private AnnotationMirror getAnnotation(Element element, String fqName) {
503         for (AnnotationMirror annotation : element.getAnnotationMirrors()) {
504             Element annotationElement = annotation.getAnnotationType().asElement();
505             if (annotationElement instanceof TypeElement
506                     && ((TypeElement) annotationElement).getQualifiedName().contentEquals(fqName)) {
507                 return annotation;
508             }
509         }
510         return null;
511     }
512 
513     private void writeProperties(List<ConfigurationEntry> configurationEntries) throws IOException {
514         Properties properties = new Properties();
515         properties.setProperty("keys.count", String.valueOf(configurationEntries.size()));
516         for (int i = 0; i < configurationEntries.size(); i++) {
517             ConfigurationEntry entry = configurationEntries.get(i);
518             writeEntry(properties, entry, "keys." + i + ".");
519         }
520         if (output.getParent() != null) {
521             Files.createDirectories(output.getParent());
522         }
523         try (Writer writer = Files.newBufferedWriter(output, StandardCharsets.UTF_8)) {
524             properties.store(writer, "Generated by ConfigurationCollectorDoclet - DO NOT EDIT");
525         }
526     }
527 
528     private void writeEntry(Properties properties, ConfigurationEntry entry, String prefix) {
529         properties.setProperty(prefix + "key", entry.key());
530         properties.setProperty(prefix + "defaultValue", entry.defaultValue());
531         properties.setProperty(prefix + "fqName", entry.fqName());
532         properties.setProperty(prefix + "description", entry.description());
533         properties.setProperty(prefix + "since", entry.since());
534         properties.setProperty(prefix + "configurationSource", entry.source());
535         properties.setProperty(prefix + "configurationType", entry.type());
536         properties.setProperty(prefix + "configurationTypeJavadocUrl", entry.typeJavadocUrl());
537         properties.setProperty(prefix + "supportRepoIdSuffix", toYesNo(entry.supportsRepoIdSuffix()));
538         properties.setProperty(prefix + "deprecated", entry.deprecated());
539     }
540 
541     // --- Javadoc extraction helpers -------------------------------------------------------------------------------
542 
543     private Map<String, UnknownBlockTagTree> collectBlockTags(DocCommentTree docComment) {
544         Map<String, UnknownBlockTagTree> result = new LinkedHashMap<>();
545         if (docComment == null) {
546             return result;
547         }
548         for (DocTree tag : docComment.getBlockTags()) {
549             if (tag instanceof UnknownBlockTagTree unknownBlockTree) {
550                 result.put(unknownBlockTree.getTagName(), unknownBlockTree);
551             }
552         }
553         return result;
554     }
555 
556     private String getFullBodyContent(DocTreePath path) {
557         return renderContent(path, RenderMode.HTML, true, path.getDocComment().getFullBody());
558     }
559 
560     private Optional<String> resolveDefaultValue(DocTreePath path, Map<String, UnknownBlockTagTree> blockTags) {
561         UnknownBlockTagTree defaultValueTag = blockTags.get("configurationDefaultValue");
562         if (defaultValueTag == null) {
563             return Optional.empty();
564         }
565         DocTreePath defaultValuePath = DocTreePath.getPath(path, defaultValueTag);
566         for (DocTree tree : defaultValueTag.getContent()) {
567             if (tree instanceof LinkTree link) {
568                 String signature = link.getReference().getSignature();
569                 DocTreePath linkTreePath = DocTreePath.getPath(path, tree);
570                 // resolve the referenced constant using the fully qualified signature, so that references
571                 // to constants declared in other types (e.g. {@link OtherType#CONSTANT}) can be resolved
572                 VariableElement referenced = resolveReferencedField(linkTreePath, link);
573                 String value = referenced != null ? lookupConstant(referenced) : null;
574                 if (value == null) {
575                     // hard fail: default value constants must be resolvable; report at the precise
576                     // link-reference location if we can resolve a path to it, otherwise at the block tag
577                     DocTreePath linkRefPath = DocTreePath.getPath(linkTreePath, link.getReference());
578                     throw new DocTreePathAwareRuntimeException(
579                             linkRefPath != null ? linkRefPath : linkTreePath,
580                             "Could not resolve link to determine default value: " + signature);
581                 }
582                 return Optional.ofNullable(value);
583             }
584         }
585         // fallback: render the content of the block tag as-is (e.g. if it contains a literal value rather than a {@code
586         // {@link ...}} reference)
587         return Optional.of(renderContent(defaultValuePath, RenderMode.PLAIN, true));
588     }
589 
590     /**
591      * Resolves the {@link VariableElement} a {@code {@link ...}} reference points to using the fully qualified
592      * signature (so references into other types are supported). Returns {@code null} if the reference cannot be
593      * resolved to a field.
594      */
595     private VariableElement resolveReferencedField(DocTreePath path, LinkTree link) {
596         DocTreePath refPath = DocTreePath.getPath(path, link.getReference());
597         if (refPath == null) {
598             return null;
599         }
600         Element element = docTrees.getElement(refPath);
601         return element instanceof VariableElement variableElement ? variableElement : null;
602     }
603 
604     private String lookupConstant(VariableElement field) {
605         if (field.getConstantValue() != null) {
606             Object value = field.getConstantValue();
607             if (value instanceof String) {
608                 return "\"" + value + "\"";
609             } else {
610                 return String.valueOf(field.getConstantValue());
611             }
612         }
613         // enum constants don't expose a constant value, fall back to the enum value's name
614         if (field.getKind() == ElementKind.ENUM_CONSTANT) {
615             return field.getSimpleName().toString();
616         }
617         // the field may indirectly reference an enum variable, e.g. "SomeEnum.VALUE";
618         // resolve it from the field's initializer
619         return resolveEnumReference(field);
620     }
621 
622     /**
623      * Resolves an enum constant that a field is initialized with, including the enum type in the result
624      * (e.g. a field declared as {@code SomeEnum FOO = SomeEnum.VALUE} resolves to {@code SomeEnum.VALUE}).
625      * Returns {@code null} if the field's initializer is not a simple enum reference.
626      */
627     private String resolveEnumReference(VariableElement field) {
628         if (!(docTrees.getTree(field) instanceof VariableTree variableTree)) {
629             return null;
630         }
631         ExpressionTree initializer = variableTree.getInitializer();
632         String enumConstant = null;
633         if (initializer instanceof MemberSelectTree memberSelectTree) {
634             // e.g. SomeEnum.VALUE -> VALUE
635             enumConstant = memberSelectTree.getIdentifier().toString();
636         } else if (initializer instanceof IdentifierTree identifierTree) {
637             // e.g. statically imported VALUE -> VALUE
638             enumConstant = identifierTree.getName().toString();
639         }
640         if (enumConstant == null) {
641             return null;
642         }
643         return enumConstant;
644     }
645 
646     private Optional<LinkTree> getFirstLinkInBlockTag(UnknownBlockTagTree tag) {
647         for (DocTree tree : tag.getContent()) {
648             if (tree instanceof LinkTree link) {
649                 return Optional.of(link);
650             }
651         }
652         return Optional.empty();
653     }
654 
655     /**
656      * Resolves the fully qualified type name a {@code {@link ...}} reference points to.
657      * @param path the path of the given inline link tag
658      * @param link the inline link tag
659      * @return
660      */
661     private String getType(DocTreePath path, LinkTree link) {
662         String signature = link.getReference().getSignature();
663         if (signature.contains("#")) {
664             // report at the precise link reference node within the block tag
665             DocTreePath linkRefPath = DocTreePath.getPath(path, link.getReference());
666             throw new DocTreePathAwareRuntimeException(
667                     linkRefPath != null ? linkRefPath : path,
668                     "Expected a class link, but got a member reference: " + signature);
669         }
670         // resolve the referenced type and return its fully qualified name, falling back to the raw signature if it
671         // cannot be resolved
672         return resolveReferencedType(path, link.getReference())
673                 .map(t -> t.getQualifiedName().toString())
674                 .orElse(signature);
675     }
676 
677     /**
678      * Resolves the fully qualified class name a {@code {@link ...}} class reference points to (so that simple names
679      * declared via imports are expanded). Falls back to the raw signature if the reference cannot be resolved to a
680      * type.
681      */
682     private Optional<TypeElement> resolveReferencedType(DocTreePath path, ReferenceTree reference) {
683         // TODO: try to resolve from type outside the current compilation unit (e.g. from imports)
684         DocTreePath refPath = DocTreePath.getPath(path, reference);
685         if (refPath == null) {
686             return Optional.empty();
687         }
688         Element element = docTrees.getElement(refPath);
689         return element instanceof TypeElement typeElement ? Optional.of(typeElement) : Optional.empty();
690     }
691 
692     enum RenderMode {
693         /** Render the content as plain text. Stripping any rich text markup */
694         PLAIN,
695         /** Render the content as HTML, escaping special characters and rendering inline tags. */
696         HTML
697     }
698 
699     private String renderContent(DocTreePath docTreePath, RenderMode mode, boolean trim) {
700         return renderContent(docTreePath, mode, trim, null);
701     }
702 
703     /**
704      * Renders the content of a Javadoc tag into an HTML string, escaping HTML special characters and rendering inline tags.
705      *
706      * @param docTreePath encapsulates the doc comment tree and the path to the content being rendered.
707      * The latter is used for resolving {@code {@link ...}} references and emitting error messages.
708      * @param trim if true, trims the result string (may destroy {@code <pre> </pre>} formatting).
709      * @param docTrees the doc trees for which to render the content. If {@code null}, the leaf of the {@code docTreePath} is rendered.
710      * @return the rendered content (never {@code null})
711      * @see <a href="https://docs.oracle.com/en/java/javase/25/docs/specs/javadoc/doc-comment-spec.html#standard-tags">Javadoc tags</a>
712      * @see <a href="https://docs.oracle.com/en/java/javase/25/docs/api/jdk.compiler/com/sun/source/doctree/InlineTagTree.html">InlineTagTree (common superinterface of all inline tags)</a>
713      */
714     private String renderContent(
715             DocTreePath docTreePath, RenderMode mode, boolean trim, Collection<? extends DocTree> docTreesToRender) {
716         Objects.requireNonNull(docTreePath, "docTreePath must not be null");
717         StringBuilder sb = new StringBuilder();
718         SimpleDocTreeVisitor<String, Void> visitor = new SimpleDocTreeVisitor<String, Void>() {
719             @Override
720             public String visitText(TextTree node, Void p) {
721                 return escape(mode, node.getBody());
722             }
723 
724             @Override
725             public String visitLink(LinkTree node, Void p) {
726                 String ref = node.getReference() != null ? node.getReference().getSignature() : "";
727                 List<? extends DocTree> labelTrees = node.getLabel();
728                 String label = (labelTrees != null && !labelTrees.isEmpty())
729                         ? renderContent(docTreePath, mode, false, labelTrees)
730                         : "";
731                 String text = label.isEmpty() ? ref : label;
732                 String rendered = node.getKind() == DocTree.Kind.LINK_PLAIN ? escape(mode, text) : renderAsCode(text);
733                 if (mode == RenderMode.HTML) {
734                     VariableElement referenced = resolveReferencedField(docTreePath, node);
735                     String configurationKey = getConfigurationKey(referenced);
736                     if (configurationKey != null) {
737                         rendered = node.getKind() == DocTree.Kind.LINK_PLAIN
738                                 ? escape(mode, configurationKey)
739                                 : renderAsCode(configurationKey);
740                         return "<a href=\"#" + escape(mode, configurationKey) + "\">" + rendered + "</a>";
741                     }
742                     Optional<String> javadocUrl = getJavadocUrl(docTreePath, node.getReference());
743                     if (javadocUrl.isPresent()) {
744                         return "<a href=\"" + javadocUrl.get() + "\">" + rendered + "</a>";
745                     }
746                 }
747                 return rendered;
748             }
749 
750             @Override
751             public String visitLiteral(LiteralTree node, Void p) {
752                 if (node.getKind() == DocTree.Kind.CODE) {
753                     return renderAsCode(node.getBody().getBody());
754                 } else {
755                     return escape(mode, node.getBody().getBody());
756                 }
757             }
758 
759             @Override
760             public String visitSystemProperty(SystemPropertyTree node, Void p) {
761                 return renderAsCode(node.getPropertyName().toString());
762             }
763 
764             private String renderAsCode(String text) {
765                 if (mode == RenderMode.HTML) {
766                     return "<code>" + escape(mode, text) + "</code>";
767                 } else {
768                     return escape(mode, text);
769                 }
770             }
771 
772             @Override
773             public String visitValue(ValueTree node, Void p) {
774                 if (node.getReference() != null) {
775                     DocTreePath refPath = DocTreePath.getPath(docTreePath, node.getReference());
776                     if (refPath != null) {
777                         Element element = docTrees.getElement(refPath);
778                         if (element instanceof VariableElement ve) {
779                             String value = lookupConstant(ve);
780                             if (value != null) {
781                                 return renderAsCode(value);
782                             }
783                         }
784                     }
785                 }
786                 // fall back to showing the reference signature
787                 String ref = node.getReference() != null ? node.getReference().getSignature() : "";
788                 return renderAsCode(ref);
789             }
790 
791             @Override
792             public String visitEntity(EntityTree node, Void p) {
793                 return "&" + node.getName() + ";";
794             }
795 
796             @Override
797             public String visitUnknownBlockTag(UnknownBlockTagTree node, Void p) {
798                 StringBuilder sb = new StringBuilder();
799                 node.getContent().forEach(child -> sb.append(child.accept(this, p)));
800                 return sb.toString();
801             }
802 
803             @Override
804             public String visitSince(SinceTree node, Void p) {
805                 return escape(mode, node.getBody().toString());
806             }
807 
808             @Override
809             public String visitDeprecated(DeprecatedTree node, Void p) {
810                 StringBuilder sb = new StringBuilder();
811                 node.getBody().forEach(child -> sb.append(child.accept(this, p)));
812                 return sb.toString();
813             }
814 
815             @Override
816             protected String defaultAction(DocTree node, Void p) {
817                 // the default action internally calls node.toString(), which uses
818                 // com.sun.tools.javac.tree.DCTree.toString() which relies on com.sun.tools.javac.tree.DocPretty to
819                 // render the node
820                 return node.toString();
821             }
822         };
823         if (docTreesToRender == null) {
824             docTreesToRender = Collections.singleton(docTreePath.getLeaf());
825         }
826         for (DocTree docTreeToRender : docTreesToRender) {
827             sb.append(docTreeToRender.accept(visitor, null));
828         }
829 
830         if (trim) {
831             // normalize whitespace not relevant for HTML rendering,
832             // trimming behaviour already differs between different Javadoc
833             // versions (Java > 21 trims leading whitespace per line)
834             return sb.toString().trim().replaceAll("\\s+", " ");
835         } else {
836             return sb.toString();
837         }
838     }
839 
840     private static String escape(RenderMode mode, String text) {
841         if (mode == RenderMode.HTML) {
842             return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
843         } else {
844             return text;
845         }
846     }
847 
848     private Optional<String> getSince(DocTreePath path) {
849         String since = getSinceTag(path);
850         if (since == null && path.getTreePath().getParentPath() != null) {
851             // get the @since tag from the enclosing element (e.g. the enclosing class or package)
852             return getSince(docTrees.getElement(path.getTreePath().getParentPath()));
853         }
854         return Optional.ofNullable(since);
855     }
856 
857     private Optional<String> getSince(Element element) {
858         if (element == null) {
859             return Optional.empty();
860         }
861         DocCommentTree docComment = docTrees.getDocCommentTree(element);
862         if (docComment != null) {
863             DocTreePath path = new DocTreePath(docTrees.getPath(element), docComment);
864             Optional<String> since = getSince(path);
865             if (since.isPresent()) {
866                 return since;
867             }
868         }
869         // traverse up the enclosing elements to find a @since tag in the closest enclosing type or package
870         return getSince(element.getEnclosingElement());
871     }
872 
873     private String getSinceTag(DocTreePath path) {
874         if (path == null) {
875             // may be non existent
876             return null;
877         }
878         for (DocTree tag : path.getDocComment().getBlockTags()) {
879             if (tag instanceof SinceTree) {
880                 return renderContent(DocTreePath.getPath(path, tag), RenderMode.PLAIN, true);
881             }
882         }
883         return null;
884     }
885 
886     private ConfigurationType getConfigurationType(DocTreePath path, Map<String, UnknownBlockTagTree> blockTags) {
887         UnknownBlockTagTree typeTag = blockTags.get("configurationType");
888         if (typeTag == null) {
889             throw new IllegalStateException("Missing block tag @configurationType");
890         }
891         DocTreePath configurationTypePath = DocTreePath.getPath(path, typeTag);
892         LinkTree linkTree = getFirstLinkInBlockTag(typeTag)
893                 .orElseThrow(() -> new DocTreePathAwareRuntimeException(
894                         configurationTypePath, "No valid {@link ...} reference found in @" + typeTag.getTagName()));
895 
896         String type = getType(configurationTypePath, linkTree);
897         String javadocUrl =
898                 getJavadocUrl(configurationTypePath, linkTree.getReference()).orElse("");
899         String javaLangPackage = "java.lang.";
900         if (type.startsWith(javaLangPackage)) {
901             type = type.substring(javaLangPackage.length());
902         }
903         return new ConfigurationType(type, javadocUrl);
904     }
905 
906     private String getConfigurationKey(VariableElement field) {
907         if (field == null || !(field.getConstantValue() instanceof String key)) {
908             return null;
909         }
910         if (mode == Mode.MAVEN) {
911             return getAnnotation(field, MAVEN_CONFIG_ANNOTATION) != null ? key : null;
912         }
913         DocCommentTree docComment = docTrees.getDocCommentTree(field);
914         return docComment != null && collectBlockTags(docComment).containsKey("configurationSource") ? key : null;
915     }
916 
917     private Optional<String> getJavadocUrl(DocTreePath path, ReferenceTree reference) {
918         if (reference == null) {
919             return Optional.empty();
920         }
921         DocTreePath referencePath = DocTreePath.getPath(path, reference);
922         if (referencePath == null) {
923             return Optional.empty();
924         }
925         Element element = docTrees.getElement(referencePath);
926         if (element == null) {
927             return Optional.empty();
928         }
929         return createJavadocReference(element).flatMap(javadocReference -> {
930             try {
931                 return Optional.of(
932                         javadocLinkGenerator.createLink(javadocReference).toString());
933             } catch (IllegalArgumentException e) {
934                 return Optional.empty();
935             }
936         });
937     }
938 
939     private Optional<FullyQualifiedJavadocReference> createJavadocReference(Element element) {
940         boolean external = !isInternalJavadocElement(element);
941         if (element instanceof ModuleElement moduleElement) {
942             return Optional.of(new FullyQualifiedJavadocReference(
943                     Optional.of(moduleElement.getQualifiedName().toString()),
944                     Optional.empty(),
945                     Optional.empty(),
946                     Optional.empty(),
947                     Optional.empty(),
948                     Optional.empty(),
949                     external));
950         }
951 
952         Optional<String> moduleName = external ? Optional.empty() : getModuleName(element);
953         if (element instanceof PackageElement packageElement) {
954             return Optional.of(new FullyQualifiedJavadocReference(
955                     moduleName,
956                     Optional.of(packageElement.getQualifiedName().toString()),
957                     Optional.empty(),
958                     Optional.empty(),
959                     Optional.empty(),
960                     Optional.empty(),
961                     external));
962         }
963 
964         TypeElement declaringType;
965         Optional<String> member = Optional.empty();
966         Optional<MemberType> memberType = Optional.empty();
967         if (element instanceof TypeElement typeElement) {
968             declaringType = typeElement;
969         } else if (element instanceof VariableElement variableElement
970                 && variableElement.getEnclosingElement() instanceof TypeElement typeElement) {
971             declaringType = typeElement;
972             member = Optional.of(variableElement.getSimpleName().toString());
973             memberType = Optional.of(MemberType.FIELD);
974         } else if (element instanceof ExecutableElement executableElement
975                 && executableElement.getEnclosingElement() instanceof TypeElement typeElement) {
976             declaringType = typeElement;
977             String executableName = executableElement.getKind() == ElementKind.CONSTRUCTOR
978                     ? typeElement.getSimpleName().toString()
979                     : executableElement.getSimpleName().toString();
980             String parameterTypes = String.join(
981                     ",",
982                     executableElement.getParameters().stream()
983                             .map(parameter -> getFullyQualifiedName(types.erasure(parameter.asType())))
984                             .toList());
985             member = Optional.of(executableName + "(" + parameterTypes + ")");
986             memberType = Optional.of(
987                     executableElement.getKind() == ElementKind.CONSTRUCTOR
988                             ? MemberType.CONSTRUCTOR
989                             : MemberType.METHOD);
990         } else {
991             return Optional.empty();
992         }
993 
994         PackageElement packageElement = elements.getPackageOf(declaringType);
995         String packageName = packageElement.getQualifiedName().toString();
996         String qualifiedName = declaringType.getQualifiedName().toString();
997         String className = packageName.isEmpty() ? qualifiedName : qualifiedName.substring(packageName.length() + 1);
998         return Optional.of(new FullyQualifiedJavadocReference(
999                 moduleName,
1000                 Optional.of(packageName),
1001                 Optional.of(className),
1002                 member,
1003                 memberType,
1004                 Optional.empty(),
1005                 external));
1006     }
1007 
1008     private Optional<String> getModuleName(Element element) {
1009         ModuleElement module = elements.getModuleOf(element);
1010         return module != null && !module.isUnnamed()
1011                 ? Optional.of(module.getQualifiedName().toString())
1012                 : Optional.empty();
1013     }
1014 
1015     private boolean isInternalJavadocElement(Element element) {
1016         if (element instanceof ModuleElement) {
1017             return docTrees.getPath(element) != null;
1018         }
1019         if (element instanceof PackageElement packageElement) {
1020             return docTrees.getPath(element) != null || isPackageInInternalSourceTree(packageElement);
1021         }
1022 
1023         TypeElement topLevelType = null;
1024         for (Element current = element;
1025                 current != null && !(current instanceof PackageElement) && !(current instanceof ModuleElement);
1026                 current = current.getEnclosingElement()) {
1027             if (current instanceof TypeElement typeElement) {
1028                 topLevelType = typeElement;
1029             }
1030             if (isJavadocDeclaration(current)
1031                     && !current.getModifiers().contains(Modifier.PUBLIC)
1032                     && !current.getModifiers().contains(Modifier.PROTECTED)) {
1033                 return false;
1034             }
1035         }
1036         return topLevelType != null
1037                 && (docTrees.getPath(topLevelType) != null || isTypeInInternalSourceTree(topLevelType));
1038     }
1039 
1040     private boolean isPackageInInternalSourceTree(PackageElement packageElement) {
1041         Path packagePath = getPackagePath(packageElement);
1042         return internalJavadocSourceRoots.stream()
1043                 .map(sourceRoot -> sourceRoot.resolve(packagePath))
1044                 .anyMatch(Files::isDirectory);
1045     }
1046 
1047     private boolean isTypeInInternalSourceTree(TypeElement topLevelType) {
1048         Path packagePath = getPackagePath(elements.getPackageOf(topLevelType));
1049         Path sourceFile = packagePath.resolve(topLevelType.getSimpleName() + ".java");
1050         return internalJavadocSourceRoots.stream()
1051                 .map(sourceRoot -> sourceRoot.resolve(sourceFile))
1052                 .anyMatch(Files::isRegularFile);
1053     }
1054 
1055     private static Path getPackagePath(PackageElement packageElement) {
1056         String packageName = packageElement.getQualifiedName().toString();
1057         return packageName.isEmpty() ? Path.of("") : Path.of(packageName.replace('.', '/'));
1058     }
1059 
1060     private static boolean isJavadocDeclaration(Element element) {
1061         ElementKind kind = element.getKind();
1062         return kind.isClass()
1063                 || kind.isInterface()
1064                 || kind == ElementKind.FIELD
1065                 || kind == ElementKind.ENUM_CONSTANT
1066                 || kind == ElementKind.METHOD
1067                 || kind == ElementKind.CONSTRUCTOR;
1068     }
1069 
1070     private Optional<String> getConfigurationSource(DocTreePath path, Map<String, UnknownBlockTagTree> blockTags) {
1071         UnknownBlockTagTree configurationSourceTag = blockTags.get("configurationSource");
1072         if (configurationSourceTag == null) {
1073             return Optional.empty();
1074         }
1075         DocTreePath configurationSourcePath = DocTreePath.getPath(path, configurationSourceTag);
1076         LinkTree linkTree = getFirstLinkInBlockTag(configurationSourceTag)
1077                 .orElseThrow(() -> new DocTreePathAwareRuntimeException(
1078                         configurationSourcePath,
1079                         "No valid {@link ...} reference found in @" + configurationSourceTag.getTagName()));
1080 
1081         // javadoc signature is not normalized, use the resolved reference (leveraging ReferenceParser) to get a unique
1082         // canonical representation of the referenced method
1083         MethodReference methodReference = getReferencedMethod(configurationSourcePath, linkTree);
1084         if (methodReference.equals(METHOD_REFERENCE_SESSION_CONFIGURATION)) {
1085             return Optional.of("Session Configuration");
1086         } else if (methodReference.equals(METHOD_REFERENCE_SYSTEM_PROPERTY)) {
1087             return Optional.of("Java System Properties");
1088         } else {
1089             reporter.print(
1090                     Diagnostic.Kind.WARNING,
1091                     path,
1092                     "Unknown configuration source: " + linkTree.getReference().getSignature()
1093                             + ", using raw signature as source");
1094             return Optional.of(linkTree.getReference().getSignature());
1095         }
1096     }
1097 
1098     /**
1099      * Represents a reference to a method, including the fully qualified class name, method name, and parameter types.
1100      * This is supposed to be unique as well as canonical.
1101      * The signature within a Javadoc link is not normalized (e.g. may contain spaces or not, may contain argument names or not)
1102      * so we need to resolve the reference to get a unique representation of the method.
1103      * @param fullyQualifiedClassName the fully qualified name of the class containing the method
1104      * @param methodName the name of the method
1105      * @param fullyQualifiedParameterTypes a list of fully qualified names (for declared types) or simple names (for primitive types) of the parameter types of the method
1106      */
1107     protected record MethodReference(
1108             String fullyQualifiedClassName, String methodName, List<String> fullyQualifiedParameterTypes) {}
1109 
1110     private MethodReference getReferencedMethod(DocTreePath path, LinkTree link) {
1111         ExecutableElement ee = getReferencedExecutableElement(path, link);
1112         String fullyQualifiedClassName =
1113                 ((TypeElement) ee.getEnclosingElement()).getQualifiedName().toString();
1114         String methodName = ee.getSimpleName().toString();
1115         List<String> parameterTypes = ee.getParameters().stream()
1116                 .map(p -> getFullyQualifiedName(p.asType()))
1117                 .toList();
1118         return new MethodReference(fullyQualifiedClassName, methodName, parameterTypes);
1119     }
1120 
1121     static String getFullyQualifiedName(Element e) {
1122         return new SimpleElementVisitor14<String, Void>() {
1123             @Override
1124             public String visitModule(ModuleElement e, Void p) {
1125                 return e.getQualifiedName().toString();
1126             }
1127 
1128             @Override
1129             public String visitPackage(PackageElement e, Void p) {
1130                 return e.getQualifiedName().toString();
1131             }
1132 
1133             @Override
1134             public String visitType(TypeElement e, Void p) {
1135                 return e.getQualifiedName().toString();
1136             }
1137 
1138             @Override
1139             protected String defaultAction(Element e, Void p) {
1140                 return visit(e.getEnclosingElement()) + "." + e.getSimpleName();
1141             }
1142         }.visit(e);
1143     }
1144 
1145     static String getFullyQualifiedName(TypeMirror e) {
1146         return new SimpleTypeVisitor14<String, Void>() {
1147             @Override
1148             public String visitDeclared(DeclaredType t, Void p) {
1149                 Element e = t.asElement();
1150                 if (e instanceof TypeElement typeElement) {
1151                     return typeElement.getQualifiedName().toString();
1152                 }
1153                 return super.visitDeclared(t, p);
1154             }
1155 
1156             @Override
1157             public String visitPrimitive(PrimitiveType t, Void p) {
1158                 return t.toString();
1159             }
1160 
1161             @Override
1162             protected String defaultAction(TypeMirror e, Void p) {
1163                 return e.toString();
1164             }
1165         }.visit(e);
1166     }
1167 
1168     private ExecutableElement getReferencedExecutableElement(DocTreePath path, LinkTree link) {
1169         DocTreePath linkRefPath = DocTreePath.getPath(path, link.getReference());
1170         if (linkRefPath == null) {
1171             throw new DocTreePathAwareRuntimeException(
1172                     path,
1173                     "Could not resolve link reference: " + link.getReference().getSignature());
1174         }
1175         Element element = docTrees.getElement(linkRefPath);
1176         if (element instanceof ExecutableElement ee) {
1177             return ee;
1178         } else {
1179             throw new DocTreePathAwareRuntimeException(
1180                     linkRefPath, "Expected an executable element, but got: " + element);
1181         }
1182     }
1183 
1184     private static String toYesNo(boolean value) {
1185         return value ? "Yes" : "No";
1186     }
1187 
1188     private static URI parseJavadocUrl(String value) {
1189         URI uri = URI.create(value);
1190         if (uri.getQuery() != null || uri.getFragment() != null) {
1191             throw new IllegalArgumentException("Javadoc base URL must not contain a query or fragment: " + value);
1192         }
1193         return value.endsWith("/") ? uri : URI.create(value + "/");
1194     }
1195 
1196     /**
1197      * Minimal {@link Option} implementation.
1198      */
1199     private static final class SingleArgumentOption implements Option {
1200         private final List<String> names;
1201         private final String description;
1202         private final String parameters;
1203         private final java.util.function.Consumer<String> processor;
1204 
1205         SingleArgumentOption(
1206                 List<String> names,
1207                 String description,
1208                 String parameters,
1209                 java.util.function.Consumer<String> processor) {
1210             this.names = names;
1211             this.description = description;
1212             this.parameters = parameters;
1213             this.processor = processor;
1214         }
1215 
1216         @Override
1217         public int getArgumentCount() {
1218             return 1;
1219         }
1220 
1221         @Override
1222         public String getDescription() {
1223             return description;
1224         }
1225 
1226         @Override
1227         public Kind getKind() {
1228             return Kind.STANDARD;
1229         }
1230 
1231         @Override
1232         public List<String> getNames() {
1233             return names;
1234         }
1235 
1236         @Override
1237         public String getParameters() {
1238             return parameters;
1239         }
1240 
1241         @Override
1242         public boolean process(String option, List<String> arguments) {
1243             processor.accept(arguments.get(0));
1244             // returning false just leads to a very generic error message (not even exposing the affected option) so
1245             // rather rely on custom runtime exceptions for validation errors
1246             return true;
1247         }
1248     }
1249 }