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                         return "<a href=\"#" + escape(mode, configurationKey) + "\">" + rendered + "</a>";
738                     }
739                     Optional<String> javadocUrl = getJavadocUrl(docTreePath, node.getReference());
740                     if (javadocUrl.isPresent()) {
741                         return "<a href=\"" + javadocUrl.get() + "\">" + rendered + "</a>";
742                     }
743                 }
744                 return rendered;
745             }
746 
747             @Override
748             public String visitLiteral(LiteralTree node, Void p) {
749                 if (node.getKind() == DocTree.Kind.CODE) {
750                     return renderAsCode(node.getBody().getBody());
751                 } else {
752                     return escape(mode, node.getBody().getBody());
753                 }
754             }
755 
756             @Override
757             public String visitSystemProperty(SystemPropertyTree node, Void p) {
758                 return renderAsCode(node.getPropertyName().toString());
759             }
760 
761             private String renderAsCode(String text) {
762                 if (mode == RenderMode.HTML) {
763                     return "<code>" + escape(mode, text) + "</code>";
764                 } else {
765                     return escape(mode, text);
766                 }
767             }
768 
769             @Override
770             public String visitValue(ValueTree node, Void p) {
771                 if (node.getReference() != null) {
772                     DocTreePath refPath = DocTreePath.getPath(docTreePath, node.getReference());
773                     if (refPath != null) {
774                         Element element = docTrees.getElement(refPath);
775                         if (element instanceof VariableElement ve) {
776                             String value = lookupConstant(ve);
777                             if (value != null) {
778                                 return renderAsCode(value);
779                             }
780                         }
781                     }
782                 }
783                 // fall back to showing the reference signature
784                 String ref = node.getReference() != null ? node.getReference().getSignature() : "";
785                 return renderAsCode(ref);
786             }
787 
788             @Override
789             public String visitEntity(EntityTree node, Void p) {
790                 return "&" + node.getName() + ";";
791             }
792 
793             @Override
794             public String visitUnknownBlockTag(UnknownBlockTagTree node, Void p) {
795                 StringBuilder sb = new StringBuilder();
796                 node.getContent().forEach(child -> sb.append(child.accept(this, p)));
797                 return sb.toString();
798             }
799 
800             @Override
801             public String visitSince(SinceTree node, Void p) {
802                 return escape(mode, node.getBody().toString());
803             }
804 
805             @Override
806             public String visitDeprecated(DeprecatedTree node, Void p) {
807                 StringBuilder sb = new StringBuilder();
808                 node.getBody().forEach(child -> sb.append(child.accept(this, p)));
809                 return sb.toString();
810             }
811 
812             @Override
813             protected String defaultAction(DocTree node, Void p) {
814                 // the default action internally calls node.toString(), which uses
815                 // com.sun.tools.javac.tree.DCTree.toString() which relies on com.sun.tools.javac.tree.DocPretty to
816                 // render the node
817                 return node.toString();
818             }
819         };
820         if (docTreesToRender == null) {
821             docTreesToRender = Collections.singleton(docTreePath.getLeaf());
822         }
823         for (DocTree docTreeToRender : docTreesToRender) {
824             sb.append(docTreeToRender.accept(visitor, null));
825         }
826 
827         if (trim) {
828             // normalize whitespace not relevant for HTML rendering,
829             // trimming behaviour already differs between different Javadoc
830             // versions (Java > 21 trims leading whitespace per line)
831             return sb.toString().trim().replaceAll("\\s+", " ");
832         } else {
833             return sb.toString();
834         }
835     }
836 
837     private static String escape(RenderMode mode, String text) {
838         if (mode == RenderMode.HTML) {
839             return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
840         } else {
841             return text;
842         }
843     }
844 
845     private Optional<String> getSince(DocTreePath path) {
846         String since = getSinceTag(path);
847         if (since == null && path.getTreePath().getParentPath() != null) {
848             // get the @since tag from the enclosing element (e.g. the enclosing class or package)
849             return getSince(docTrees.getElement(path.getTreePath().getParentPath()));
850         }
851         return Optional.ofNullable(since);
852     }
853 
854     private Optional<String> getSince(Element element) {
855         if (element == null) {
856             return Optional.empty();
857         }
858         DocCommentTree docComment = docTrees.getDocCommentTree(element);
859         if (docComment != null) {
860             DocTreePath path = new DocTreePath(docTrees.getPath(element), docComment);
861             Optional<String> since = getSince(path);
862             if (since.isPresent()) {
863                 return since;
864             }
865         }
866         // traverse up the enclosing elements to find a @since tag in the closest enclosing type or package
867         return getSince(element.getEnclosingElement());
868     }
869 
870     private String getSinceTag(DocTreePath path) {
871         if (path == null) {
872             // may be non existent
873             return null;
874         }
875         for (DocTree tag : path.getDocComment().getBlockTags()) {
876             if (tag instanceof SinceTree) {
877                 return renderContent(DocTreePath.getPath(path, tag), RenderMode.PLAIN, true);
878             }
879         }
880         return null;
881     }
882 
883     private ConfigurationType getConfigurationType(DocTreePath path, Map<String, UnknownBlockTagTree> blockTags) {
884         UnknownBlockTagTree typeTag = blockTags.get("configurationType");
885         if (typeTag == null) {
886             throw new IllegalStateException("Missing block tag @configurationType");
887         }
888         DocTreePath configurationTypePath = DocTreePath.getPath(path, typeTag);
889         LinkTree linkTree = getFirstLinkInBlockTag(typeTag)
890                 .orElseThrow(() -> new DocTreePathAwareRuntimeException(
891                         configurationTypePath, "No valid {@link ...} reference found in @" + typeTag.getTagName()));
892 
893         String type = getType(configurationTypePath, linkTree);
894         String javadocUrl =
895                 getJavadocUrl(configurationTypePath, linkTree.getReference()).orElse("");
896         String javaLangPackage = "java.lang.";
897         if (type.startsWith(javaLangPackage)) {
898             type = type.substring(javaLangPackage.length());
899         }
900         return new ConfigurationType(type, javadocUrl);
901     }
902 
903     private String getConfigurationKey(VariableElement field) {
904         if (field == null || !(field.getConstantValue() instanceof String key)) {
905             return null;
906         }
907         if (mode == Mode.MAVEN) {
908             return getAnnotation(field, MAVEN_CONFIG_ANNOTATION) != null ? key : null;
909         }
910         DocCommentTree docComment = docTrees.getDocCommentTree(field);
911         return docComment != null && collectBlockTags(docComment).containsKey("configurationSource") ? key : null;
912     }
913 
914     private Optional<String> getJavadocUrl(DocTreePath path, ReferenceTree reference) {
915         if (reference == null) {
916             return Optional.empty();
917         }
918         DocTreePath referencePath = DocTreePath.getPath(path, reference);
919         if (referencePath == null) {
920             return Optional.empty();
921         }
922         Element element = docTrees.getElement(referencePath);
923         if (element == null) {
924             return Optional.empty();
925         }
926         return createJavadocReference(element).flatMap(javadocReference -> {
927             try {
928                 return Optional.of(
929                         javadocLinkGenerator.createLink(javadocReference).toString());
930             } catch (IllegalArgumentException e) {
931                 return Optional.empty();
932             }
933         });
934     }
935 
936     private Optional<FullyQualifiedJavadocReference> createJavadocReference(Element element) {
937         boolean external = !isInternalJavadocElement(element);
938         if (element instanceof ModuleElement moduleElement) {
939             return Optional.of(new FullyQualifiedJavadocReference(
940                     Optional.of(moduleElement.getQualifiedName().toString()),
941                     Optional.empty(),
942                     Optional.empty(),
943                     Optional.empty(),
944                     Optional.empty(),
945                     Optional.empty(),
946                     external));
947         }
948 
949         Optional<String> moduleName = external ? Optional.empty() : getModuleName(element);
950         if (element instanceof PackageElement packageElement) {
951             return Optional.of(new FullyQualifiedJavadocReference(
952                     moduleName,
953                     Optional.of(packageElement.getQualifiedName().toString()),
954                     Optional.empty(),
955                     Optional.empty(),
956                     Optional.empty(),
957                     Optional.empty(),
958                     external));
959         }
960 
961         TypeElement declaringType;
962         Optional<String> member = Optional.empty();
963         Optional<MemberType> memberType = Optional.empty();
964         if (element instanceof TypeElement typeElement) {
965             declaringType = typeElement;
966         } else if (element instanceof VariableElement variableElement
967                 && variableElement.getEnclosingElement() instanceof TypeElement typeElement) {
968             declaringType = typeElement;
969             member = Optional.of(variableElement.getSimpleName().toString());
970             memberType = Optional.of(MemberType.FIELD);
971         } else if (element instanceof ExecutableElement executableElement
972                 && executableElement.getEnclosingElement() instanceof TypeElement typeElement) {
973             declaringType = typeElement;
974             String executableName = executableElement.getKind() == ElementKind.CONSTRUCTOR
975                     ? typeElement.getSimpleName().toString()
976                     : executableElement.getSimpleName().toString();
977             String parameterTypes = String.join(
978                     ",",
979                     executableElement.getParameters().stream()
980                             .map(parameter -> getFullyQualifiedName(types.erasure(parameter.asType())))
981                             .toList());
982             member = Optional.of(executableName + "(" + parameterTypes + ")");
983             memberType = Optional.of(
984                     executableElement.getKind() == ElementKind.CONSTRUCTOR
985                             ? MemberType.CONSTRUCTOR
986                             : MemberType.METHOD);
987         } else {
988             return Optional.empty();
989         }
990 
991         PackageElement packageElement = elements.getPackageOf(declaringType);
992         String packageName = packageElement.getQualifiedName().toString();
993         String qualifiedName = declaringType.getQualifiedName().toString();
994         String className = packageName.isEmpty() ? qualifiedName : qualifiedName.substring(packageName.length() + 1);
995         return Optional.of(new FullyQualifiedJavadocReference(
996                 moduleName,
997                 Optional.of(packageName),
998                 Optional.of(className),
999                 member,
1000                 memberType,
1001                 Optional.empty(),
1002                 external));
1003     }
1004 
1005     private Optional<String> getModuleName(Element element) {
1006         ModuleElement module = elements.getModuleOf(element);
1007         return module != null && !module.isUnnamed()
1008                 ? Optional.of(module.getQualifiedName().toString())
1009                 : Optional.empty();
1010     }
1011 
1012     private boolean isInternalJavadocElement(Element element) {
1013         if (element instanceof ModuleElement) {
1014             return docTrees.getPath(element) != null;
1015         }
1016         if (element instanceof PackageElement packageElement) {
1017             return docTrees.getPath(element) != null || isPackageInInternalSourceTree(packageElement);
1018         }
1019 
1020         TypeElement topLevelType = null;
1021         for (Element current = element;
1022                 current != null && !(current instanceof PackageElement) && !(current instanceof ModuleElement);
1023                 current = current.getEnclosingElement()) {
1024             if (current instanceof TypeElement typeElement) {
1025                 topLevelType = typeElement;
1026             }
1027             if (isJavadocDeclaration(current)
1028                     && !current.getModifiers().contains(Modifier.PUBLIC)
1029                     && !current.getModifiers().contains(Modifier.PROTECTED)) {
1030                 return false;
1031             }
1032         }
1033         return topLevelType != null
1034                 && (docTrees.getPath(topLevelType) != null || isTypeInInternalSourceTree(topLevelType));
1035     }
1036 
1037     private boolean isPackageInInternalSourceTree(PackageElement packageElement) {
1038         Path packagePath = getPackagePath(packageElement);
1039         return internalJavadocSourceRoots.stream()
1040                 .map(sourceRoot -> sourceRoot.resolve(packagePath))
1041                 .anyMatch(Files::isDirectory);
1042     }
1043 
1044     private boolean isTypeInInternalSourceTree(TypeElement topLevelType) {
1045         Path packagePath = getPackagePath(elements.getPackageOf(topLevelType));
1046         Path sourceFile = packagePath.resolve(topLevelType.getSimpleName() + ".java");
1047         return internalJavadocSourceRoots.stream()
1048                 .map(sourceRoot -> sourceRoot.resolve(sourceFile))
1049                 .anyMatch(Files::isRegularFile);
1050     }
1051 
1052     private static Path getPackagePath(PackageElement packageElement) {
1053         String packageName = packageElement.getQualifiedName().toString();
1054         return packageName.isEmpty() ? Path.of("") : Path.of(packageName.replace('.', '/'));
1055     }
1056 
1057     private static boolean isJavadocDeclaration(Element element) {
1058         ElementKind kind = element.getKind();
1059         return kind.isClass()
1060                 || kind.isInterface()
1061                 || kind == ElementKind.FIELD
1062                 || kind == ElementKind.ENUM_CONSTANT
1063                 || kind == ElementKind.METHOD
1064                 || kind == ElementKind.CONSTRUCTOR;
1065     }
1066 
1067     private Optional<String> getConfigurationSource(DocTreePath path, Map<String, UnknownBlockTagTree> blockTags) {
1068         UnknownBlockTagTree configurationSourceTag = blockTags.get("configurationSource");
1069         if (configurationSourceTag == null) {
1070             return Optional.empty();
1071         }
1072         DocTreePath configurationSourcePath = DocTreePath.getPath(path, configurationSourceTag);
1073         LinkTree linkTree = getFirstLinkInBlockTag(configurationSourceTag)
1074                 .orElseThrow(() -> new DocTreePathAwareRuntimeException(
1075                         configurationSourcePath,
1076                         "No valid {@link ...} reference found in @" + configurationSourceTag.getTagName()));
1077 
1078         // javadoc signature is not normalized, use the resolved reference (leveraging ReferenceParser) to get a unique
1079         // canonical representation of the referenced method
1080         MethodReference methodReference = getReferencedMethod(configurationSourcePath, linkTree);
1081         if (methodReference.equals(METHOD_REFERENCE_SESSION_CONFIGURATION)) {
1082             return Optional.of("Session Configuration");
1083         } else if (methodReference.equals(METHOD_REFERENCE_SYSTEM_PROPERTY)) {
1084             return Optional.of("Java System Properties");
1085         } else {
1086             reporter.print(
1087                     Diagnostic.Kind.WARNING,
1088                     path,
1089                     "Unknown configuration source: " + linkTree.getReference().getSignature()
1090                             + ", using raw signature as source");
1091             return Optional.of(linkTree.getReference().getSignature());
1092         }
1093     }
1094 
1095     /**
1096      * Represents a reference to a method, including the fully qualified class name, method name, and parameter types.
1097      * This is supposed to be unique as well as canonical.
1098      * The signature within a Javadoc link is not normalized (e.g. may contain spaces or not, may contain argument names or not)
1099      * so we need to resolve the reference to get a unique representation of the method.
1100      * @param fullyQualifiedClassName the fully qualified name of the class containing the method
1101      * @param methodName the name of the method
1102      * @param fullyQualifiedParameterTypes a list of fully qualified names (for declared types) or simple names (for primitive types) of the parameter types of the method
1103      */
1104     protected record MethodReference(
1105             String fullyQualifiedClassName, String methodName, List<String> fullyQualifiedParameterTypes) {}
1106 
1107     private MethodReference getReferencedMethod(DocTreePath path, LinkTree link) {
1108         ExecutableElement ee = getReferencedExecutableElement(path, link);
1109         String fullyQualifiedClassName =
1110                 ((TypeElement) ee.getEnclosingElement()).getQualifiedName().toString();
1111         String methodName = ee.getSimpleName().toString();
1112         List<String> parameterTypes = ee.getParameters().stream()
1113                 .map(p -> getFullyQualifiedName(p.asType()))
1114                 .toList();
1115         return new MethodReference(fullyQualifiedClassName, methodName, parameterTypes);
1116     }
1117 
1118     static String getFullyQualifiedName(Element e) {
1119         return new SimpleElementVisitor14<String, Void>() {
1120             @Override
1121             public String visitModule(ModuleElement e, Void p) {
1122                 return e.getQualifiedName().toString();
1123             }
1124 
1125             @Override
1126             public String visitPackage(PackageElement e, Void p) {
1127                 return e.getQualifiedName().toString();
1128             }
1129 
1130             @Override
1131             public String visitType(TypeElement e, Void p) {
1132                 return e.getQualifiedName().toString();
1133             }
1134 
1135             @Override
1136             protected String defaultAction(Element e, Void p) {
1137                 return visit(e.getEnclosingElement()) + "." + e.getSimpleName();
1138             }
1139         }.visit(e);
1140     }
1141 
1142     static String getFullyQualifiedName(TypeMirror e) {
1143         return new SimpleTypeVisitor14<String, Void>() {
1144             @Override
1145             public String visitDeclared(DeclaredType t, Void p) {
1146                 Element e = t.asElement();
1147                 if (e instanceof TypeElement typeElement) {
1148                     return typeElement.getQualifiedName().toString();
1149                 }
1150                 return super.visitDeclared(t, p);
1151             }
1152 
1153             @Override
1154             public String visitPrimitive(PrimitiveType t, Void p) {
1155                 return t.toString();
1156             }
1157 
1158             @Override
1159             protected String defaultAction(TypeMirror e, Void p) {
1160                 return e.toString();
1161             }
1162         }.visit(e);
1163     }
1164 
1165     private ExecutableElement getReferencedExecutableElement(DocTreePath path, LinkTree link) {
1166         DocTreePath linkRefPath = DocTreePath.getPath(path, link.getReference());
1167         if (linkRefPath == null) {
1168             throw new DocTreePathAwareRuntimeException(
1169                     path,
1170                     "Could not resolve link reference: " + link.getReference().getSignature());
1171         }
1172         Element element = docTrees.getElement(linkRefPath);
1173         if (element instanceof ExecutableElement ee) {
1174             return ee;
1175         } else {
1176             throw new DocTreePathAwareRuntimeException(
1177                     linkRefPath, "Expected an executable element, but got: " + element);
1178         }
1179     }
1180 
1181     private static String toYesNo(boolean value) {
1182         return value ? "Yes" : "No";
1183     }
1184 
1185     private static URI parseJavadocUrl(String value) {
1186         URI uri = URI.create(value);
1187         if (uri.getQuery() != null || uri.getFragment() != null) {
1188             throw new IllegalArgumentException("Javadoc base URL must not contain a query or fragment: " + value);
1189         }
1190         return value.endsWith("/") ? uri : URI.create(value + "/");
1191     }
1192 
1193     /**
1194      * Minimal {@link Option} implementation.
1195      */
1196     private static final class SingleArgumentOption implements Option {
1197         private final List<String> names;
1198         private final String description;
1199         private final String parameters;
1200         private final java.util.function.Consumer<String> processor;
1201 
1202         SingleArgumentOption(
1203                 List<String> names,
1204                 String description,
1205                 String parameters,
1206                 java.util.function.Consumer<String> processor) {
1207             this.names = names;
1208             this.description = description;
1209             this.parameters = parameters;
1210             this.processor = processor;
1211         }
1212 
1213         @Override
1214         public int getArgumentCount() {
1215             return 1;
1216         }
1217 
1218         @Override
1219         public String getDescription() {
1220             return description;
1221         }
1222 
1223         @Override
1224         public Kind getKind() {
1225             return Kind.STANDARD;
1226         }
1227 
1228         @Override
1229         public List<String> getNames() {
1230             return names;
1231         }
1232 
1233         @Override
1234         public String getParameters() {
1235             return parameters;
1236         }
1237 
1238         @Override
1239         public boolean process(String option, List<String> arguments) {
1240             processor.accept(arguments.get(0));
1241             // returning false just leads to a very generic error message (not even exposing the affected option) so
1242             // rather rely on custom runtime exceptions for validation errors
1243             return true;
1244         }
1245     }
1246 }