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