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