001/*
002 * Licensed to the Apache Software Foundation (ASF) under one
003 * or more contributor license agreements.  See the NOTICE file
004 * distributed with this work for additional information
005 * regarding copyright ownership.  The ASF licenses this file
006 * to you under the Apache License, Version 2.0 (the
007 * "License"); you may not use this file except in compliance
008 * with the License.  You may obtain a copy of the License at
009 *
010 *   http://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing,
013 * software distributed under the License is distributed on an
014 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
015 * KIND, either express or implied.  See the License for the
016 * specific language governing permissions and limitations
017 * under the License.
018 */
019package org.eclipse.aether.tools;
020
021import javax.lang.model.SourceVersion;
022import javax.lang.model.element.AnnotationMirror;
023import javax.lang.model.element.AnnotationValue;
024import javax.lang.model.element.Element;
025import javax.lang.model.element.ElementKind;
026import javax.lang.model.element.ExecutableElement;
027import javax.lang.model.element.ModuleElement;
028import javax.lang.model.element.PackageElement;
029import javax.lang.model.element.TypeElement;
030import javax.lang.model.element.VariableElement;
031import javax.lang.model.type.DeclaredType;
032import javax.lang.model.type.PrimitiveType;
033import javax.lang.model.type.TypeMirror;
034import javax.lang.model.util.ElementFilter;
035import javax.lang.model.util.SimpleElementVisitor14;
036import javax.lang.model.util.SimpleTypeVisitor14;
037import javax.tools.Diagnostic;
038
039import java.io.IOException;
040import java.io.PrintWriter;
041import java.io.Writer;
042import java.nio.charset.StandardCharsets;
043import java.nio.file.Files;
044import java.nio.file.InvalidPathException;
045import java.nio.file.Path;
046import java.nio.file.Paths;
047import java.util.ArrayList;
048import java.util.Arrays;
049import java.util.Collection;
050import java.util.Collections;
051import java.util.LinkedHashMap;
052import java.util.List;
053import java.util.Locale;
054import java.util.Map;
055import java.util.Objects;
056import java.util.Optional;
057import java.util.Properties;
058import java.util.Set;
059
060import com.sun.source.doctree.DeprecatedTree;
061import com.sun.source.doctree.DocCommentTree;
062import com.sun.source.doctree.DocTree;
063import com.sun.source.doctree.EntityTree;
064import com.sun.source.doctree.LinkTree;
065import com.sun.source.doctree.LiteralTree;
066import com.sun.source.doctree.ReferenceTree;
067import com.sun.source.doctree.SinceTree;
068import com.sun.source.doctree.SystemPropertyTree;
069import com.sun.source.doctree.TextTree;
070import com.sun.source.doctree.UnknownBlockTagTree;
071import com.sun.source.doctree.ValueTree;
072import com.sun.source.tree.ExpressionTree;
073import com.sun.source.tree.IdentifierTree;
074import com.sun.source.tree.MemberSelectTree;
075import com.sun.source.tree.VariableTree;
076import com.sun.source.util.DocTreePath;
077import com.sun.source.util.DocTrees;
078import com.sun.source.util.SimpleDocTreeVisitor;
079import jdk.javadoc.doclet.Doclet;
080import jdk.javadoc.doclet.DocletEnvironment;
081import jdk.javadoc.doclet.Reporter;
082
083/**
084 * A custom Javadoc {@link Doclet} that scans constant fields for configuration metadata declared via custom Javadoc
085 * block tags (e.g. {@code @configurationSource}) and writes the discovered keys into an intermediate
086 * {@link Properties} file. That file is subsequently consumed by {@link CollectConfiguration} to render the
087 * documentation via Velocity templates.
088 * <p>
089 * The intermediate file uses an indexed layout:
090 * <pre>
091 * keys.count=N
092 * keys.0.key=...
093 * keys.0.description=...
094 * ...
095 * </pre>
096 */
097public class ConfigurationCollectorDoclet implements Doclet {
098
099    /**
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}