summaryrefslogtreecommitdiff
path: root/plugins/kotlin/idea/src/org/jetbrains/kotlin/idea/inspections/UnnecessaryOptInAnnotationInspection.kt
blob: 732e1890ea23fe1b88dcbeca9a7a2adda47d997d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
// Copyright 2000-2021 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
package org.jetbrains.kotlin.idea.inspections

import com.intellij.codeInspection.LocalQuickFix
import com.intellij.codeInspection.ProblemDescriptor
import com.intellij.codeInspection.ProblemHighlightType
import com.intellij.codeInspection.ProblemsHolder
import com.intellij.openapi.project.Project
import com.intellij.psi.PsiElementVisitor
import com.intellij.psi.SmartPsiElementPointer
import com.intellij.psi.util.parentOfType
import org.jetbrains.kotlin.config.ApiVersion
import org.jetbrains.kotlin.descriptors.*
import org.jetbrains.kotlin.idea.KotlinBundle
import org.jetbrains.kotlin.idea.caches.resolve.analyze
import org.jetbrains.kotlin.idea.caches.resolve.getResolutionFacade
import org.jetbrains.kotlin.idea.caches.resolve.resolveToDescriptorIfAny
import org.jetbrains.kotlin.idea.core.getDirectlyOverriddenDeclarations
import org.jetbrains.kotlin.idea.project.languageVersionSettings
import org.jetbrains.kotlin.idea.refactoring.fqName.fqName
import org.jetbrains.kotlin.idea.references.ReadWriteAccessChecker
import org.jetbrains.kotlin.idea.references.resolveMainReferenceToDescriptors
import org.jetbrains.kotlin.idea.resolve.ResolutionFacade
import org.jetbrains.kotlin.lexer.KtTokens
import org.jetbrains.kotlin.name.FqName
import org.jetbrains.kotlin.name.Name
import org.jetbrains.kotlin.psi.*
import org.jetbrains.kotlin.psi.psiUtil.createSmartPointer
import org.jetbrains.kotlin.psi.psiUtil.getStrictParentOfType
import org.jetbrains.kotlin.renderer.render
import org.jetbrains.kotlin.resolve.BindingContext
import org.jetbrains.kotlin.resolve.SINCE_KOTLIN_FQ_NAME
import org.jetbrains.kotlin.resolve.checkers.OptInNames
import org.jetbrains.kotlin.resolve.checkers.OptInNames.OPT_IN_FQ_NAMES
import org.jetbrains.kotlin.resolve.constants.ArrayValue
import org.jetbrains.kotlin.resolve.constants.KClassValue
import org.jetbrains.kotlin.resolve.constants.StringValue
import org.jetbrains.kotlin.resolve.descriptorUtil.*
import org.jetbrains.kotlin.types.KotlinType
import org.jetbrains.kotlin.util.aliasImportMap
import org.jetbrains.kotlin.utils.addToStdlib.safeAs

/**
 * An inspection to detect unnecessary (obsolete) `@OptIn` annotations.
 *
 * The `@OptIn(SomeExperimentalMarker::class)` annotation is necessary for the code that
 * uses experimental library API marked with `@SomeExperimentalMarker` but is not experimental by itself.
 * When the library authors decide that the API is not experimental anymore, and they remove
 * the experimental marker, the corresponding `@OptIn` annotation in the client code becomes unnecessary
 * and may be removed so the people working with the code would not be misguided.
 *
 * For each `@OptIn` annotation, the inspection checks if in its scope there are names marked with
 * the experimental marker mentioned in the `@OptIn`, and it reports the marker classes that don't match
 * any names. For these redundant markers, the inspection proposes a quick fix to remove the marker
 * or the entire unnecessary `@OptIn` annotation if it contains a single marker.
 */
class UnnecessaryOptInAnnotationInspection : AbstractKotlinInspection() {

    /**
     * Get the PSI element to which the given `@OptIn` annotation applies.
     *
     * @receiver the `@OptIn` annotation entry
     * @return the annotated element, or null if no such element is found
     */
    private fun KtAnnotationEntry.getOwner(): KtElement? = getStrictParentOfType<KtAnnotated>()

