Home | History | Annotate | Download | only in metalava
      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.&nbsp;" + 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 }