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.TypeElement;
028import javax.lang.model.element.VariableElement;
029import javax.lang.model.util.ElementFilter;
030import javax.tools.Diagnostic;
031
032import java.io.IOException;
033import java.io.UncheckedIOException;
034import java.io.Writer;
035import java.nio.charset.StandardCharsets;
036import java.nio.file.Files;
037import java.nio.file.Path;
038import java.nio.file.Paths;
039import java.util.ArrayList;
040import java.util.LinkedHashMap;
041import java.util.List;
042import java.util.Locale;
043import java.util.Map;
044import java.util.Properties;
045import java.util.Set;
046import java.util.regex.Matcher;
047import java.util.regex.Pattern;
048
049import com.sun.source.doctree.DocCommentTree;
050import com.sun.source.doctree.DocTree;
051import com.sun.source.doctree.LinkTree;
052import com.sun.source.doctree.LiteralTree;
053import com.sun.source.doctree.SinceTree;
054import com.sun.source.doctree.TextTree;
055import com.sun.source.doctree.UnknownBlockTagTree;
056import com.sun.source.tree.ExpressionTree;
057import com.sun.source.tree.IdentifierTree;
058import com.sun.source.tree.MemberSelectTree;
059import com.sun.source.tree.VariableTree;
060import com.sun.source.util.DocTreePath;
061import com.sun.source.util.DocTrees;
062import jdk.javadoc.doclet.Doclet;
063import jdk.javadoc.doclet.DocletEnvironment;
064import jdk.javadoc.doclet.Reporter;
065
066/**
067 * A custom Javadoc {@link Doclet} that scans constant fields for configuration metadata declared via custom Javadoc
068 * block tags (e.g. {@code @configurationSource}) and writes the discovered keys into an intermediate
069 * {@link Properties} file. That file is subsequently consumed by {@link CollectConfiguration} to render the
070 * documentation via Velocity templates.
071 * <p>
072 * The intermediate file uses an indexed layout:
073 * <pre>
074 * keys.count=N
075 * keys.0.key=...
076 * keys.0.description=...
077 * ...
078 * </pre>
079 */
080public class ConfigurationCollectorDoclet implements Doclet {
081
082    /**
083     * Fully qualified name of the Maven annotation that marks a configuration key when scanning Maven sources.
084     */
085    private static final String MAVEN_CONFIG_ANNOTATION = "org.apache.maven.api.annotations.Config";
086
087    private Reporter reporter;
088
089    private Path output;
090
091    /**
092     * The scanning mode; either {@code resolver} (Javadoc block tags) or {@code maven} (the {@code @Config}
093     * annotation). Defaults to {@code resolver}.
094     */
095    private String mode = "resolver";
096
097    private DocTrees docTrees;
098
099    @Override
100    public void init(Locale locale, Reporter reporter) {
101        this.reporter = reporter;
102    }
103
104    @Override
105    public String getName() {
106        return "ConfigurationCollector";
107    }
108
109    @Override
110    public Set<? extends Option> getSupportedOptions() {
111        return Set.of(
112                new SimpleOption(
113                        List.of("--output", "-o"),
114                        1,
115                        "The intermediate properties file to write discovered keys to",
116                        "<file>",
117                        args -> output = Paths.get(args.get(0))),
118                new SimpleOption(
119                        List.of("--mode", "-m"),
120                        1,
121                        "The scanning mode, either 'resolver' or 'maven'",
122                        "<mode>",
123                        args -> mode = args.get(0)));
124    }
125
126    @Override
127    public SourceVersion getSupportedSourceVersion() {
128        return SourceVersion.latest();
129    }
130
131    @Override
132    public boolean run(DocletEnvironment environment) {
133        try {
134            return doRun(environment);
135        } catch (RuntimeException e) {
136            reporter.print(Diagnostic.Kind.ERROR, "Error running ConfigurationCollectorDoclet: " + e.getMessage());
137            e.printStackTrace(reporter.getStandardWriter());
138            return false;
139        }
140    }
141
142    private boolean doRun(DocletEnvironment environment) {
143        if (output == null) {
144            throw new IllegalStateException("Missing required --output option");
145        }
146        docTrees = environment.getDocTrees();
147        List<Map<String, String>> discoveredKeys = new ArrayList<>();
148
149        Set<TypeElement> types = ElementFilter.typesIn(environment.getIncludedElements());
150        for (TypeElement type : types) {
151            for (VariableElement field : ElementFilter.fieldsIn(type.getEnclosedElements())) {
152                if (field.getConstantValue() == null) {
153                    continue;
154                }
155                DocCommentTree docComment = docTrees.getDocCommentTree(field);
156                if ("maven".equals(mode)) {
157                    processMavenField(type, field, docComment, discoveredKeys);
158                } else if ("resolver".equals(mode)) {
159                    processResolverField(type, field, docComment, discoveredKeys);
160                } else {
161                    throw new IllegalArgumentException("Unknown mode: " + mode);
162                }
163            }
164        }
165
166        writeProperties(discoveredKeys);
167        return true;
168    }
169
170    private void processResolverField(
171            TypeElement type, VariableElement field, DocCommentTree docComment, List<Map<String, String>> discovered) {
172        if (docComment == null) {
173            return;
174        }
175        Map<String, List<? extends DocTree>> blockTags = collectBlockTags(docComment);
176        if (!blockTags.containsKey("configurationSource")) {
177            return;
178        }
179
180        String configurationType =
181                getConfigurationType(extractClassLink(field, docComment, blockTags.get("configurationType")));
182        String defValue = resolveDefaultValue(type, field, docComment, blockTags.get("configurationDefaultValue"));
183
184        Map<String, String> entry = new LinkedHashMap<>();
185        entry.put("key", String.valueOf(field.getConstantValue()));
186        entry.put("defaultValue", nvl(defValue, ""));
187        entry.put("fqName", type.getQualifiedName() + "." + field.getSimpleName());
188        entry.put("description", cleanseJavadoc(renderContent(docComment.getFullBody())));
189        entry.put("since", nvl(getSince(type, docComment), ""));
190        entry.put("configurationSource", getConfigurationSource(renderContent(blockTags.get("configurationSource"))));
191        entry.put("configurationType", configurationType);
192        entry.put("supportRepoIdSuffix", toYesNo(renderContent(blockTags.get("configurationRepoIdSuffix"))));
193        discovered.add(entry);
194    }
195
196    /**
197     * Processes a constant field declared in Maven sources. Maven declares configuration keys via the
198     * {@code org.apache.maven.api.annotations.Config} annotation (rather than the custom Javadoc block tags used by
199     * Resolver), so the metadata is read from that annotation's attributes.
200     */
201    // TODO: move to Maven repository module and use the Maven annotation type directly (currently we don't have a
202    // dependency on Maven API)
203    private void processMavenField(
204            TypeElement type, VariableElement field, DocCommentTree docComment, List<Map<String, String>> discovered) {
205        AnnotationMirror config = getAnnotation(field, MAVEN_CONFIG_ANNOTATION);
206        if (config == null) {
207            return;
208        }
209
210        String source = "USER_PROPERTIES";
211        String defaultValue = "";
212        String configurationType = "java.lang.String";
213        for (Map.Entry<? extends ExecutableElement, ? extends AnnotationValue> attribute :
214                config.getElementValues().entrySet()) {
215            String name = attribute.getKey().getSimpleName().toString();
216            Object value = attribute.getValue().getValue();
217            switch (name) {
218                case "source":
219                    source = value instanceof VariableElement
220                            ? ((VariableElement) value).getSimpleName().toString()
221                            : String.valueOf(value);
222                    break;
223                case "defaultValue":
224                    defaultValue = String.valueOf(value);
225                    break;
226                case "type":
227                    configurationType = String.valueOf(value);
228                    break;
229                default:
230                    break;
231            }
232        }
233
234        source = source.toLowerCase(Locale.ROOT);
235        switch (source) {
236            case "model":
237                source = "Model properties";
238                break;
239            case "user_properties":
240                source = "User properties";
241                break;
242            case "system_properties":
243                source = "System properties";
244                break;
245            default:
246                break;
247        }
248
249        if (configurationType.startsWith("java.lang.")) {
250            configurationType = configurationType.substring("java.lang.".length());
251        } else if (configurationType.startsWith("java.util.")) {
252            configurationType = configurationType.substring("java.util.".length());
253        }
254
255        String description = docComment != null ? renderContent(docComment.getFullBody()) : "";
256        description = description.replace("*", "\\*");
257
258        Map<String, String> entry = new LinkedHashMap<>();
259        entry.put("key", String.valueOf(field.getConstantValue()));
260        entry.put("defaultValue", nvl(defaultValue, ""));
261        entry.put("fqName", "");
262        entry.put("description", nvl(description, ""));
263        entry.put("since", nvl(getSince(type, docComment), ""));
264        entry.put("configurationSource", source);
265        entry.put("configurationType", configurationType);
266        entry.put("supportRepoIdSuffix", "");
267        discovered.add(entry);
268    }
269
270    private AnnotationMirror getAnnotation(Element element, String fqName) {
271        for (AnnotationMirror annotation : element.getAnnotationMirrors()) {
272            Element annotationElement = annotation.getAnnotationType().asElement();
273            if (annotationElement instanceof TypeElement
274                    && ((TypeElement) annotationElement).getQualifiedName().contentEquals(fqName)) {
275                return annotation;
276            }
277        }
278        return null;
279    }
280
281    private void writeProperties(List<Map<String, String>> discoveredKeys) {
282        Properties properties = new Properties();
283        properties.setProperty("keys.count", String.valueOf(discoveredKeys.size()));
284        for (int i = 0; i < discoveredKeys.size(); i++) {
285            Map<String, String> entry = discoveredKeys.get(i);
286            for (Map.Entry<String, String> field : entry.entrySet()) {
287                properties.setProperty("keys." + i + "." + field.getKey(), field.getValue());
288            }
289        }
290        try {
291            if (output.getParent() != null) {
292                Files.createDirectories(output.getParent());
293            }
294            try (Writer writer = Files.newBufferedWriter(output, StandardCharsets.UTF_8)) {
295                properties.store(writer, "Generated by ConfigurationCollectorDoclet - DO NOT EDIT");
296            }
297        } catch (IOException e) {
298            throw new UncheckedIOException(e);
299        }
300    }
301
302    // --- Javadoc extraction helpers -------------------------------------------------------------------------------
303
304    private Map<String, List<? extends DocTree>> collectBlockTags(DocCommentTree docComment) {
305        Map<String, List<? extends DocTree>> result = new LinkedHashMap<>();
306        for (DocTree tag : docComment.getBlockTags()) {
307            if (tag instanceof UnknownBlockTagTree) {
308                UnknownBlockTagTree unknown = (UnknownBlockTagTree) tag;
309                result.put(unknown.getTagName(), unknown.getContent());
310            }
311        }
312        return result;
313    }
314
315    private String resolveDefaultValue(
316            TypeElement type,
317            VariableElement contextField,
318            DocCommentTree docComment,
319            List<? extends DocTree> content) {
320        if (content == null || content.isEmpty()) {
321            return null;
322        }
323        for (DocTree tree : content) {
324            if (tree instanceof LinkTree) {
325                LinkTree link = (LinkTree) tree;
326                if (link.getReference() != null) {
327                    String signature = link.getReference().getSignature();
328                    // resolve the referenced constant using the fully qualified signature, so that references
329                    // to constants declared in other types (e.g. {@link OtherType#CONSTANT}) can be resolved
330                    VariableElement referenced = resolveReferencedField(contextField, docComment, link);
331                    String value = referenced != null
332                            ? lookupConstant(referenced)
333                            : lookupConstant(type, signature.substring(signature.indexOf('#') + 1));
334                    if (value == null) {
335                        // hard fail as in the original implementation: default value constants must be resolvable
336                        throw new IllegalArgumentException("Could not look up {@link " + signature
337                                + "} for configuration " + type.getQualifiedName());
338                    }
339                    return value;
340                }
341            }
342        }
343        return renderContent(content);
344    }
345
346    /**
347     * Resolves the {@link VariableElement} a {@code {@link ...}} reference points to using the fully qualified
348     * signature (so references into other types are supported). Returns {@code null} if the reference cannot be
349     * resolved to a field.
350     */
351    private VariableElement resolveReferencedField(
352            VariableElement contextField, DocCommentTree docComment, LinkTree link) {
353        if (contextField == null || docComment == null) {
354            return null;
355        }
356        DocTreePath rootPath = new DocTreePath(docTrees.getPath(contextField), docComment);
357        DocTreePath refPath = DocTreePath.getPath(rootPath, link.getReference());
358        if (refPath == null) {
359            return null;
360        }
361        Element element = docTrees.getElement(refPath);
362        return element instanceof VariableElement ? (VariableElement) element : null;
363    }
364
365    private String lookupConstant(TypeElement type, String constantName) {
366        for (VariableElement field : ElementFilter.fieldsIn(type.getEnclosedElements())) {
367            if (field.getSimpleName().contentEquals(constantName)) {
368                String value = lookupConstant(field);
369                if (value != null) {
370                    return value;
371                }
372            }
373        }
374        return null;
375    }
376
377    private String lookupConstant(VariableElement field) {
378        if (field.getConstantValue() != null) {
379            Object value = field.getConstantValue();
380            if (value instanceof String) {
381                return "\"" + value + "\"";
382            } else {
383                return String.valueOf(field.getConstantValue());
384            }
385        }
386        // enum constants don't expose a constant value, fall back to the enum value's name
387        if (field.getKind() == ElementKind.ENUM_CONSTANT) {
388            return field.getSimpleName().toString();
389        }
390        // the field may indirectly reference an enum variable, e.g. "SomeEnum.VALUE";
391        // resolve it from the field's initializer
392        return resolveEnumReference(field);
393    }
394
395    /**
396     * Resolves an enum constant that a field is initialized with, including the enum type in the result
397     * (e.g. a field declared as {@code SomeEnum FOO = SomeEnum.VALUE} resolves to {@code SomeEnum.VALUE}).
398     * Returns {@code null} if the field's initializer is not a simple enum reference.
399     */
400    private String resolveEnumReference(VariableElement field) {
401        if (!(docTrees.getTree(field) instanceof VariableTree variableTree)) {
402            return null;
403        }
404        ExpressionTree initializer = variableTree.getInitializer();
405        String enumConstant = null;
406        if (initializer instanceof MemberSelectTree memberSelectTree) {
407            // e.g. SomeEnum.VALUE -> VALUE
408            enumConstant = memberSelectTree.getIdentifier().toString();
409        } else if (initializer instanceof IdentifierTree identifierTree) {
410            // e.g. statically imported VALUE -> VALUE
411            enumConstant = identifierTree.getName().toString();
412        }
413        if (enumConstant == null) {
414            return null;
415        }
416        return enumConstant;
417    }
418
419    private String extractClassLink(
420            VariableElement contextField, DocCommentTree docComment, List<? extends DocTree> content) {
421        if (content == null || content.isEmpty()) {
422            throw new IllegalArgumentException("Missing content for @configurationDefaultValue");
423        }
424        for (DocTree tree : content) {
425            // just use the first link, ignore any other content (e.g. text) in the tag
426            if (tree instanceof LinkTree link) {
427                String signature = link.getReference().getSignature();
428                if (signature.contains("#")) {
429                    throw new IllegalArgumentException(
430                            "Expected a class link in @configurationDefaultValue, but got a member reference: "
431                                    + signature);
432                }
433                return resolveReferencedType(contextField, docComment, link, signature);
434            }
435        }
436        throw new IllegalArgumentException("No valid {@link ...} reference found in @configurationDefaultValue");
437    }
438
439    /**
440     * Resolves the fully qualified class name a {@code {@link ...}} class reference points to (so that simple names
441     * declared via imports are expanded). Falls back to the raw signature if the reference cannot be resolved to a
442     * type.
443     */
444    private String resolveReferencedType(
445            VariableElement contextField, DocCommentTree docComment, LinkTree link, String signature) {
446        if (contextField == null || docComment == null) {
447            return signature;
448        }
449        DocTreePath rootPath = new DocTreePath(docTrees.getPath(contextField), docComment);
450        DocTreePath refPath = DocTreePath.getPath(rootPath, link.getReference());
451        if (refPath == null) {
452            return signature;
453        }
454        Element element = docTrees.getElement(refPath);
455        return element instanceof TypeElement
456                ? ((TypeElement) element).getQualifiedName().toString()
457                : signature;
458    }
459
460    private String renderContent(List<? extends DocTree> content) {
461        if (content == null) {
462            return null;
463        }
464        StringBuilder sb = new StringBuilder();
465        for (DocTree tree : content) {
466            sb.append(renderTree(tree));
467        }
468        return sb.toString().trim();
469    }
470
471    private String renderTree(DocTree tree) {
472        switch (tree.getKind()) {
473            case TEXT:
474                return ((TextTree) tree).getBody();
475            case LINK:
476            case LINK_PLAIN:
477                LinkTree link = (LinkTree) tree;
478                String ref = link.getReference() != null ? link.getReference().getSignature() : "";
479                return "{@link " + ref + "}";
480            case CODE:
481                return "{@code " + ((LiteralTree) tree).getBody().getBody() + "}";
482            case LITERAL:
483                return "{@literal " + ((LiteralTree) tree).getBody().getBody() + "}";
484            default:
485                return tree.toString();
486        }
487    }
488
489    private String getSince(TypeElement type, DocCommentTree docComment) {
490        String since = getSinceTag(docComment);
491        if (since == null && type != null) {
492            // fall back to the enclosing type's @since
493            since = getSinceTag(docTrees.getDocCommentTree(type));
494        }
495        return since;
496    }
497
498    private String getSinceTag(DocCommentTree docComment) {
499        if (docComment == null) {
500            return null;
501        }
502        for (DocTree tag : docComment.getBlockTags()) {
503            if (tag instanceof SinceTree sinceTree) {
504                return renderContent(sinceTree.getBody());
505            }
506        }
507        return null;
508    }
509
510    private String getConfigurationType(String type) {
511        if (type != null) {
512            String linkPrefix = "{@link ";
513            String linkSuffix = "}";
514            if (type.startsWith(linkPrefix) && type.endsWith(linkSuffix)) {
515                type = type.substring(linkPrefix.length(), type.length() - linkSuffix.length());
516            }
517            String javaLangPackage = "java.lang.";
518            if (type.startsWith(javaLangPackage)) {
519                type = type.substring(javaLangPackage.length());
520            }
521        }
522        return nvl(type, "n/a");
523    }
524
525    private String getConfigurationSource(String source) {
526        if ("{@link RepositorySystemSession#getConfigProperties()}".equals(source)) {
527            return "Session Configuration";
528        } else if ("{@link System#getProperty(String,String)}".equals(source)) {
529            return "Java System Properties";
530        } else {
531            return source;
532        }
533    }
534
535    private String cleanseJavadoc(String fullText) {
536        String[] lines = fullText.split("\n");
537        StringBuilder result = new StringBuilder();
538        for (String line : lines) {
539            if (!line.startsWith("@") && !line.trim().isEmpty()) {
540                result.append(line);
541            }
542        }
543        return cleanseTags(result.toString());
544    }
545
546    private String cleanseTags(String text) {
547        // {@code XXX} -> <code>XXX</code>
548        // {@link XXX} -> <code>XXX</code>
549        Pattern pattern = Pattern.compile("(\\{@\\w\\w\\w\\w (.+?)})");
550        Matcher matcher = pattern.matcher(text);
551        if (!matcher.find()) {
552            return text;
553        }
554        int prevEnd = 0;
555        StringBuilder result = new StringBuilder();
556        do {
557            result.append(text, prevEnd, matcher.start(1));
558            result.append("<code>");
559            result.append(matcher.group(2));
560            result.append("</code>");
561            prevEnd = matcher.end(1);
562        } while (matcher.find());
563        result.append(text, prevEnd, text.length());
564        return result.toString();
565    }
566
567    private String toYesNo(String value) {
568        return "yes".equalsIgnoreCase(value) || "true".equalsIgnoreCase(value) ? "Yes" : "No";
569    }
570
571    private String nvl(String string, String def) {
572        return string == null ? def : string;
573    }
574
575    /**
576     * Minimal {@link Option} implementation.
577     */
578    private static final class SimpleOption implements Option {
579        private final List<String> names;
580        private final int argumentCount;
581        private final String description;
582        private final String parameters;
583        private final java.util.function.Consumer<List<String>> processor;
584
585        SimpleOption(
586                List<String> names,
587                int argumentCount,
588                String description,
589                String parameters,
590                java.util.function.Consumer<List<String>> processor) {
591            this.names = names;
592            this.argumentCount = argumentCount;
593            this.description = description;
594            this.parameters = parameters;
595            this.processor = processor;
596        }
597
598        @Override
599        public int getArgumentCount() {
600            return argumentCount;
601        }
602
603        @Override
604        public String getDescription() {
605            return description;
606        }
607
608        @Override
609        public Kind getKind() {
610            return Kind.STANDARD;
611        }
612
613        @Override
614        public List<String> getNames() {
615            return names;
616        }
617
618        @Override
619        public String getParameters() {
620            return parameters;
621        }
622
623        @Override
624        public boolean process(String option, List<String> arguments) {
625            processor.accept(arguments);
626            return true;
627        }
628    }
629}