    /**
     * A temporary storage for expected experimental markers.
     *
     * @param expression a smart pointer to the argument expression to create a quick fix
     * @param fqName the resolved fully qualified name
     */
    private data class ResolvedMarker(
        val expression: SmartPsiElementPointer<KtClassLiteralExpression>,
        val fqName: FqName
    )

    // Short names for `kotlin.OptIn` and `kotlin.UseExperimental` for faster comparison without name resolution
    private val OPT_IN_SHORT_NAMES = OPT_IN_FQ_NAMES.map { it.shortName().asString() }.toSet()

    /**
     * Main inspection visitor to traverse all annotation entries.
     */
    override fun buildVisitor(holder: ProblemsHolder, isOnTheFly: Boolean): PsiElementVisitor {
        val optInAliases = holder.file.safeAs<KtFile>()
            ?.aliasImportMap()
            ?.entries()
            ?.filter { it.value in OPT_IN_SHORT_NAMES }
            ?.mapNotNull { it.key }
            ?.toSet()
            ?: emptySet()

        return annotationEntryVisitor { annotationEntry  ->
            // Fast check if the annotation may be `@OptIn`/`@UseExperimental` or any of their import aliases
            val entryShortName = annotationEntry.shortName?.asString()
            if (entryShortName != null && entryShortName !in OPT_IN_SHORT_NAMES && entryShortName !in optInAliases)
                return@annotationEntryVisitor

            // Resolve the candidate annotation entry. If it is an `@OptIn`/`@UseExperimental` annotation,
            // resolve all expected experimental markers.
            val resolutionFacade = annotationEntry.getResolutionFacade()
            val annotationContext = annotationEntry.analyze(resolutionFacade)
            val annotationFqName = annotationContext[BindingContext.ANNOTATION, annotationEntry]?.fqName
            if (annotationFqName !in OptInNames.USE_EXPERIMENTAL_FQ_NAMES) return@annotationEntryVisitor

            val resolvedMarkers = mutableListOf<ResolvedMarker>()
            for (arg in annotationEntry.valueArguments) {
                val argumentExpression = arg.getArgumentExpression()?.safeAs<KtClassLiteralExpression>() ?: continue
                val markerFqName = annotationContext[
                        BindingContext.REFERENCE_TARGET,
                        argumentExpression.lhs?.safeAs<KtNameReferenceExpression>()
                ]?.fqNameSafe ?: continue
                resolvedMarkers.add(ResolvedMarker(argumentExpression.createSmartPointer(), markerFqName))
            }

            // Find the scope of the `@OptIn` declaration and collect all its experimental markers.
            val markerProcessor = MarkerCollector(resolutionFacade)
            annotationEntry.getOwner()?.accept(OptInMarkerVisitor(), markerProcessor)

            val unusedMarkers = resolvedMarkers.filter { markerProcessor.isUnused(it.fqName) }
            if (annotationEntry.valueArguments.size == unusedMarkers.size) {
                // If all markers in the `@OptIn` annotation are useless, create a quick fix to remove
                // the entire annotation.
                holder.registerProblem(
                    annotationEntry,
                    KotlinBundle.message("inspection.unnecessary.opt_in.redundant.annotation"),
                    ProblemHighlightType.LIKE_UNUSED_SYMBOL,
                    RemoveAnnotationEntry()
                )
            } else {
                // Check each resolved marker whether it is actually used in the scope of the `@OptIn`.
                // Create a quick fix to remove the unnecessary marker if no marked names have been found.
                for (marker in unusedMarkers) {
                    val expression = marker.expression.element ?: continue
                    holder.registerProblem(
                        expression,
                        KotlinBundle.message(
                                    "inspection.unnecessary.opt_in.redundant.marker",
                                    marker.fqName.shortName().render()
                                ),
                        ProblemHighlightType.LIKE_UNUSED_SYMBOL,
                        RemoveAnnotationArgumentOrEntireEntry()
                    )
                }
            }
        }
    }
}

/**
 * A processor that collects experimental markers referred by names in the `@OptIn` annotation scope.
 */
private class MarkerCollector(private val resolutionFacade: ResolutionFacade) {
    // Experimental markers found during a check for a specific annotation entry
    private val foundMarkers = mutableSetOf<FqName>()

    // A checker instance for setter call detection
    private val readWriteAccessChecker = ReadWriteAccessChecker.getInstance()

