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