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