    /**
     * Check if a specific experimental marker is not used in the scope of a specific `@OptIn` annotation.
     *
     * @param marker the fully qualified name of the experimental marker of interest
     * @return true if no marked names was found during the check, false if there is at least one marked name
     */
    fun isUnused(marker: FqName): Boolean = marker !in foundMarkers

    /**
     * Collect experimental markers for a declaration and add them to [foundMarkers].
     *
     * The `@OptIn` annotation is useful for declarations that override a marked declaration (e.g., overridden
     * functions or properties in classes/objects). If the declaration overrides another name, we should
     * collect experimental markers from the overridden declaration.
     *
     * @param declaration the declaration to process
     */
    fun collectMarkers(declaration: KtDeclaration?) {
        if (declaration == null) return
        if (declaration !is KtFunction && declaration !is KtProperty && declaration !is KtParameter) return
        if (declaration.hasModifier(KtTokens.OVERRIDE_KEYWORD)) {
            val descriptor = declaration.resolveToDescriptorIfAny(resolutionFacade)?.safeAs<CallableMemberDescriptor>() ?: return
            descriptor.getDirectlyOverriddenDeclarations().forEach { it.collectMarkers(declaration.languageVersionSettings.apiVersion) }
        }
    }

    /**
     * Collect experimental markers for an expression and add them to [foundMarkers].
     *
     * @param expression the expression to process
     */
    fun collectMarkers(expression: KtReferenceExpression?) {
        if (expression == null) return

        // Resolve the reference to descriptors, then analyze the annotations
        // For each descriptor, we also check a corresponding importable descriptor
        // if it is not equal to the descriptor itself. The goal is to correctly
        // resolve class names. For example, the `Foo` reference in the code fragment
        // `val x = Foo()` is resolved as a constructor, while the corresponding
        // class descriptor can be found as the constructor's importable name.
        // Both constructor and class may be annotated with an experimental API marker,
        // so we should check both of them.
        val descriptorList = expression
            .resolveMainReferenceToDescriptors()
            .flatMap { setOf(it, it.getImportableDescriptor()) }

        val moduleApiVersion = expression.languageVersionSettings.apiVersion

        for (descriptor in descriptorList) {
            descriptor.collectMarkers(moduleApiVersion)
            // A special case: a property has no experimental markers but its setter is experimental.
            // We need to additionally collect markers from the setter if it is invoked in the expression.
            if (descriptor is PropertyDescriptor) {
                val setter = descriptor.setter
                if (setter != null && expression.isSetterCall()) setter.collectMarkers(moduleApiVersion)
            }

            // The caller implicitly uses argument types and return types of a declaration,
            // so we need to check whether these types have experimental markers
            // regardless of the `@OptIn` annotation on the declaration itself.
            if (descriptor is CallableDescriptor) {
                descriptor.valueParameters.forEach { it.type.collectMarkers(moduleApiVersion)}
                descriptor.returnType?.collectMarkers(moduleApiVersion)
            }
        }
    }

    /**
     * Collect markers from a declaration descriptor corresponding to a Kotlin type.
     *
     * @receiver the type to collect markers
     * @param moduleApiVersion the API version of the current module to check `@WasExperimental` annotations
     */
    private fun KotlinType.collectMarkers(moduleApiVersion: ApiVersion) {
        arguments.forEach { it.type.collectMarkers(moduleApiVersion) }
        val descriptor = this.constructor.declarationDescriptor ?: return
        descriptor.collectMarkers(moduleApiVersion)
    }

