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