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.SeeTree;
072import com.sun.source.doctree.SinceTree;
073import com.sun.source.doctree.SystemPropertyTree;
074import com.sun.source.doctree.TextTree;
075import com.sun.source.doctree.UnknownBlockTagTree;
076import com.sun.source.doctree.ValueTree;
077import com.sun.source.tree.ExpressionTree;
078import com.sun.source.tree.IdentifierTree;
079import com.sun.source.tree.MemberSelectTree;
080import com.sun.source.tree.VariableTree;
081import com.sun.source.util.DocTreePath;
082import com.sun.source.util.DocTrees;
083import com.sun.source.util.SimpleDocTreeVisitor;
084import jdk.javadoc.doclet.Doclet;
085import jdk.javadoc.doclet.DocletEnvironment;
086import jdk.javadoc.doclet.Reporter;
087import org.apache.maven.tools.plugin.javadoc.FullyQualifiedJavadocReference;
088import org.apache.maven.tools.plugin.javadoc.FullyQualifiedJavadocReference.MemberType;
089import org.apache.maven.tools.plugin.javadoc.JavadocLinkGenerator;
090
091/**
092 * A custom Javadoc {@link Doclet} that scans constant fields for configuration metadata declared via custom Javadoc
093 * block tags (e.g. {@code @configurationSource}) and writes the discovered keys into an intermediate
094 * {@link Properties} file. That file is subsequently consumed by {@link CollectConfiguration} to render the
095 * documentation via Velocity templates.
096 * <p>
097 * The intermediate file uses an indexed layout:
098 * <pre>
099 * keys.count=N
100 * keys.0.key=...
101 * keys.0.description=...
102 * ...
103 * </pre>
104 */
105public 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}