1 /* <lambda>null2 * Copyright 2020 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 @file:Suppress("UnstableApiUsage") 18 19 package androidx.build.lint 20 21 import com.android.tools.lint.client.api.JavaEvaluator 22 import com.android.tools.lint.client.api.UElementHandler 23 import com.android.tools.lint.detector.api.Category 24 import com.android.tools.lint.detector.api.Detector 25 import com.android.tools.lint.detector.api.Implementation 26 import com.android.tools.lint.detector.api.Incident 27 import com.android.tools.lint.detector.api.Issue 28 import com.android.tools.lint.detector.api.JavaContext 29 import com.android.tools.lint.detector.api.Scope 30 import com.android.tools.lint.detector.api.Severity 31 import com.android.tools.lint.model.DefaultLintModelMavenName 32 import com.android.tools.lint.model.LintModelMavenName 33 import com.intellij.psi.PsiCompiledElement 34 import java.io.File 35 import java.io.FileNotFoundException 36 import org.jetbrains.uast.UAnnotated 37 import org.jetbrains.uast.UAnnotation 38 import org.jetbrains.uast.UCallExpression 39 import org.jetbrains.uast.UClass 40 import org.jetbrains.uast.UClassLiteralExpression 41 import org.jetbrains.uast.UElement 42 import org.jetbrains.uast.UExpression 43 import org.jetbrains.uast.UastCallKind 44 import org.jetbrains.uast.resolveToUElement 45 import org.jetbrains.uast.toUElement 46 47 /** Prevents usage of experimental annotations outside the groups in which they were defined. */ 48 class BanInappropriateExperimentalUsage : Detector(), Detector.UastScanner { 49 50 override fun getApplicableUastTypes() = listOf(UAnnotation::class.java) 51 52 override fun createUastHandler(context: JavaContext): UElementHandler { 53 return AnnotationChecker(context) 54 } 55 56 private inner class AnnotationChecker(val context: JavaContext) : UElementHandler() { 57 val atomicGroupList: List<String> by lazy { loadAtomicLibraryGroupList() } 58 59 override fun visitAnnotation(node: UAnnotation) { 60 val signature = node.qualifiedName 61 62 if (DEBUG) { 63 if (APPLICABLE_ANNOTATIONS.contains(signature) && node.sourcePsi != null) { 64 (node.uastParent as? UClass)?.let { annotation -> 65 println( 66 "${context.driver.mode}: declared ${annotation.qualifiedName} in " + 67 "${context.project}" 68 ) 69 } 70 } 71 } 72 73 /** 74 * If the annotation under evaluation is a form of @OptIn, extract and evaluate the 75 * annotation(s) referenced by @OptIn - denoted by its markerClass. 76 */ 77 if (signature != null && APPLICATION_OPT_IN_ANNOTATIONS.contains(signature)) { 78 if (DEBUG) { 79 println("Found an @OptIn annotation. Attempting to find markerClass element(s)") 80 } 81 82 val markerClass: UExpression? = node.findAttributeValue("markerClass") 83 if (markerClass != null) { 84 val markerClasses = getUElementsFromOptInMarkerClass(markerClass) 85 86 if (DEBUG && markerClasses.isNotEmpty()) { 87 println("Found ${markerClasses.size} markerClass(es): ") 88 } 89 90 markerClasses.forEach { uElement -> 91 if (DEBUG) { 92 println( 93 "Inspecting markerClass annotation " + uElement.getQualifiedName() 94 ) 95 } 96 inspectAnnotation(uElement, node) 97 } 98 } 99 100 /** 101 * @OptIn has no effect if its markerClass isn't provided. Similarly, if 102 * [getUElementsFromOptInMarkerClass] returns an empty list then there isn't 103 * anything more to inspect. 104 * 105 * In both of these cases we can stop processing here. 106 */ 107 return 108 } 109 110 inspectAnnotation(node.resolveToUElement(), node) 111 } 112 113 private fun getUElementsFromOptInMarkerClass(markerClass: UExpression): List<UElement> { 114 val elements = ArrayList<UElement?>() 115 116 when { 117 markerClass is UClassLiteralExpression -> { 118 // opting in to single annotation 119 elements.add(markerClass.toUElement()) 120 } 121 markerClass is UCallExpression && 122 markerClass.kind == UastCallKind.NESTED_ARRAY_INITIALIZER -> { 123 // opting in to multiple annotations 124 val expressions: List<UExpression> = markerClass.valueArguments 125 for (expression in expressions) { 126 val uElement = (expression as UClassLiteralExpression).toUElement() 127 elements.add(uElement) 128 } 129 } 130 else -> { 131 // do nothing 132 } 133 } 134 135 return elements.filterNotNull() 136 } 137 138 private fun UClassLiteralExpression.toUElement(): UElement? { 139 val psiType = this.type 140 val psiClass = context.evaluator.getTypeClass(psiType) 141 return psiClass.toUElement() 142 } 143 144 // If we find an usage of an experimentally-declared annotation, check it. 145 private fun inspectAnnotation(annotation: UElement?, node: UAnnotation) { 146 if (annotation is UAnnotated) { 147 val annotations = context.evaluator.getAllAnnotations(annotation, false) 148 if (annotations.any { APPLICABLE_ANNOTATIONS.contains(it.qualifiedName) }) { 149 if (DEBUG) { 150 println( 151 "${context.driver.mode}: used ${annotation.getQualifiedName()} in " + 152 context.project.mavenCoordinate.groupId 153 ) 154 } 155 verifyUsageOfElementIsWithinSameGroup( 156 context, 157 node, 158 annotation, 159 ISSUE, 160 atomicGroupList 161 ) 162 } 163 } 164 } 165 166 private fun loadAtomicLibraryGroupList(): List<String> { 167 val fileStream = 168 this::class.java.classLoader.getResourceAsStream(ATOMIC_LIBRARY_GROUPS_FILENAME) 169 ?: throw FileNotFoundException( 170 "Couldn't find atomic library group file $ATOMIC_LIBRARY_GROUPS_FILENAME" + 171 " within lint-checks.jar" 172 ) 173 174 val atomicLibraryGroupsString = fileStream.bufferedReader().use { it.readText() } 175 if (atomicLibraryGroupsString.isEmpty()) { 176 throw RuntimeException("Atomic library group file should not be empty") 177 } 178 179 return atomicLibraryGroupsString.split("\n") 180 } 181 } 182 183 fun verifyUsageOfElementIsWithinSameGroup( 184 context: JavaContext, 185 usage: UElement, 186 annotation: UElement, 187 issue: Issue, 188 atomicGroupList: List<String>, 189 ) { 190 191 // Experimental annotations are permitted if they are in the allowlist 192 val annotationQualifiedName = annotation.getQualifiedName() 193 if (annotationQualifiedName != null && isAnnotationAlwaysAllowed(annotationQualifiedName)) { 194 return 195 } 196 197 val evaluator = context.evaluator 198 199 // The location where the annotation is used 200 val usageCoordinates = evaluator.getLibrary(usage) ?: context.project.mavenCoordinate 201 val usageGroupId = usageCoordinates?.groupId 202 203 // The location where the annotation is declared 204 val annotationCoordinates = evaluator.getLibraryLocalMode(annotation) 205 206 // This should not happen; generate a lint report if it does 207 if (annotationCoordinates == null) { 208 Incident(context) 209 .issue(NULL_ANNOTATION_GROUP_ISSUE) 210 .at(usage) 211 .message( 212 "Could not find associated group for annotation " + 213 "${annotation.getQualifiedName()}, which is used in " + 214 "${context.project.mavenCoordinate.groupId}." 215 ) 216 .report() 217 return 218 } 219 220 val annotationGroupId = annotationCoordinates.groupId 221 222 val isUsedInAlpha = usageCoordinates.version.contains("-alpha") 223 val isUsedInSameGroup = usageCoordinates.groupId == annotationCoordinates.groupId 224 val isUsedInSameArtifact = usageCoordinates.artifactId == annotationCoordinates.artifactId 225 val isAtomic = atomicGroupList.contains(usageGroupId) 226 227 /** 228 * Usage of experimental APIs is allowed in either of the following conditions: 229 * - The usage is in an alpha library 230 * - Both the group ID and artifact ID in `usageCoordinates` and `annotationCoordinates` 231 * match 232 * - The group IDs match, and that group ID is atomic 233 */ 234 if ( 235 isUsedInAlpha || 236 (isUsedInSameGroup && isUsedInSameArtifact) || 237 (isUsedInSameGroup && isAtomic) 238 ) 239 return 240 241 // Log inappropriate experimental usage 242 if (DEBUG) { 243 println("${context.driver.mode}: report usage of $annotationGroupId in $usageGroupId") 244 } 245 Incident(context) 246 .issue(issue) 247 .at(usage) 248 .message( 249 "`Experimental` and `RequiresOptIn` APIs may only be used within the " + 250 "same-version group where they were defined." 251 ) 252 .report() 253 } 254 255 /** 256 * An implementation of [JavaEvaluator.getLibrary] that attempts to use the JAR path when we 257 * can't find the project from the sourcePsi, even if the element isn't a compiled element. 258 */ 259 private fun JavaEvaluator.getLibraryLocalMode(element: UElement): LintModelMavenName? { 260 if (element !is PsiCompiledElement) { 261 val coord = element.sourcePsi?.let { psi -> getProject(psi)?.mavenCoordinate } 262 if (coord != null) { 263 return coord 264 } 265 } 266 val findJarPath = findJarPath(element) 267 return if (findJarPath != null) { 268 val file = File(findJarPath) 269 getLibrary(file) ?: getMavenCoordinatesFromPath(file.path) 270 } else { 271 null 272 } 273 } 274 275 private fun UElement.getQualifiedName() = (this as UClass).qualifiedName 276 277 companion object { 278 private const val DEBUG = false 279 280 /** 281 * Even though Kotlin's [Experimental] annotation is deprecated in favor of [RequiresOptIn], 282 * we still want to check for its use in Lint. 283 */ 284 private const val KOTLIN_EXPERIMENTAL_ANNOTATION = "kotlin.Experimental" 285 286 private const val KOTLIN_REQUIRES_OPT_IN_ANNOTATION = "kotlin.RequiresOptIn" 287 private const val JAVA_EXPERIMENTAL_ANNOTATION = 288 "androidx.annotation.experimental.Experimental" 289 private const val JAVA_REQUIRES_OPT_IN_ANNOTATION = "androidx.annotation.RequiresOptIn" 290 291 val APPLICABLE_ANNOTATIONS = 292 listOf( 293 JAVA_EXPERIMENTAL_ANNOTATION, 294 KOTLIN_EXPERIMENTAL_ANNOTATION, 295 JAVA_REQUIRES_OPT_IN_ANNOTATION, 296 KOTLIN_REQUIRES_OPT_IN_ANNOTATION, 297 ) 298 299 private val APPLICATION_OPT_IN_ANNOTATIONS = 300 listOf( 301 "androidx.annotation.OptIn", 302 "kotlin.OptIn", 303 ) 304 305 // This must match the definition in ExportAtomicLibraryGroupsToTextTask 306 const val ATOMIC_LIBRARY_GROUPS_FILENAME = "atomic-library-groups.txt" 307 308 val ISSUE = 309 Issue.create( 310 id = "IllegalExperimentalApiUsage", 311 briefDescription = "Using experimental API from separately versioned library", 312 explanation = 313 "Annotations meta-annotated with `@RequiresOptIn` or `@Experimental` " + 314 "may only be referenced from within the same-version group in which they were " + 315 "defined.", 316 category = Category.CORRECTNESS, 317 priority = 5, 318 severity = Severity.ERROR, 319 implementation = 320 Implementation( 321 BanInappropriateExperimentalUsage::class.java, 322 Scope.JAVA_FILE_SCOPE, 323 ), 324 ) 325 326 val NULL_ANNOTATION_GROUP_ISSUE = 327 Issue.create( 328 id = "NullAnnotationGroup", 329 briefDescription = "Maven group associated with an annotation could not be found", 330 explanation = 331 "An annotation's group could not be found using `getProject` or " + 332 "`getLibrary`.", 333 category = Category.CORRECTNESS, 334 priority = 5, 335 severity = Severity.ERROR, 336 implementation = 337 Implementation( 338 BanInappropriateExperimentalUsage::class.java, 339 Scope.JAVA_FILE_SCOPE, 340 ), 341 ) 342 343 /** Checks to see if the given annotation is always allowed for use in @OptIn. */ 344 internal fun isAnnotationAlwaysAllowed(annotation: String): Boolean { 345 val allowedExperimentalAnnotations = 346 listOf( 347 Regex("com\\.google\\.devtools\\.ksp\\.KspExperimental"), 348 Regex("kotlin\\..*"), 349 Regex("kotlinx\\..*"), 350 Regex("org.jetbrains.kotlin\\..*"), 351 ) 352 return allowedExperimentalAnnotations.any { annotation.matches(it) } 353 } 354 355 /** 356 * Extracts the Maven coordinates from a given JAR path 357 * 358 * For example: given `<checkout 359 * root>/androidx/compose/ui/ui-test/build/libs/ui-test-jvmstubs-1.8.0-beta01.jar`, this 360 * method will return a: 361 * - `groupId` of `androidx.compose.ui` 362 * - `artifactId` of `ui-test` 363 * - `version` of `jvmstubs-1.8.0-beta01` 364 * 365 * @param jarFilePath the path to the JAR file 366 * @return a [LintModelMavenName] with the groupId, artifactId, and version parsed from the 367 * path, or `null` if [jarFilePath] doesn't contain the strings "androidx" and "build". 368 */ 369 internal fun getMavenCoordinatesFromPath(jarFilePath: String): LintModelMavenName? { 370 val pathParts = jarFilePath.split("/") 371 val androidxIndex = pathParts.indexOf("androidx") 372 val buildIndex = pathParts.indexOf("build") 373 if (androidxIndex == -1 || buildIndex == -1) return null 374 375 val groupId = pathParts.subList(androidxIndex, buildIndex - 1).joinToString(".") 376 val artifactId = pathParts[buildIndex - 1] 377 378 val filename = pathParts.last() 379 val version = filename.removePrefix("$artifactId-").removeSuffix(".jar") 380 381 return DefaultLintModelMavenName(groupId, artifactId, version) 382 } 383 } 384 } 385