All Downloads are FREE. Search and download functionalities are using the official Maven repository.

main.io.nlopez.compose.rules.ModifierClickableOrder.kt Maven / Gradle / Ivy

There is a newer version: 0.4.22
Show newest version
// Copyright 2023 Nacho Lopez
// SPDX-License-Identifier: Apache-2.0
package io.nlopez.compose.rules

import io.nlopez.compose.core.ComposeKtConfig
import io.nlopez.compose.core.ComposeKtVisitor
import io.nlopez.compose.core.Emitter
import io.nlopez.compose.core.report
import io.nlopez.compose.core.util.argumentsUsingModifiers
import io.nlopez.compose.core.util.findChildrenByClass
import io.nlopez.compose.core.util.modifierParameters
import io.nlopez.compose.core.util.obtainAllModifierNames
import org.jetbrains.kotlin.psi.KtCallExpression
import org.jetbrains.kotlin.psi.KtDotQualifiedExpression
import org.jetbrains.kotlin.psi.KtExpression
import org.jetbrains.kotlin.psi.KtFunction
import org.jetbrains.kotlin.psi.KtIfExpression
import org.jetbrains.kotlin.psi.KtReferenceExpression
import org.jetbrains.kotlin.psi.KtValueArgument

class ModifierClickableOrder : ComposeKtVisitor {

    override fun visitComposable(
        function: KtFunction,
        autoCorrect: Boolean,
        emitter: Emitter,
        config: ComposeKtConfig,
    ) {
        val code = function.bodyBlockExpression ?: return

        val initialModifierNames = with(config) { function.modifierParameters.mapNotNull { it.name } }
        val modifiers = initialModifierNames.flatMap { code.obtainAllModifierNames(it) }.toSet()

        val suspiciousOrderModifiers = code.findChildrenByClass()
            .filter { it.calleeExpression?.text?.first()?.isUpperCase() == true }
            .flatMap { callExpression ->
                callExpression.argumentsUsingModifiers(modifiers + "Modifier")
            }
            // We only want chains of more than 1 modifier
            .mapNotNull { argument -> argument.getArgumentExpression() }
            .filterIsInstance()
            .mapNotNull { chain -> chain.findCallExpressionSuspiciousOrder() }

        for (methodInvocation in suspiciousOrderModifiers) {
            emitter.report(methodInvocation, ModifierChainWithSuspiciousOrder)
        }
    }

    private fun KtDotQualifiedExpression.findCallExpressionSuspiciousOrder(): KtCallExpression? {
        // KtDotQualifiedExpression are resolved from end to beginning, so:
        // Modifier.a().b().c() -> (2) + CallExpression c ==> 3
        // Modifier.a().b() -> (1) + CallExpression b ==> 2
        // Modifier.a() -> (root expression) + CallExpression a ==> 1

        var currentReceiver: KtExpression = receiverExpression
        var currentSelector: KtExpression? = selectorExpression

        var shapeAlteringCandidate = false
        while (currentSelector != null) {
            if (currentSelector is KtCallExpression) {
                when {
                    shapeAlteringCandidate && currentSelector.isClickableInteraction -> return currentSelector
                    currentSelector.isClipWithShape -> shapeAlteringCandidate = true
                    currentSelector.isBorderWithShape -> shapeAlteringCandidate = true
                    currentSelector.isBackgroundWithShape -> shapeAlteringCandidate = true
                    currentSelector.isThen -> {
                        val param = currentSelector.valueArguments.firstOrNull()
                        if (param != null) {
                            val argumentExpression = param.getArgumentExpression()
                            if (argumentExpression is KtIfExpression) {
                                // If any of the two branches from the `if` passes the same checks for sus methods,
                                // we flag them as well.
                                val suspicious = sequenceOf(argumentExpression.then, argumentExpression.`else`)
                                    .filterNotNull()
                                    .filterIsInstance()
                                    .any { it.isClipWithShape || it.isBackgroundWithShape || it.isBorderWithShape }

                                if (suspicious) {
                                    shapeAlteringCandidate = true
                                }
                            }
                        }
                    }

                    else -> {
                        // no-op
                    }
                }
            }

            if (currentReceiver is KtDotQualifiedExpression) {
                currentSelector = currentReceiver.selectorExpression
                currentReceiver = currentReceiver.receiverExpression
            } else {
                // If currentReceiver isn't a dot qualified expression anymore it means that we reached the top of the
                // chain, and we are not interesting on it anymore.
                currentSelector = null
            }
        }

        return null
    }

    private val KtCallExpression.isClickableInteraction: Boolean
        get() = calleeExpression?.text in interactionModifiers

    private val KtCallExpression.isThen: Boolean
        get() = calleeExpression?.text == "then"

    private val KtCallExpression.isClipWithShape: Boolean
        get() = calleeExpression?.text == "clip" // any clip will reference a shape

    private val KtCallExpression.isBackgroundWithShape: Boolean
        get() = calleeExpression?.text == "background" && valueArguments.any { it.isNamedShape || it.referencesShape }

    private val KtCallExpression.isBorderWithShape: Boolean
        get() = calleeExpression?.text == "border" && valueArguments.any { it.isNamedShape || it.referencesShape }

    private val KtValueArgument.isNamedShape: Boolean
        get() = isNamed() && name == "shape"

    private val KtValueArgument.referencesShape: Boolean
        get() = when (val expression = getArgumentExpression()) {
            // MyShape()
            is KtCallExpression -> expression.calleeExpression?.text?.endsWith("Shape") == true
            // MyShape
            is KtReferenceExpression -> expression.text.endsWith("Shape")
            // if (x) MyShape else MyOtherShape
            is KtIfExpression -> expression.then?.text?.endsWith("Shape") == true ||
                expression.`else`?.text?.endsWith("Shape") == true
            // MaterialTheme.shapes.x or LocalShapes.current.x or AppShapes.x or MyThemeShapes.x
            is KtDotQualifiedExpression -> expression.text.startsWith("MaterialTheme.shapes") ||
                expression.text.contains("Shape")

            else -> false
        }

    companion object {
        private val interactionModifiers = setOf(
            "clickable",
            "selectable",
            "toggleable",
            "triStateToggleable",
            "combinedClickable",
        )

        val ModifierChainWithSuspiciousOrder = """
            This order of modifiers is likely to cause visual issues. You should have your clickable modifiers after modifiers that use shapes, so that the clickable selected area takes into account the change in shape as well.

            See https://mrmans0n.github.io/compose-rules/rules/#modifier-order-matters for more information.
        """.trimIndent()
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy