1 package com.android.tools.metalava 2 3 import com.android.sdklib.SdkVersionInfo 4 import com.android.tools.lint.LintCliClient 5 import com.android.tools.lint.checks.ApiLookup 6 import com.android.tools.lint.helpers.DefaultJavaEvaluator 7 import com.android.tools.metalava.doclava1.Errors 8 import com.android.tools.metalava.model.AnnotationAttributeValue 9 import com.android.tools.metalava.model.AnnotationItem 10 import com.android.tools.metalava.model.ClassItem 11 import com.android.tools.metalava.model.Codebase 12 import com.android.tools.metalava.model.FieldItem 13 import com.android.tools.metalava.model.Item 14 import com.android.tools.metalava.model.MemberItem 15 import com.android.tools.metalava.model.MethodItem 16 import com.android.tools.metalava.model.ParameterItem 17 import com.android.tools.metalava.model.visitors.ApiVisitor 18 import com.android.tools.metalava.model.visitors.VisibleItemVisitor 19 import com.intellij.psi.PsiClass 20 import com.intellij.psi.PsiField 21 import com.intellij.psi.PsiMethod 22 import java.io.File 23 import java.util.HashMap 24 import java.util.regex.Pattern 25 26 /** 27 * Walk over the API and apply tweaks to the documentation, such as 28 * - Looking for annotations and converting them to auxiliary tags 29 * that will be processed by the documentation tools later. 30 * - Reading lint's API database and inserting metadata into 31 * the documentation like api levels and deprecation levels. 32 * - Transferring docs from hidden super methods. 33 * - Performing tweaks for common documentation mistakes, such as 34 * ending the first sentence with ", e.g. " where javadoc will sadly 35 * see the ". " and think "aha, that's the end of the sentence!" 36 * (It works around this by replacing the space with .) 37 * This will also attempt to fix common typos (Andriod->Android etc). 38 */ 39 class DocAnalyzer( 40 /** The codebase to analyze */ 41 private val codebase: Codebase 42 ) { 43 44 /** Computes the visible part of the API from all the available code in the codebase */ 45 fun enhance() { 46 // Apply options for packages that should be hidden 47 documentsFromAnnotations() 48 49 tweakGrammar() 50 51 injectArtifactIds() 52 53 // TODO: 54 // insertMissingDocFromHiddenSuperclasses() 55 } 56 57 private fun injectArtifactIds() { 58 val artifacts = options.artifactRegistrations 59 if (!artifacts.any()) { 60 return 61 } 62 63 artifacts.tag(codebase) 64 65 codebase.accept(object : VisibleItemVisitor() { 66 override fun visitClass(cls: ClassItem) { 67 cls.artifact?.let { 68 cls.appendDocumentation(it, "@artifactId") 69 } 70 } 71 }) 72 } 73 74 val mentionsNull: Pattern = Pattern.compile("\\bnull\\b") 75 76 /** Hide packages explicitly listed in [Options.hidePackages] */ 77 private fun documentsFromAnnotations() { 78 // Note: Doclava1 inserts its own javadoc parameters into the documentation, 79 // which is then later processed by javadoc to insert actual descriptions. 80 // This indirection makes the actual descriptions of the annotations more 81 // configurable from a separate file -- but since this tool isn't hooked 82 // into javadoc anymore (and is going to be used by for example Dokka too) 83 // instead metalava will generate the descriptions directly in-line into the 84 // docs. 85 // 86 // This does mean that you have to update the metalava source code to update 87 // the docs -- but on the other hand all the other docs in the documentation 88 // set also requires updating framework source code, so this doesn't seem 89 // like an unreasonable burden. 90 91 codebase.accept(object : ApiVisitor(codebase) { 92 override fun visitItem(item: Item) { 93 val annotations = item.modifiers.annotations() 94 if (annotations.isEmpty()) { 95 return 96 } 97 98 for (annotation in annotations) { 99 handleAnnotation(annotation, item, depth = 0) 100 } 101 102 /* Handled via @memberDoc/@classDoc on the annotations themselves right now. 103 That doesn't handle combinations of multiple thread annotations, but those 104 don't occur yet, right? 105 // Threading annotations: can't look at them one at a time; need to list them 106 // all together 107 if (item is ClassItem || item is MethodItem) { 108 val threads = findThreadAnnotations(annotations) 109 threads?.let { 110 val threadList = it.joinToString(separator = " or ") + 111 (if (it.size == 1) " thread" else " threads") 112 val doc = if (item is ClassItem) { 113 "All methods in this class must be invoked on the $threadList, unless otherwise noted" 114 } else { 115 assert(item is MethodItem) 116 "This method must be invoked on the $threadList" 117 } 118 appendDocumentation(doc, item, false) 119 } 120 } 121 */ 122 if (findThreadAnnotations(annotations).size > 1) { 123 reporter.warning( 124 item, "Found more than one threading annotation on $item; " + 125 "the auto-doc feature does not handle this correctly", 126 Errors.MULTIPLE_THREAD_ANNOTATIONS 127 ) 128 } 129 } 130 131 private fun findThreadAnnotations(annotations: List<AnnotationItem>): List<String> { 132 var result: MutableList<String>? = null 133 for (annotation in annotations) { 134 val name = annotation.qualifiedName() 135 if (name != null && name.endsWith("Thread") && 136 (name.startsWith(ANDROID_SUPPORT_ANNOTATION_PREFIX) || 137 name.startsWith(ANDROIDX_ANNOTATION_PREFIX)) 138 ) { 139 if (result == null) { 140 result = mutableListOf() 141 } 142 val threadName = if (name.endsWith("UiThread")) { 143 "UI" 144 } else { 145 name.substring(name.lastIndexOf('.') + 1, name.length - "Thread".length) 146 } 147 result.add(threadName) 148 } 149 } 150 return result ?: emptyList() 151 } 152 153 /** Fallback if field can't be resolved or if an inlined string value is used */ 154 private fun findPermissionField(codebase: Codebase, value: Any): FieldItem? { 155 val perm = value.toString() 156 val permClass = codebase.findClass("android.Manifest.permission") 157 permClass?.fields()?.filter { 158 it.initialValue(requireConstant = false)?.toString() == perm 159 }?.forEach { return it } 160 return null 161 } 162 163 private fun handleAnnotation( 164 annotation: AnnotationItem, 165 item: Item, 166 depth: Int 167 ) { 168 val name = annotation.qualifiedName() 169 if (name == null || name.startsWith(JAVA_LANG_PREFIX)) { 170 // Ignore java.lang.Retention etc. 171 return 172 } 173 174 // Some annotations include the documentation they want inlined into usage docs. 175 // Copy those here: 176 177 if (annotation.isNullable() || annotation.isNonNull()) { 178 // Some docs already specifically talk about null policy; in that case, 179 // don't include the docs (since it may conflict with more specific conditions 180 // outlined in the docs). 181 if (item.documentation.contains("null") && 182 mentionsNull.matcher(item.documentation).find() 183 ) { 184 return 185 } 186 } 187 188 when (item) { 189 is FieldItem -> { 190 addDoc(annotation, "memberDoc", item) 191 } 192 is MethodItem -> { 193 addDoc(annotation, "memberDoc", item) 194 addDoc(annotation, "returnDoc", item) 195 } 196 is ParameterItem -> { 197 addDoc(annotation, "paramDoc", item) 198 } 199 is ClassItem -> { 200 addDoc(annotation, "classDoc", item) 201 } 202 } 203 204 // Document required permissions 205 if (item is MemberItem && name == "androidx.annotation.RequiresPermission") { 206 var values: List<AnnotationAttributeValue>? = null 207 var any = false 208 var conditional = false 209 for (attribute in annotation.attributes()) { 210 when (attribute.name) { 211 "value", "allOf" -> { 212 values = attribute.leafValues() 213 } 214 "anyOf" -> { 215 any = true 216 values = attribute.leafValues() 217 } 218 "conditional" -> { 219 conditional = attribute.value.value() == true 220 } 221 } 222 } 223 224 if (values != null && values.isNotEmpty() && !conditional) { 225 // Look at macros_override.cs for the usage of these 226 // tags. In particular, search for def:dump_permission 227 228 val sb = StringBuilder(100) 229 sb.append("Requires ") 230 var first = true 231 for (value in values) { 232 when { 233 first -> first = false 234 any -> sb.append(" or ") 235 else -> sb.append(" and ") 236 } 237 238 val resolved = value.resolve() 239 val field = if (resolved is FieldItem) 240 resolved 241 else { 242 val v: Any = value.value() ?: value.toSource() 243 findPermissionField(codebase, v) 244 } 245 if (field == null) { 246 reporter.report( 247 Errors.MISSING_PERMISSION, item, 248 "Cannot find permission field for $value required by $item (may be hidden or removed)" 249 ) 250 sb.append(value.toSource()) 251 } else { 252 if (field.isHiddenOrRemoved()) { 253 reporter.report( 254 Errors.MISSING_PERMISSION, item, 255 "Permission $value required by $item is hidden or removed" 256 ) 257 } 258 sb.append("{@link ${field.containingClass().qualifiedName()}#${field.name()}}") 259 } 260 } 261 262 appendDocumentation(sb.toString(), item, false) 263 } 264 } 265 266 // Document value ranges 267 if (name == "androidx.annotation.IntRange" || name == "androidx.annotation.FloatRange") { 268 val from: String? = annotation.findAttribute("from")?.value?.toSource() 269 val to: String? = annotation.findAttribute("to")?.value?.toSource() 270 // TODO: inclusive/exclusive attributes on FloatRange! 271 if (from != null || to != null) { 272 val args = HashMap<String, String>() 273 if (from != null) args["from"] = from 274 if (from != null) args["from"] = from 275 if (to != null) args["to"] = to 276 val doc = if (from != null && to != null) { 277 "Value is between $from and $to inclusive" 278 } else if (from != null) { 279 "Value is $from or greater" 280 } else if (to != null) { 281 "Value is $to or less" 282 } else { 283 null 284 } 285 appendDocumentation(doc, item, true) 286 } 287 } 288 289 // Document expected constants 290 if (name == "androidx.annotation.IntDef" || name == "androidx.annotation.LongDef" || 291 name == "androidx.annotation.StringDef" 292 ) { 293 val values = annotation.findAttribute("value")?.leafValues() ?: return 294 val flag = annotation.findAttribute("flag")?.value?.toSource() == "true" 295 296 // Look at macros_override.cs for the usage of these 297 // tags. In particular, search for def:dump_int_def 298 299 val sb = StringBuilder(100) 300 sb.append("Value is ") 301 if (flag) { 302 sb.append("either <code>0</code> or ") 303 if (values.size > 1) { 304 sb.append("a combination of ") 305 } 306 } 307 308 values.forEachIndexed { index, value -> 309 sb.append( 310 when (index) { 311 0 -> { 312 "" 313 } 314 values.size - 1 -> { 315 if (flag) { 316 ", and " 317 } else { 318 ", or " 319 } 320 } 321 else -> { 322 ", " 323 } 324 } 325 ) 326 327 val field = value.resolve() 328 if (field is FieldItem) 329 sb.append("{@link ${field.containingClass().qualifiedName()}#${field.name()}}") 330 else { 331 sb.append(value.toSource()) 332 } 333 } 334 appendDocumentation(sb.toString(), item, true) 335 } 336 337 // Required Features 338 if (name == "android.annotation.RequiresFeature") { 339 val value = annotation.findAttribute("value")?.leafValues()?.firstOrNull() ?: return 340 val sb = StringBuilder(100) 341 val resolved = value.resolve() 342 val field = resolved as? FieldItem 343 sb.append("Requires the ") 344 if (field == null) { 345 reporter.report( 346 Errors.MISSING_PERMISSION, item, 347 "Cannot find feature field for $value required by $item (may be hidden or removed)" 348 ) 349 sb.append("{@link ${value.toSource()}}") 350 } else { 351 if (field.isHiddenOrRemoved()) { 352 reporter.report( 353 Errors.MISSING_PERMISSION, item, 354 "Feature field $value required by $item is hidden or removed" 355 ) 356 } 357 358 sb.append("{@link ${field.containingClass().qualifiedName()}#${field.name()} ${field.containingClass().simpleName()}#${field.name()}} ") 359 } 360 361 sb.append("feature which can be detected using ") 362 sb.append("{@link android.content.pm.PackageManager#hasSystemFeature(String) ") 363 sb.append("PackageManager.hasSystemFeature(String)}.") 364 appendDocumentation(sb.toString(), item, false) 365 } 366 367 // Required API levels 368 if (name == "androidx.annotation.RequiresApi") { 369 val level = run { 370 val api = annotation.findAttribute("api")?.leafValues()?.firstOrNull()?.value() 371 if (api == null || api == 1) { 372 annotation.findAttribute("value")?.leafValues()?.firstOrNull()?.value() ?: return 373 } else { 374 api 375 } 376 } 377 378 if (level is Int) { 379 addApiLevelDocumentation(level, item) 380 } 381 } 382 383 // Thread annotations are ignored here because they're handled as a group afterwards 384 385 // TODO: Resource type annotations 386 387 // Handle inner annotations 388 annotation.resolve()?.modifiers?.annotations()?.forEach { nested -> 389 if (depth == 20) { // Temp debugging 390 throw StackOverflowError( 391 "Unbounded recursion, processing annotation " + 392 "${annotation.toSource()} in $item in ${item.compilationUnit()} " 393 ) 394 } 395 handleAnnotation(nested, item, depth + 1) 396 } 397 } 398 }) 399 } 400 401 /** 402 * Appends the given documentation to the given item. 403 * If it's documentation on a parameter, it is redirected to the surrounding method's 404 * documentation. 405 * 406 * If the [returnValue] flag is true, the documentation is added to the description text 407 * of the method, otherwise, it is added to the return tag. This lets for example 408 * a threading annotation requirement be listed as part of a method description's 409 * text, and a range annotation be listed as part of the return value description. 410 * */ 411 private fun appendDocumentation(doc: String?, item: Item, returnValue: Boolean) { 412 doc ?: return 413 414 when (item) { 415 is ParameterItem -> item.containingMethod().appendDocumentation(doc, item.name()) 416 is MethodItem -> 417 // Document as part of return annotation, not member doc 418 item.appendDocumentation(doc, if (returnValue) "@return" else null) 419 else -> item.appendDocumentation(doc) 420 } 421 } 422 423 private fun addDoc(annotation: AnnotationItem, tag: String, item: Item) { 424 // TODO: Cache: we shouldn't have to keep looking this up over and over 425 // for example for the nullable/non-nullable annotation classes that 426 // are used everywhere! 427 val cls = annotation.resolve() ?: return 428 429 val documentation = cls.findTagDocumentation(tag) 430 if (documentation != null) { 431 assert(documentation.startsWith("@$tag")) { documentation } 432 // TODO: Insert it in the right place (@return or @param) 433 val section = when { 434 documentation.startsWith("@returnDoc") -> "@return" 435 documentation.startsWith("@paramDoc") -> "@param" 436 documentation.startsWith("@memberDoc") -> null 437 else -> null 438 } 439 val insert = stripMetaTags(documentation.substring(tag.length + 2)) 440 item.appendDocumentation(insert, section) // 2: @ and space after tag 441 } 442 } 443 444 private fun stripMetaTags(string: String): String { 445 // Get rid of @hide and @remove tags etc that are part of documentation snippets 446 // we pull in, such that we don't accidentally start applying this to the 447 // item that is pulling in the documentation. 448 if (string.contains("@hide") || string.contains("@remove")) { 449 return string.replace("@hide", "").replace("@remove", "") 450 } 451 return string 452 } 453 454 /** Replacements to perform in documentation */ 455 val typos = mapOf( 456 "Andriod" to "Android", 457 "Kitkat" to "KitKat", 458 "LemonMeringuePie" to "Lollipop", 459 "LMP" to "Lollipop", 460 "KeyLimePie" to "KitKat", 461 "KLP" to "KitKat" 462 ) 463 464 private fun tweakGrammar() { 465 codebase.accept(object : VisibleItemVisitor() { 466 override fun visitItem(item: Item) { 467 var doc = item.documentation 468 if (doc.isBlank()) { 469 return 470 } 471 472 for (typo in typos.keys) { 473 if (doc.contains(typo)) { 474 val replacement = typos[typo] ?: continue 475 reporter.report( 476 Errors.TYPO, 477 item, 478 "Replaced $typo with $replacement in documentation for $item" 479 ) 480 doc = doc.replace(typo, replacement, false) 481 item.documentation = doc 482 } 483 } 484 485 val firstDot = doc.indexOf(".") 486 if (firstDot > 0 && doc.regionMatches(firstDot - 1, "e.g. ", 0, 5, false)) { 487 doc = doc.substring(0, firstDot) + ".g. " + doc.substring(firstDot + 4) 488 item.documentation = doc 489 } 490 } 491 }) 492 } 493 494 fun applyApiLevels(applyApiLevelsXml: File) { 495 val client = object : LintCliClient() { 496 override fun findResource(relativePath: String): File? { 497 if (relativePath == ApiLookup.XML_FILE_PATH) { 498 return applyApiLevelsXml 499 } 500 return super.findResource(relativePath) 501 } 502 503 override fun getCacheDir(name: String?, create: Boolean): File? { 504 val dir = File(System.getProperty("java.io.tmpdir")) 505 if (create) { 506 dir.mkdirs() 507 } 508 return dir 509 } 510 } 511 512 val apiLookup = ApiLookup.get(client) 513 514 codebase.accept(object : ApiVisitor(codebase, visitConstructorsAsMethods = false) { 515 override fun visitMethod(method: MethodItem) { 516 val psiMethod = method.psi() as PsiMethod 517 addApiLevelDocumentation(apiLookup.getMethodVersion(psiMethod), method) 518 addDeprecatedDocumentation(apiLookup.getMethodDeprecatedIn(psiMethod), method) 519 } 520 521 override fun visitClass(cls: ClassItem) { 522 val psiClass = cls.psi() as PsiClass 523 addApiLevelDocumentation(apiLookup.getClassVersion(psiClass), cls) 524 addDeprecatedDocumentation(apiLookup.getClassDeprecatedIn(psiClass), cls) 525 } 526 527 override fun visitField(field: FieldItem) { 528 val psiField = field.psi() as PsiField 529 addApiLevelDocumentation(apiLookup.getFieldVersion(psiField), field) 530 addDeprecatedDocumentation(apiLookup.getFieldDeprecatedIn(psiField), field) 531 } 532 }) 533 } 534 535 private fun addApiLevelDocumentation(level: Int, item: Item) { 536 if (level > 1) { 537 appendDocumentation("Requires API level $level", item, false) 538 // Also add @since tag, unless already manually entered. 539 // TODO: Override it everywhere in case the existing doc is wrong (we know 540 // better), and at least for OpenJDK sources we *should* since the since tags 541 // are talking about language levels rather than API levels! 542 if (!item.documentation.contains("@since")) { 543 item.appendDocumentation(describeApiLevel(level), "@since") 544 } 545 } 546 } 547 548 private fun addDeprecatedDocumentation(level: Int, item: Item) { 549 if (level > 1) { 550 // TODO: *pre*pend instead! 551 val description = 552 "<p class=\"caution\"><strong>This class was deprecated in API level 21.</strong></p>" 553 item.appendDocumentation(description, "@deprecated", append = false) 554 } 555 } 556 557 private fun describeApiLevel(level: Int): String { 558 return "${SdkVersionInfo.getVersionString(level)} ${SdkVersionInfo.getCodeName(level)} ($level)" 559 } 560 } 561 562 fun ApiLookup.getClassVersion(cls: PsiClass): Int { 563 val owner = cls.qualifiedName ?: return -1 564 return getClassVersion(owner) 565 } 566 567 fun ApiLookup.getMethodVersion(method: PsiMethod): Int { 568 val containingClass = method.containingClass ?: return -1 569 val owner = containingClass.qualifiedName ?: return -1 570 val evaluator = DefaultJavaEvaluator(null, null) 571 val desc = evaluator.getMethodDescription(method, false, false) 572 return getMethodVersion(owner, method.name, desc) 573 } 574 575 fun ApiLookup.getFieldVersion(field: PsiField): Int { 576 val containingClass = field.containingClass ?: return -1 577 val owner = containingClass.qualifiedName ?: return -1 578 return getFieldVersion(owner, field.name) 579 } 580 581 fun ApiLookup.getClassDeprecatedIn(cls: PsiClass): Int { 582 val owner = cls.qualifiedName ?: return -1 583 return getClassDeprecatedIn(owner) 584 } 585 586 fun ApiLookup.getMethodDeprecatedIn(method: PsiMethod): Int { 587 val containingClass = method.containingClass ?: return -1 588 val owner = containingClass.qualifiedName ?: return -1 589 val evaluator = DefaultJavaEvaluator(null, null) 590 val desc = evaluator.getMethodDescription(method, false, false) 591 return getMethodDeprecatedIn(owner, method.name, desc) 592 } 593 594 fun ApiLookup.getFieldDeprecatedIn(field: PsiField): Int { 595 val containingClass = field.containingClass ?: return -1 596 val owner = containingClass.qualifiedName ?: return -1 597 return getFieldDeprecatedIn(owner, field.name) 598 }