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