    /**
     * Actually collect markers for a resolved descriptor and add them to [foundMarkers].
     *
     * @receiver the descriptor to collect markers
     * @param moduleApiVersion the API version of the current module to check `@WasExperimental` annotations
     */
    private fun DeclarationDescriptor.collectMarkers(moduleApiVersion: ApiVersion) {
        for (ann in annotations) {
            val annotationFqName = ann.fqName ?: continue
            val annotationClass = ann.annotationClass ?: continue

            // Add the annotation class as a marker if it has `@RequireOptIn` annotation.
            if (annotationClass.annotations.hasAnnotation(OptInNames.REQUIRES_OPT_IN_FQ_NAME)
                || annotationClass.annotations.hasAnnotation(OptInNames.OLD_EXPERIMENTAL_FQ_NAME)) {
                foundMarkers += annotationFqName
            }

            val wasExperimental = annotations.findAnnotation(OptInNames.WAS_EXPERIMENTAL_FQ_NAME)  ?: continue
            val sinceKotlin = annotations.findAnnotation(SINCE_KOTLIN_FQ_NAME) ?: continue

            // If there are both `@SinceKotlin` and `@WasExperimental` annotations,
            // and Kotlin API version of the module is less than the version specified by `@SinceKotlin`,
            // then the `@OptIn` for `@WasExperimental` marker is necessary and should be added
            // to the set of found markers.
            //
            // For example, consider a function
            // ```
            // @SinceKotlin("1.6")
            // @WasExperimental(Marker::class)
            // fun foo() { ... }
            // ```
            // This combination of annotations means that `foo` was experimental before Kotlin 1.6
            // and required `@OptIn(Marker::class) or `@Marker` annotation. When the client code
            // is compiled as Kotlin 1.6 code, there are no problems, and the `@OptIn(Marker::class)`
            // annotation would not be necessary. At the same time, when the code is compiled with
            // `apiVersion = 1.5`, the non-experimental declaration of `foo` will be hidden
            // from the resolver, so `@OptIn` is necessary for the code to compile.
            val sinceKotlinApiVersion = sinceKotlin.allValueArguments[VERSION_ARGUMENT]
                ?.safeAs<StringValue>()?.value?.let {
                    ApiVersion.parse(it)
                }

            if (sinceKotlinApiVersion != null && moduleApiVersion < sinceKotlinApiVersion) {
                wasExperimental.allValueArguments[OptInNames.WAS_EXPERIMENTAL_ANNOTATION_CLASS]?.safeAs<ArrayValue>()?.value
                    ?.mapNotNull { it.safeAs<KClassValue>()?.getArgumentType(module)?.fqName }
                    ?.forEach { foundMarkers.add(it) }
            }
        }
    }

    /**
     * Check if the reference expression is a part of a property setter invocation.
     *
     * @receiver the expression to check
     */
    private fun KtReferenceExpression.isSetterCall(): Boolean =
        readWriteAccessChecker.readWriteAccessWithFullExpression(this, true).first.isWrite

    private val VERSION_ARGUMENT = Name.identifier("version")
}

/**
 * The marker collecting visitor that navigates the PSI tree in the scope of the `@OptIn` declaration
 * and collects experimental markers.
 */
private class OptInMarkerVisitor : KtTreeVisitor<MarkerCollector>() {
    override fun visitNamedDeclaration(declaration: KtNamedDeclaration, markerCollector: MarkerCollector): Void? {
        markerCollector.collectMarkers(declaration)
        return super.visitNamedDeclaration(declaration, markerCollector)
    }

    override fun visitReferenceExpression(expression: KtReferenceExpression, markerCollector: MarkerCollector): Void? {
        markerCollector.collectMarkers(expression)
        return super.visitReferenceExpression(expression, markerCollector)
    }
}

/**
 * A quick fix that removes the argument from the value argument list of an annotation entry,
 * or the entire entry if the argument was the only argument of the annotation.
 */
private class RemoveAnnotationArgumentOrEntireEntry : LocalQuickFix {
    override fun getFamilyName(): String = KotlinBundle.message("inspection.unnecessary.opt_in.remove.marker.fix.family.name")

    override fun applyFix(project: Project, descriptor: ProblemDescriptor) {
        val valueArgument = descriptor.psiElement?.parentOfType<KtValueArgument>() ?: return
        val annotationEntry = valueArgument.parentOfType<KtAnnotationEntry>() ?: return
        if (annotationEntry.valueArguments.size == 1) {
            annotationEntry.delete()
        } else {
            annotationEntry.valueArgumentList?.removeArgument(valueArgument)
        }
    }
}

/**
 * A quick fix that removes the entire annotation entry.
 */
private class RemoveAnnotationEntry : LocalQuickFix {
    override fun getFamilyName(): String = KotlinBundle.message("inspection.unnecessary.opt_in.remove.annotation.fix.family.name")

    override fun applyFix(project: Project, descriptor: ProblemDescriptor) {
        val annotationEntry = descriptor.psiElement?.safeAs<KtAnnotationEntry>() ?: return
        annotationEntry.delete()
    }
}