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