blob: 900775592a29b53299e91800f94a7f5201dc651b [file] [log] [blame]
/*
* Copyright (C) 2019 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.tools.idea.compose.preview
import com.android.sdklib.SdkVersionInfo
import com.android.tools.compose.COMPOSABLE_ANNOTATION_FQ_NAME
import com.android.tools.compose.COMPOSE_PREVIEW_ANNOTATION_FQN
import com.android.tools.compose.COMPOSE_PREVIEW_PARAMETER_ANNOTATION_FQN
import com.android.tools.idea.compose.preview.util.isValidPreviewLocation
import com.android.tools.idea.configurations.ConfigurationManager
import com.android.tools.idea.kotlin.evaluateConstant
import com.android.tools.idea.kotlin.findValueArgument
import com.android.tools.idea.kotlin.fqNameMatches
import com.android.tools.idea.projectsystem.isUnitTestFile
import com.android.tools.idea.util.androidFacet
import com.android.tools.layoutlib.isLayoutLibTarget
import com.android.tools.preview.MAX_HEIGHT
import com.android.tools.preview.MAX_WIDTH
import com.android.tools.preview.config.PARAMETER_API_LEVEL
import com.android.tools.preview.config.PARAMETER_FONT_SCALE
import com.android.tools.preview.config.PARAMETER_HEIGHT_DP
import com.android.tools.preview.config.PARAMETER_WIDTH_DP
import com.intellij.codeInspection.LocalInspectionToolSession
import com.intellij.codeInspection.ProblemHighlightType
import com.intellij.codeInspection.ProblemsHolder
import com.intellij.openapi.application.ApplicationManager
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiElementVisitor
import com.intellij.psi.util.parentOfType
import org.jetbrains.kotlin.analysis.api.analyze
import org.jetbrains.kotlin.analysis.api.calls.singleFunctionCallOrNull
import org.jetbrains.kotlin.analysis.api.calls.symbol
import org.jetbrains.kotlin.idea.base.plugin.KotlinPluginModeProvider
import org.jetbrains.kotlin.idea.caches.resolve.resolveToCall
import org.jetbrains.kotlin.idea.codeinsight.api.classic.inspections.AbstractKotlinInspection
import org.jetbrains.kotlin.idea.util.module
import org.jetbrains.kotlin.psi.KtAnnotationEntry
import org.jetbrains.kotlin.psi.KtCallExpression
import org.jetbrains.kotlin.psi.KtClass
import org.jetbrains.kotlin.psi.KtImportDirective
import org.jetbrains.kotlin.psi.KtNamedFunction
import org.jetbrains.kotlin.psi.KtParameter
import org.jetbrains.kotlin.psi.KtValueArgument
import org.jetbrains.kotlin.psi.KtVisitorVoid
import org.jetbrains.uast.UAnnotation
import org.jetbrains.uast.toUElement
/**
* Base class for inspection that depend on methods and annotation classes annotated with
* `@Preview`, or with a MultiPreview.
*/
abstract class BasePreviewAnnotationInspection : AbstractKotlinInspection() {
/**
* Will be true if the inspected file imports the `@Preview` annotation. This is used as a
* shortcut to avoid analyzing all kotlin files
*/
var isPreviewFile: Boolean = false
/**
* Will be true if the inspected file imports the `@Composable` annotation. This is used as a
* shortcut to avoid analyzing all kotlin files
*/
var isComposableFile: Boolean = false
override fun getGroupDisplayName() = message("inspection.group.name")
fun isPreview(annotation: KtAnnotationEntry) =
annotation.fqNameMatches(COMPOSE_PREVIEW_ANNOTATION_FQN)
fun isPreviewOrMultiPreview(annotation: KtAnnotationEntry) =
isPreview(annotation) || (annotation.toUElement() as? UAnnotation).isMultiPreviewAnnotation()
/**
* Called for every `@Preview` and MultiPreview annotation, that is annotating a function.
*
* @param holder A [ProblemsHolder] user to report problems
* @param function The function that was annotated with `@Preview` or with a MultiPreview
* @param previewAnnotation The `@Preview` or MultiPreview annotation
*/
abstract fun visitPreviewAnnotation(
holder: ProblemsHolder,
function: KtNamedFunction,
previewAnnotation: KtAnnotationEntry,
)
/**
* Called for every `@Preview` and MultiPreview annotation, that is annotating an annotation
* class.
*
* @param holder A [ProblemsHolder] user to report problems
* @param annotationClass The annotation class that was annotated with `@Preview` or with a
* MultiPreview
* @param previewAnnotation The `@Preview` or MultiPreview annotation
*/
abstract fun visitPreviewAnnotation(
holder: ProblemsHolder,
annotationClass: KtClass,
previewAnnotation: KtAnnotationEntry,
)
final override fun buildVisitor(
holder: ProblemsHolder,
isOnTheFly: Boolean,
session: LocalInspectionToolSession,
): PsiElementVisitor =
if (session.file.androidFacet != null || ApplicationManager.getApplication().isUnitTestMode) {
object : KtVisitorVoid() {
override fun visitImportDirective(importDirective: KtImportDirective) {
super.visitImportDirective(importDirective)
isPreviewFile =
isPreviewFile ||
COMPOSE_PREVIEW_ANNOTATION_FQN == importDirective.importedFqName?.asString()
isComposableFile =
isComposableFile ||
COMPOSABLE_ANNOTATION_FQ_NAME == importDirective.importedFqName?.asString()
}
override fun visitAnnotationEntry(annotationEntry: KtAnnotationEntry) {
super.visitAnnotationEntry(annotationEntry)
isPreviewFile = isPreviewFile || isPreview(annotationEntry)
isComposableFile =
isComposableFile || annotationEntry.fqNameMatches(COMPOSABLE_ANNOTATION_FQ_NAME)
}
override fun visitNamedFunction(function: KtNamedFunction) {
super.visitNamedFunction(function)
if (!isPreviewFile && !isComposableFile) {
return
}
function.annotationEntries.forEach {
if (isPreviewOrMultiPreview(it)) {
visitPreviewAnnotation(holder, function, it)
}
}
}
override fun visitClass(klass: KtClass) {
super.visitClass(klass)
if (!klass.isAnnotation()) return
klass.annotationEntries.forEach {
if (isPreviewOrMultiPreview(it)) {
visitPreviewAnnotation(holder, klass, it)
}
}
}
}
} else {
PsiElementVisitor.EMPTY_VISITOR
}
}
/**
* Returns whether the [KtParameter] can be used in the preview. This will return true if the
* parameter has a default value or a value provider.
*/
private fun KtParameter.isAcceptableForPreview(): Boolean =
hasDefaultValue() ||
// We also accept parameters with the @PreviewParameter annotation
annotationEntries.any { it.fqNameMatches(COMPOSE_PREVIEW_PARAMETER_ANNOTATION_FQN) }
/**
* Inspection that checks that any function annotated with `@Preview`, or with a MultiPreview, does
* not have parameters.
*/
class PreviewAnnotationInFunctionWithParametersInspection : BasePreviewAnnotationInspection() {
override fun visitPreviewAnnotation(
holder: ProblemsHolder,
function: KtNamedFunction,
previewAnnotation: KtAnnotationEntry,
) {
if (function.valueParameters.any { !it.isAcceptableForPreview() }) {
holder.registerProblem(
previewAnnotation.psiOrParent as PsiElement,
message("inspection.no.parameters.or.provider.description"),
ProblemHighlightType.ERROR,
)
}
}
override fun visitPreviewAnnotation(
holder: ProblemsHolder,
annotationClass: KtClass,
previewAnnotation: KtAnnotationEntry,
) {
// This inspection only applies for functions, not for Annotation classes
return
}
override fun getStaticDescription() = message("inspection.no.parameters.or.provider.description")
}
/**
* Inspection that checks that any function annotated with `@Preview`, or with a MultiPreview, has
* at most one `@PreviewParameter`.
*/
class PreviewMultipleParameterProvidersInspection : BasePreviewAnnotationInspection() {
override fun visitPreviewAnnotation(
holder: ProblemsHolder,
function: KtNamedFunction,
previewAnnotation: KtAnnotationEntry,
) {
// Find the second PreviewParameter annotation if any
val secondPreviewParameter =
function.valueParameters
.mapNotNull {
it.annotationEntries.firstOrNull { annotation ->
annotation.fqNameMatches(COMPOSE_PREVIEW_PARAMETER_ANNOTATION_FQN)
}
}
.drop(1)
.firstOrNull() ?: return
// Flag the second annotation as the error
holder.registerProblem(
secondPreviewParameter as PsiElement,
message("inspection.no.multiple.preview.provider.description"),
ProblemHighlightType.ERROR,
)
}
override fun visitPreviewAnnotation(
holder: ProblemsHolder,
annotationClass: KtClass,
previewAnnotation: KtAnnotationEntry,
) {
// This inspection only applies for functions, not for Annotation classes
return
}
override fun getStaticDescription() =
message("inspection.no.multiple.preview.provider.description")
}
/**
* Inspection that checks that any function annotated with `@Preview`, or with a MultiPreview, is
* also annotated with `@Composable`.
*/
class PreviewNeedsComposableAnnotationInspection : BasePreviewAnnotationInspection() {
override fun visitPreviewAnnotation(
holder: ProblemsHolder,
function: KtNamedFunction,
previewAnnotation: KtAnnotationEntry,
) {
val nonComposable =
function.annotationEntries.none { it.fqNameMatches(COMPOSABLE_ANNOTATION_FQ_NAME) }
if (nonComposable) {
holder.registerProblem(
previewAnnotation.psiOrParent as PsiElement,
message("inspection.no.composable.description"),
ProblemHighlightType.ERROR,
)
}
}
override fun visitPreviewAnnotation(
holder: ProblemsHolder,
annotationClass: KtClass,
previewAnnotation: KtAnnotationEntry,
) {
// This inspection only applies for functions, not for Annotation classes
return
}
override fun getStaticDescription() = message("inspection.no.composable.description")
}
/**
* Inspection that checks that any function annotated with `@Preview`, or with a MultiPreview, is a
* top level function. This is to avoid `@Preview` methods to be instance methods of classes that we
* can not instantiate.
*/
class PreviewMustBeTopLevelFunction : BasePreviewAnnotationInspection() {
override fun visitPreviewAnnotation(
holder: ProblemsHolder,
function: KtNamedFunction,
previewAnnotation: KtAnnotationEntry,
) {
if (function.isValidPreviewLocation()) return
holder.registerProblem(
previewAnnotation.psiOrParent as PsiElement,
message("inspection.top.level.function"),
ProblemHighlightType.ERROR,
)
}
override fun visitPreviewAnnotation(
holder: ProblemsHolder,
annotationClass: KtClass,
previewAnnotation: KtAnnotationEntry,
) {
// This inspection only applies for functions, not for Annotation classes
return
}
override fun getStaticDescription() = message("inspection.top.level.function")
}
/**
* Inspection that checks that `@Preview` width parameter doesn't go higher than [MAX_WIDTH], and
* the height parameter doesn't go higher than [MAX_HEIGHT].
*/
class PreviewDimensionRespectsLimit : BasePreviewAnnotationInspection() {
override fun visitPreviewAnnotation(
holder: ProblemsHolder,
function: KtNamedFunction,
previewAnnotation: KtAnnotationEntry,
) {
checkMaxWidthAndHeight(holder, previewAnnotation)
}
override fun visitPreviewAnnotation(
holder: ProblemsHolder,
annotationClass: KtClass,
previewAnnotation: KtAnnotationEntry,
) {
checkMaxWidthAndHeight(holder, previewAnnotation)
}
private fun checkMaxWidthAndHeight(holder: ProblemsHolder, previewAnnotation: KtAnnotationEntry) {
// If it's not a preview, it must be a MultiPreview, and MultiPreview parameters don't affect
// the Previews
if (!isPreview(previewAnnotation)) return
previewAnnotation.findValueArgument(PARAMETER_WIDTH_DP)?.let {
if (it.exceedsLimit(MAX_WIDTH)) {
holder.registerProblem(
it.psiOrParent as PsiElement,
message("inspection.width.limit.description", MAX_WIDTH),
ProblemHighlightType.WARNING,
)
}
}
previewAnnotation.findValueArgument(PARAMETER_HEIGHT_DP)?.let {
if (it.exceedsLimit(MAX_HEIGHT)) {
holder.registerProblem(
it.psiOrParent as PsiElement,
message("inspection.height.limit.description", MAX_HEIGHT),
ProblemHighlightType.WARNING,
)
}
}
}
override fun getStaticDescription() =
message("inspection.width.height.limit.description", MAX_WIDTH, MAX_HEIGHT)
}
/** Inspection that checks if `@Preview` fontScale parameter is not positive. */
class PreviewFontScaleMustBeGreaterThanZero : BasePreviewAnnotationInspection() {
override fun visitPreviewAnnotation(
holder: ProblemsHolder,
function: KtNamedFunction,
previewAnnotation: KtAnnotationEntry,
) {
checkMinFontScale(holder, previewAnnotation)
}
override fun visitPreviewAnnotation(
holder: ProblemsHolder,
annotationClass: KtClass,
previewAnnotation: KtAnnotationEntry,
) {
checkMinFontScale(holder, previewAnnotation)
}
private fun checkMinFontScale(holder: ProblemsHolder, previewAnnotation: KtAnnotationEntry) {
// If it's not a preview, it must be a MultiPreview, and MultiPreview parameters don't affect
// the Previews
if (!isPreview(previewAnnotation)) return
previewAnnotation.findValueArgument(PARAMETER_FONT_SCALE)?.let {
val argumentExpression = it.getArgumentExpression() ?: return
val fontScale = argumentExpression.evaluateConstant<Float>() ?: return
if (fontScale <= 0) {
holder.registerProblem(
it.psiOrParent as PsiElement,
message("inspection.preview.font.scale.description"),
ProblemHighlightType.ERROR,
)
}
}
}
override fun getStaticDescription() = message("inspection.preview.font.scale.description")
}
/** Inspection that checks if `@Preview` apiLevel is valid. */
class PreviewApiLevelMustBeValid : BasePreviewAnnotationInspection() {
override fun visitPreviewAnnotation(
holder: ProblemsHolder,
function: KtNamedFunction,
previewAnnotation: KtAnnotationEntry,
) {
checkApiLevelIsValid(holder, previewAnnotation)
}
override fun visitPreviewAnnotation(
holder: ProblemsHolder,
annotationClass: KtClass,
previewAnnotation: KtAnnotationEntry,
) {
checkApiLevelIsValid(holder, previewAnnotation)
}
private fun checkApiLevelIsValid(holder: ProblemsHolder, previewAnnotation: KtAnnotationEntry) {
// If it's not a preview, it must be a MultiPreview, and MultiPreview parameters don't affect
// the Previews
if (!isPreview(previewAnnotation)) return
val supportedApiLevels =
previewAnnotation.module?.let { module ->
ConfigurationManager.findExistingInstance(module)
?.targets
?.filter { it.isLayoutLibTarget }
?.map { it.version.apiLevel }
?.takeIf { it.isNotEmpty() }
} ?: listOf(SdkVersionInfo.LOWEST_COMPILE_SDK_VERSION, SdkVersionInfo.HIGHEST_SUPPORTED_API)
val (min, max) = supportedApiLevels.minOrNull()!! to supportedApiLevels.maxOrNull()!!
previewAnnotation.findValueArgument(PARAMETER_API_LEVEL)?.let {
val argumentExpression = it.getArgumentExpression() ?: return
val apiLevel = argumentExpression.evaluateConstant<Int>() ?: return
if (apiLevel < min || apiLevel > max) {
holder.registerProblem(
it.psiOrParent as PsiElement,
message("inspection.preview.api.level.description", min, max),
ProblemHighlightType.ERROR,
)
}
}
}
override fun getStaticDescription() = message("inspection.preview.api.level.static.description")
}
private fun KtNamedFunction.isInUnitTestFile() =
isUnitTestFile(this.project, this.containingFile.virtualFile)
/**
* Inspection that checks that functions annotated with `@Preview`, or with a MultiPreview, are not
* in a unit test file.
*/
class PreviewNotSupportedInUnitTestFiles : BasePreviewAnnotationInspection() {
override fun visitPreviewAnnotation(
holder: ProblemsHolder,
function: KtNamedFunction,
previewAnnotation: KtAnnotationEntry,
) {
// If the annotation is not in a unit test file, then this inspection has nothing to do
if (!function.isInUnitTestFile()) return
holder.registerProblem(
previewAnnotation.psiOrParent as PsiElement,
message("inspection.unit.test.files"),
ProblemHighlightType.ERROR,
)
}
override fun visitPreviewAnnotation(
holder: ProblemsHolder,
annotationClass: KtClass,
previewAnnotation: KtAnnotationEntry,
) {
// This inspection only applies for functions, not for Annotation classes
return
}
override fun getStaticDescription() = message("inspection.unit.test.files")
}
/** Inspection that checks that Preview functions are not called recursively. */
class PreviewShouldNotBeCalledRecursively : AbstractKotlinInspection() {
override fun getStaticDescription() = message("inspection.preview.recursive.description")
override fun buildVisitor(
holder: ProblemsHolder,
isOnTheFly: Boolean,
session: LocalInspectionToolSession,
): PsiElementVisitor =
if (session.file.androidFacet != null || ApplicationManager.getApplication().isUnitTestMode) {
object : KtVisitorVoid() {
override fun visitCallExpression(expression: KtCallExpression) {
super.visitCallExpression(expression)
val parentFunction = expression.psiOrParent.parentOfType<KtNamedFunction>() ?: return
if (!parentFunction.isComposablePreviewFunction()) return
if (expression.calleeFunctionName()?.asString() == parentFunction.name) {
holder.registerProblem(
expression.psiOrParent as PsiElement,
message("inspection.preview.recursive.description"),
ProblemHighlightType.WEAK_WARNING,
)
}
}
private fun KtNamedFunction.isComposablePreviewFunction() =
annotationEntries.any {
it.fqNameMatches(COMPOSE_PREVIEW_ANNOTATION_FQN) ||
(it.toUElement() as? UAnnotation).isMultiPreviewAnnotation()
}
private fun KtCallExpression.calleeFunctionName() =
if (KotlinPluginModeProvider.isK2Mode()) {
analyze(this) {
val functionSymbol = resolveCall()?.singleFunctionCallOrNull()?.symbol
functionSymbol?.callableIdIfNonLocal?.callableName
}
} else {
val resolvedExpression = resolveToCall()
resolvedExpression?.resultingDescriptor?.name
}
}
} else {
PsiElementVisitor.EMPTY_VISITOR
}
}
private fun KtValueArgument.exceedsLimit(limit: Int): Boolean {
val argumentExpression = getArgumentExpression() ?: return false
val dimension = argumentExpression.evaluateConstant<Int>() ?: return false
return dimension > limit
}