Repository: kiwicom/orbit-compose (kotlin)

Issue:
Material Dialog lint - buttons
Currently, devs tend to use Orbit buttons in `AlertDialog`'s button slots.
Create a lint, that will check for usages of our Orbit buttons within Material Dialogs, instead material buttons should be used.

Pull request:

Patch:
diff --git a/lint/src/main/kotlin/kiwi/orbit/compose/lint/OrbitComposeIssueRegistry.kt b/lint/src/main/kotlin/kiwi/orbit/compose/lint/OrbitComposeIssueRegistry.kt
index 6b0c0aad9..de206d3ee 100644
--- a/lint/src/main/kotlin/kiwi/orbit/compose/lint/OrbitComposeIssueRegistry.kt
+++ b/lint/src/main/kotlin/kiwi/orbit/compose/lint/OrbitComposeIssueRegistry.kt
@@ -4,12 +4,14 @@ import com.android.tools.lint.client.api.IssueRegistry
 import com.android.tools.lint.client.api.Vendor
 import com.android.tools.lint.detector.api.CURRENT_API
 import kiwi.orbit.compose.lint.detectors.MaterialDesignInsteadOrbitDesignDetector
+import kiwi.orbit.compose.lint.detectors.MaterialDialogWithOrbitButtonsDetector
 import kiwi.orbit.compose.lint.detectors.ScaffoldContentPaddingDetector
 
 @Suppress("UnstableApiUsage")
 class OrbitComposeIssueRegistry : IssueRegistry() {
     override val issues = listOf(
         MaterialDesignInsteadOrbitDesignDetector.ISSUE,
+        MaterialDialogWithOrbitButtonsDetector.ISSUE,
         ScaffoldContentPaddingDetector.ISSUE,
     )
 
diff --git a/lint/src/main/kotlin/kiwi/orbit/compose/lint/detectors/MaterialDialogWithOrbitButtonsDetector.kt b/lint/src/main/kotlin/kiwi/orbit/compose/lint/detectors/MaterialDialogWithOrbitButtonsDetector.kt
new file mode 100644
index 000000000..4f9ca5779
--- /dev/null
+++ b/lint/src/main/kotlin/kiwi/orbit/compose/lint/detectors/MaterialDialogWithOrbitButtonsDetector.kt
@@ -0,0 +1,134 @@
+package kiwi.orbit.compose.lint.detectors
+
+import com.android.tools.lint.detector.api.Category
+import com.android.tools.lint.detector.api.Detector
+import com.android.tools.lint.detector.api.Implementation
+import com.android.tools.lint.detector.api.Issue
+import com.android.tools.lint.detector.api.JavaContext
+import com.android.tools.lint.detector.api.Scope
+import com.android.tools.lint.detector.api.Severity
+import com.android.tools.lint.detector.api.SourceCodeScanner
+import com.android.tools.lint.detector.api.computeKotlinArgumentMapping
+import com.intellij.psi.PsiElement
+import com.intellij.psi.PsiJavaFile
+import com.intellij.psi.PsiMember
+import com.intellij.psi.PsiMethod
+import com.intellij.psi.PsiNamedElement
+import org.jetbrains.uast.UBlockExpression
+import org.jetbrains.uast.UCallExpression
+import org.jetbrains.uast.ULambdaExpression
+import org.jetbrains.uast.skipParenthesizedExprDown
+import org.jetbrains.uast.tryResolveNamed
+
+class MaterialDialogWithOrbitButtonsDetector : Detector(), SourceCodeScanner {
+
+    companion object {
+        internal val ISSUE: Issue = Issue.create(
+            id = "MaterialDialogWithOrbitButtonsDetector",
+            briefDescription = "Usage of Orbit buttons inside Material Dialogs.",
+            explanation = "Use Material TextButton inside of the Material Dialogs to preserve the material look.",
+            category = Category.CUSTOM_LINT_CHECKS,
+            priority = 7,
+            severity = Severity.ERROR,
+            implementation = Implementation(
+                MaterialDialogWithOrbitButtonsDetector::class.java,
+                Scope.JAVA_FILE_SCOPE,
+            ),
+        )
+
+        private const val PACKAGE_MATERIAL_ALERT = "androidx.compose.material3"
+        private const val MATERIAL_ALERT_CONFIRM_BUTTON_PARAM = "confirmButton"
+        private const val MATERIAL_ALERT_DISMISS_BUTTON_PARAM = "dismissButton"
+
+        private const val PACKAGE_MATERIAL_TEXT_BUTTON = "androidx.compose.material3.TextButton"
+        private const val NAME_MATERIAL_TEXT_BUTTON = "TextButton"
+
+        private const val PACKAGE_ORBIT_BUTTON = "kiwi.orbit.compose.ui.controls"
+        private const val NAME_ORBIT_BUTTON = "Button"
+    }
+
+    override fun getApplicableMethodNames(): List<String> = listOf("AlertDialog")
+
+    override fun visitMethodCall(context: JavaContext, node: UCallExpression, method: PsiMethod) {
+        if (method.isInPackageName(PACKAGE_MATERIAL_ALERT)) {
+            val confirmButtonArgument = getArgument(node, method, MATERIAL_ALERT_CONFIRM_BUTTON_PARAM)
+
+            (confirmButtonArgument?.body as? UBlockExpression)?.let { body ->
+                val resolvedBodyExpression = body.resolveFirstBodyExpression() ?: return@let
+                val packageName = resolvedBodyExpression.getPackageName() ?: return@let
+
+                val expressionName = resolvedBodyExpression.name
+                val isButton = expressionName?.contains(NAME_ORBIT_BUTTON) == true
+                val isOrbit = packageName == PACKAGE_ORBIT_BUTTON
+                if (isButton && isOrbit) {
+                    context.report(
+                        issue = ISSUE,
+                        scope = body,
+                        location = context.getLocation(body),
+                        message = "$expressionName from Orbit used in Material Dialog's confirmButton slot.",
+                        quickfixData = fix()
+                            .replace()
+                            .text(expressionName)
+                            .with(NAME_MATERIAL_TEXT_BUTTON)
+                            .imports(PACKAGE_MATERIAL_TEXT_BUTTON)
+                            .shortenNames()
+                            .build(),
+                    )
+                }
+            }
+
+            val dismissButtonArgument = getArgument(node, method, MATERIAL_ALERT_DISMISS_BUTTON_PARAM)
+
+            (dismissButtonArgument?.body as? UBlockExpression)?.let { body ->
+                val resolvedBodyExpression = body.resolveFirstBodyExpression() ?: return@let
+                val packageName = resolvedBodyExpression.getPackageName() ?: return@let
+
+                val expressionName = resolvedBodyExpression.name
+                val isButton = expressionName?.contains(NAME_ORBIT_BUTTON) == true
+                val isOrbit = packageName == PACKAGE_ORBIT_BUTTON
+                if (isButton && isOrbit) {
+                    context.report(
+                        issue = ISSUE,
+                        scope = body,
+                        location = context.getLocation(body),
+                        message = "$expressionName from Orbit used in Material Dialog's dismissButton slot.",
+                        quickfixData = fix()
+                            .replace()
+                            .text(expressionName)
+                            .with(NAME_MATERIAL_TEXT_BUTTON)
+                            .imports(PACKAGE_MATERIAL_TEXT_BUTTON)
+                            .shortenNames()
+                            .build(),
+                    )
+                }
+            }
+        }
+    }
+
+    private fun getArgument(
+        node: UCallExpression,
+        method: PsiMethod,
+        argumentName: String,
+    ): ULambdaExpression? = computeKotlinArgumentMapping(node, method)
+        .orEmpty()
+        .filter { (_, parameter) ->
+            parameter.name == argumentName
+        }
+        .keys
+        .filterIsInstance<ULambdaExpression>()
+        .firstOrNull()
+
+    private fun PsiMethod.isInPackageName(packageName: String): Boolean {
+        val actual = (containingFile as? PsiJavaFile)?.packageName
+        return packageName == actual
+    }
+
+    private fun PsiElement.getPackageName(): String? = when (this) {
+        is PsiMember -> this.containingClass?.qualifiedName?.let { it.substring(0, it.lastIndexOf(".")) }
+        else -> null
+    }
+
+    private fun UBlockExpression.resolveFirstBodyExpression(): PsiNamedElement? {
+        return expressions.firstOrNull()?.skipParenthesizedExprDown()?.tryResolveNamed()
+    }
+}


Test patch:
diff --git a/lint/src/test/kotlin/kiwi/orbit/compose/lint/detectors/MaterialDialogWithOrbitButtonsDetectorTest.kt b/lint/src/test/kotlin/kiwi/orbit/compose/lint/detectors/MaterialDialogWithOrbitButtonsDetectorTest.kt
new file mode 100644
index 000000000..73464602e
--- /dev/null
+++ b/lint/src/test/kotlin/kiwi/orbit/compose/lint/detectors/MaterialDialogWithOrbitButtonsDetectorTest.kt
@@ -0,0 +1,87 @@
+package kiwi.orbit.compose.lint.detectors
+
+import com.android.tools.lint.checks.infrastructure.TestFiles
+import com.android.tools.lint.checks.infrastructure.TestLintTask
+import org.junit.Test
+
+
+class MaterialDialogWithOrbitButtonsDetectorTest {
+
+    private val ALERT_INFO_STUB = TestFiles.kotlin(
+        """
+            package androidx.compose.material3
+
+            fun AlertDialog(
+                confirmButton: () -> Unit,
+                dismissButton: (() -> Unit)? = null,
+                icon: (() -> Unit)? = null,
+                title: (() -> Unit)? = null,
+                text: (() -> Unit)? = null,
+            ) 
+            """.trimIndent(),
+    )
+
+    private val ORBIT_BUTTON_PRIMARY_STUB = TestFiles.kotlin(
+        """
+            package kiwi.orbit.compose.ui.controls
+            
+            fun ButtonPrimary(
+                onClick: () -> Unit,
+            ) 
+        """.trimIndent(),
+    )
+
+    private val ORBIT_BUTTON_CRITICAL_STUB = TestFiles.kotlin(
+        """
+            package kiwi.orbit.compose.ui.controls
+            
+            fun ButtonCritical(
+                onClick: () -> Unit,
+            ) 
+        """.trimIndent(),
+    )
+
+    @Test
+    fun testDetector() {
+        TestLintTask.lint()
+            .files(
+                TestFiles.kotlin(
+                    """
+                    package foo
+
+                    import androidx.compose.material3.AlertDialog
+                    import kiwi.orbit.compose.ui.controls.ButtonPrimary
+                    import kiwi.orbit.compose.ui.controls.ButtonCritical
+
+                    fun test() {
+                        AlertDialog(confirmButton = { ButtonPrimary( { } ) })
+                        AlertDialog(confirmButton = { ButtonCritical( { } ) })
+                        AlertDialog(
+                            dismissButton = { ButtonPrimary( { } ) },
+                        )
+                    }
+                    """,
+                ),
+                ALERT_INFO_STUB,
+                ORBIT_BUTTON_PRIMARY_STUB,
+                ORBIT_BUTTON_CRITICAL_STUB,
+            )
+            .issues(MaterialDialogWithOrbitButtonsDetector.ISSUE)
+            .allowMissingSdk()
+            .run()
+            .expect(
+                """
+                    src/foo/test.kt:9: Error: ButtonPrimary from Orbit used in Material Dialog's confirmButton slot. [MaterialDialogWithOrbitButtonsDetector]
+                                            AlertDialog(confirmButton = { ButtonPrimary( { } ) })
+                                                                          ~~~~~~~~~~~~~~~~~~~~
+                    src/foo/test.kt:10: Error: ButtonCritical from Orbit used in Material Dialog's confirmButton slot. [MaterialDialogWithOrbitButtonsDetector]
+                                            AlertDialog(confirmButton = { ButtonCritical( { } ) })
+                                                                          ~~~~~~~~~~~~~~~~~~~~~
+                    src/foo/test.kt:12: Error: ButtonPrimary from Orbit used in Material Dialog's dismissButton slot. [MaterialDialogWithOrbitButtonsDetector]
+                                                dismissButton = { ButtonPrimary( { } ) },
+                                                                  ~~~~~~~~~~~~~~~~~~~~
+                    3 errors, 0 warnings
+                """.trimIndent(),
+            )
+    }
+}