1 /* <lambda>null2 * Copyright 2021 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 androidx.build.lint.SampledAnnotationDetector.Companion.INVALID_SAMPLES_LOCATION 22 import androidx.build.lint.SampledAnnotationDetector.Companion.MULTIPLE_FUNCTIONS_FOUND 23 import androidx.build.lint.SampledAnnotationDetector.Companion.OBSOLETE_SAMPLED_ANNOTATION 24 import androidx.build.lint.SampledAnnotationDetector.Companion.SAMPLED_ANNOTATION 25 import androidx.build.lint.SampledAnnotationDetector.Companion.SAMPLED_ANNOTATION_FQN 26 import androidx.build.lint.SampledAnnotationDetector.Companion.SAMPLED_FUNCTION_MAP 27 import androidx.build.lint.SampledAnnotationDetector.Companion.SAMPLES_DIRECTORY 28 import androidx.build.lint.SampledAnnotationDetector.Companion.SAMPLE_KDOC_ANNOTATION 29 import androidx.build.lint.SampledAnnotationDetector.Companion.SAMPLE_LINK_MAP 30 import androidx.build.lint.SampledAnnotationDetector.Companion.UNRESOLVED_SAMPLE_LINK 31 import com.android.tools.lint.client.api.UElementHandler 32 import com.android.tools.lint.detector.api.Category 33 import com.android.tools.lint.detector.api.Context 34 import com.android.tools.lint.detector.api.Detector 35 import com.android.tools.lint.detector.api.Implementation 36 import com.android.tools.lint.detector.api.Incident 37 import com.android.tools.lint.detector.api.Issue 38 import com.android.tools.lint.detector.api.JavaContext 39 import com.android.tools.lint.detector.api.LintMap 40 import com.android.tools.lint.detector.api.Location 41 import com.android.tools.lint.detector.api.PartialResult 42 import com.android.tools.lint.detector.api.Scope 43 import com.android.tools.lint.detector.api.Severity 44 import com.android.tools.lint.detector.api.SourceCodeScanner 45 import org.jetbrains.kotlin.analysis.api.KaExperimentalApi 46 import org.jetbrains.kotlin.analysis.api.analyze 47 import org.jetbrains.kotlin.kdoc.psi.api.KDoc 48 import org.jetbrains.kotlin.kdoc.psi.impl.KDocSection 49 import org.jetbrains.kotlin.psi.KtDeclaration 50 import org.jetbrains.kotlin.psi.KtFile 51 import org.jetbrains.kotlin.psi.KtModifierListOwner 52 import org.jetbrains.kotlin.psi.psiUtil.forEachDescendantOfType 53 import org.jetbrains.kotlin.psi.psiUtil.hasActualModifier 54 import org.jetbrains.uast.UDeclaration 55 import org.jetbrains.uast.UMethod 56 57 /** 58 * Detector responsible for enforcing @Sampled annotation usage 59 * 60 * This detector enforces that: 61 * - Functions referenced with @sample are annotated with @Sampled - [UNRESOLVED_SAMPLE_LINK] 62 * - Functions annotated with @Sampled are referenced with @sample - [OBSOLETE_SAMPLED_ANNOTATION] 63 * - Functions annotated with @Sampled are inside a valid samples directory, matching module / 64 * directory structure guidelines - [INVALID_SAMPLES_LOCATION] 65 * - There are never multiple functions with the same fully qualified name that could be resolved by 66 * an @sample link - [MULTIPLE_FUNCTIONS_FOUND] 67 */ 68 class SampledAnnotationDetector : Detector(), SourceCodeScanner { 69 70 override fun getApplicableUastTypes() = listOf(UDeclaration::class.java) 71 72 override fun createUastHandler(context: JavaContext) = 73 object : UElementHandler() { 74 override fun visitDeclaration(node: UDeclaration) { 75 KDocSampleLinkHandler(context).visitDeclaration(node) 76 if (node is UMethod) { 77 SampledAnnotationHandler(context).visitMethod(node) 78 } 79 } 80 } 81 82 override fun checkPartialResults(context: Context, partialResults: PartialResult) { 83 val sampleLinks = mutableMapOf<String, MutableList<Location>>() 84 val sampledFunctions = mutableMapOf<String, MutableList<Location>>() 85 partialResults.maps().forEach { map -> 86 map.getMap(SAMPLE_LINK_MAP)?.run { 87 iterator().forEach { key -> 88 sampleLinks.getOrPut(key) { mutableListOf() }.add(getLocation(key)!!) 89 } 90 } 91 92 map.getMap(SAMPLED_FUNCTION_MAP)?.run { 93 iterator().forEach { key -> 94 sampledFunctions.getOrPut(key) { mutableListOf() }.add(getLocation(key)!!) 95 } 96 } 97 } 98 99 // Only report errors on the sample module 100 if (context.project.name != "samples") return 101 102 /** 103 * Returns whether this [Location] represents a file that we want to report errors for. We 104 * only want to report an error for files in the parent module of this samples module, to 105 * avoid reporting the same errors multiple times if multiple sample modules depend on a 106 * library that has @sample links. 107 */ 108 fun Location.shouldReport(): Boolean { 109 // Path of the parent module that the sample module has samples for 110 val sampleParentPath = context.project.dir.parentFile.toPath().toRealPath() 111 val locationPath = file.toPath().toRealPath() 112 return locationPath.startsWith(sampleParentPath) 113 } 114 115 sampleLinks.forEach { (link, locations) -> 116 val functionLocations = sampledFunctions[link] 117 when { 118 functionLocations == null -> { 119 locations.forEach { location -> 120 if (location.shouldReport()) { 121 val incident = 122 Incident(context) 123 .issue(UNRESOLVED_SAMPLE_LINK) 124 .location(location) 125 .message( 126 "Couldn't find a valid @Sampled function matching $link" 127 ) 128 context.report(incident) 129 } 130 } 131 } 132 // This probably should never happen, but theoretically there could be multiple 133 // samples with the same FQN across separate sample projects, so check here as well. 134 functionLocations.size > 1 -> { 135 locations.forEach { location -> 136 if (location.shouldReport()) { 137 val incident = 138 Incident(context) 139 .issue(MULTIPLE_FUNCTIONS_FOUND) 140 .location(location) 141 .message("Found multiple functions matching $link") 142 context.report(incident) 143 } 144 } 145 } 146 } 147 } 148 149 sampledFunctions.forEach { (link, locations) -> 150 if (sampleLinks[link] == null) { 151 locations.forEach { location -> 152 if (location.shouldReport()) { 153 val incident = 154 Incident(context) 155 .issue(OBSOLETE_SAMPLED_ANNOTATION) 156 .location(location) 157 .message( 158 "$link is annotated with @$SAMPLED_ANNOTATION, but is not " + 159 "linked to from a @$SAMPLE_KDOC_ANNOTATION tag." 160 ) 161 context.report(incident) 162 } 163 } 164 } 165 } 166 } 167 168 companion object { 169 // The name of the @sample tag in KDoc 170 const val SAMPLE_KDOC_ANNOTATION = "sample" 171 // The name of the @Sampled annotation that samples must be annotated with 172 const val SAMPLED_ANNOTATION = "Sampled" 173 const val SAMPLED_ANNOTATION_FQN = "androidx.annotation.$SAMPLED_ANNOTATION" 174 // The name of the samples directory inside a project 175 const val SAMPLES_DIRECTORY = "samples" 176 177 const val SAMPLE_LINK_MAP = "SampleLinkMap" 178 const val SAMPLED_FUNCTION_MAP = "SampledFunctionMap" 179 180 val OBSOLETE_SAMPLED_ANNOTATION = 181 Issue.create( 182 id = "ObsoleteSampledAnnotation", 183 briefDescription = "Obsolete @$SAMPLED_ANNOTATION annotation", 184 explanation = 185 "This function is annotated with @$SAMPLED_ANNOTATION, but is not " + 186 "linked to from a @$SAMPLE_KDOC_ANNOTATION tag. Either remove this annotation, " + 187 "or add a valid @$SAMPLE_KDOC_ANNOTATION tag linking to it.", 188 category = Category.CORRECTNESS, 189 priority = 5, 190 severity = Severity.ERROR, 191 implementation = 192 Implementation(SampledAnnotationDetector::class.java, Scope.JAVA_FILE_SCOPE) 193 ) 194 195 val UNRESOLVED_SAMPLE_LINK = 196 Issue.create( 197 id = "UnresolvedSampleLink", 198 briefDescription = "Unresolved @$SAMPLE_KDOC_ANNOTATION annotation", 199 explanation = 200 "Couldn't find a valid @Sampled function matching the function " + 201 "specified in the $SAMPLE_KDOC_ANNOTATION link. If there is a function with the " + 202 "same fully qualified name, make sure it is annotated with @Sampled.", 203 category = Category.CORRECTNESS, 204 priority = 5, 205 severity = Severity.ERROR, 206 implementation = 207 Implementation(SampledAnnotationDetector::class.java, Scope.JAVA_FILE_SCOPE) 208 ) 209 210 val MULTIPLE_FUNCTIONS_FOUND = 211 Issue.create( 212 id = "MultipleSampledFunctions", 213 briefDescription = "Multiple matching functions found", 214 explanation = "Found multiple functions matching the $SAMPLE_KDOC_ANNOTATION link.", 215 category = Category.CORRECTNESS, 216 priority = 5, 217 severity = Severity.ERROR, 218 implementation = 219 Implementation(SampledAnnotationDetector::class.java, Scope.JAVA_FILE_SCOPE) 220 ) 221 222 val INVALID_SAMPLES_LOCATION = 223 Issue.create( 224 id = "InvalidSamplesLocation", 225 briefDescription = "Invalid samples location", 226 explanation = 227 "This function is annotated with @$SAMPLED_ANNOTATION, but is not " + 228 "inside a project/directory named $SAMPLES_DIRECTORY.", 229 category = Category.CORRECTNESS, 230 priority = 5, 231 severity = Severity.ERROR, 232 implementation = 233 Implementation(SampledAnnotationDetector::class.java, Scope.JAVA_FILE_SCOPE) 234 ) 235 } 236 } 237 238 /** 239 * Handles KDoc with @sample links 240 * 241 * Checks KDoc in all applicable UDeclarations - this includes classes, functions, fields... 242 */ 243 @OptIn(KaExperimentalApi::class) 244 private class KDocSampleLinkHandler(private val context: JavaContext) { visitDeclarationnull245 fun visitDeclaration(node: UDeclaration) { 246 val source = node.sourcePsi 247 node.comments.mapNotNull { it.sourcePsi as? KDoc }.forEach { handleSampleLink(it) } 248 // Expect declarations are not visible in UAST, but they may have sample links on them. 249 // If we are looking at an actual declaration, also manually find the corresponding 250 // expect declaration for analysis. 251 if ((source as? KtModifierListOwner)?.hasActualModifier() == true) { 252 analyze(source) { 253 val member = (source as? KtDeclaration)?.symbol ?: return 254 val expect = member.getExpectsForActual().singleOrNull() ?: return 255 val declaration = expect.psi ?: return 256 // Recursively handle everything inside the expect declaration, for example if it 257 // is a class with members that have documentation that we should look at - this 258 // will visit the declaration itself as well 259 declaration.forEachDescendantOfType<KtDeclaration> { 260 it.docComment?.let { comment -> handleSampleLink(comment) } 261 } 262 } 263 } 264 } 265 handleSampleLinknull266 private fun handleSampleLink(kdoc: KDoc) { 267 val sections: List<KDocSection> = kdoc.children.mapNotNull { it as? KDocSection } 268 269 // map of a KDocTag (which contains the location used when reporting issues) to the 270 // method link specified in @sample 271 val sampleTags = 272 sections 273 .flatMap { section -> 274 section.findTagsByName(SAMPLE_KDOC_ANNOTATION).mapNotNull { sampleTag -> 275 val linkText = sampleTag.getSubjectLink()?.getLinkText() 276 if (linkText == null) { 277 null 278 } else { 279 sampleTag to linkText 280 } 281 } 282 } 283 .distinct() 284 285 sampleTags.forEach { (docTag, link) -> 286 // TODO: handle suppressions (if needed) with LintDriver.isSuppressed 287 val mainLintMap = context.getPartialResults(UNRESOLVED_SAMPLE_LINK).map() 288 289 val sampleLinkLintMap = 290 mainLintMap.getMap(SAMPLE_LINK_MAP) 291 ?: LintMap().also { mainLintMap.put(SAMPLE_LINK_MAP, it) } 292 293 // This overrides any identical links in the same project - no need to report the 294 // same error multiple times in different places, and it is tricky to do so in any case. 295 sampleLinkLintMap.put(link, context.getNameLocation(docTag)) 296 } 297 } 298 } 299 300 /** Handles sample functions annotated with @Sampled */ 301 private class SampledAnnotationHandler(private val context: JavaContext) { 302 visitMethodnull303 fun visitMethod(node: UMethod) { 304 if (node.hasAnnotation(SAMPLED_ANNOTATION_FQN)) { 305 handleSampleCode(node) 306 } 307 } 308 handleSampleCodenull309 private fun handleSampleCode(node: UMethod) { 310 val currentPath = context.psiFile!!.virtualFile.path 311 312 if (SAMPLES_DIRECTORY !in currentPath) { 313 val incident = 314 Incident(context) 315 .issue(INVALID_SAMPLES_LOCATION) 316 .location(context.getNameLocation(node)) 317 .message( 318 "${node.name} is annotated with @$SAMPLED_ANNOTATION" + 319 ", but is not inside a project/directory named $SAMPLES_DIRECTORY." 320 ) 321 .scope(node) 322 context.report(incident) 323 return 324 } 325 326 // The package name of the file we are in 327 val parentFqName = (node.containingFile as KtFile).packageFqName.asString() 328 // The full name of the current function that will be referenced in a @sample tag 329 val fullFqName = "$parentFqName.${node.name}" 330 331 val mainLintMap = context.getPartialResults(UNRESOLVED_SAMPLE_LINK).map() 332 333 val sampledFunctionLintMap = 334 mainLintMap.getMap(SAMPLED_FUNCTION_MAP) 335 ?: LintMap().also { mainLintMap.put(SAMPLED_FUNCTION_MAP, it) } 336 337 val location = context.getNameLocation(node) 338 339 if (sampledFunctionLintMap.getLocation(fullFqName) != null) { 340 val incident = 341 Incident(context) 342 .issue(MULTIPLE_FUNCTIONS_FOUND) 343 .location(location) 344 .message("Found multiple functions matching $fullFqName") 345 context.report(incident) 346 } 347 348 sampledFunctionLintMap.put(fullFqName, location) 349 } 350 } 351