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