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