Skip to content

Commit 06a6e8b

Browse files
author
Alex Matečný
authored
Add lint for Orbit buttons usage within Material's AlertDialog (#535)
Add lint detector checking usage of Orbit BUttons within Material Dialogs
1 parent 940b6a9 commit 06a6e8b

File tree

3 files changed

+223
-0
lines changed

3 files changed

+223
-0
lines changed

lint/src/main/kotlin/kiwi/orbit/compose/lint/OrbitComposeIssueRegistry.kt

+2
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@ import com.android.tools.lint.client.api.IssueRegistry
44
import com.android.tools.lint.client.api.Vendor
55
import com.android.tools.lint.detector.api.CURRENT_API
66
import kiwi.orbit.compose.lint.detectors.MaterialDesignInsteadOrbitDesignDetector
7+
import kiwi.orbit.compose.lint.detectors.MaterialDialogWithOrbitButtonsDetector
78
import kiwi.orbit.compose.lint.detectors.ScaffoldContentPaddingDetector
89

910
@Suppress("UnstableApiUsage")
1011
class OrbitComposeIssueRegistry : IssueRegistry() {
1112
override val issues = listOf(
1213
MaterialDesignInsteadOrbitDesignDetector.ISSUE,
14+
MaterialDialogWithOrbitButtonsDetector.ISSUE,
1315
ScaffoldContentPaddingDetector.ISSUE,
1416
)
1517

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
package kiwi.orbit.compose.lint.detectors
2+
3+
import com.android.tools.lint.detector.api.Category
4+
import com.android.tools.lint.detector.api.Detector
5+
import com.android.tools.lint.detector.api.Implementation
6+
import com.android.tools.lint.detector.api.Issue
7+
import com.android.tools.lint.detector.api.JavaContext
8+
import com.android.tools.lint.detector.api.Scope
9+
import com.android.tools.lint.detector.api.Severity
10+
import com.android.tools.lint.detector.api.SourceCodeScanner
11+
import com.android.tools.lint.detector.api.computeKotlinArgumentMapping
12+
import com.intellij.psi.PsiElement
13+
import com.intellij.psi.PsiJavaFile
14+
import com.intellij.psi.PsiMember
15+
import com.intellij.psi.PsiMethod
16+
import com.intellij.psi.PsiNamedElement
17+
import org.jetbrains.uast.UBlockExpression
18+
import org.jetbrains.uast.UCallExpression
19+
import org.jetbrains.uast.ULambdaExpression
20+
import org.jetbrains.uast.skipParenthesizedExprDown
21+
import org.jetbrains.uast.tryResolveNamed
22+
23+
class MaterialDialogWithOrbitButtonsDetector : Detector(), SourceCodeScanner {
24+
25+
companion object {
26+
internal val ISSUE: Issue = Issue.create(
27+
id = "MaterialDialogWithOrbitButtonsDetector",
28+
briefDescription = "Usage of Orbit buttons inside Material Dialogs.",
29+
explanation = "Use Material TextButton inside of the Material Dialogs to preserve the material look.",
30+
category = Category.CUSTOM_LINT_CHECKS,
31+
priority = 7,
32+
severity = Severity.ERROR,
33+
implementation = Implementation(
34+
MaterialDialogWithOrbitButtonsDetector::class.java,
35+
Scope.JAVA_FILE_SCOPE,
36+
),
37+
)
38+
39+
private const val PACKAGE_MATERIAL_ALERT = "androidx.compose.material3"
40+
private const val MATERIAL_ALERT_CONFIRM_BUTTON_PARAM = "confirmButton"
41+
private const val MATERIAL_ALERT_DISMISS_BUTTON_PARAM = "dismissButton"
42+
43+
private const val PACKAGE_MATERIAL_TEXT_BUTTON = "androidx.compose.material3.TextButton"
44+
private const val NAME_MATERIAL_TEXT_BUTTON = "TextButton"
45+
46+
private const val PACKAGE_ORBIT_BUTTON = "kiwi.orbit.compose.ui.controls"
47+
private const val NAME_ORBIT_BUTTON = "Button"
48+
}
49+
50+
override fun getApplicableMethodNames(): List<String> = listOf("AlertDialog")
51+
52+
override fun visitMethodCall(context: JavaContext, node: UCallExpression, method: PsiMethod) {
53+
if (method.isInPackageName(PACKAGE_MATERIAL_ALERT)) {
54+
val confirmButtonArgument = getArgument(node, method, MATERIAL_ALERT_CONFIRM_BUTTON_PARAM)
55+
56+
(confirmButtonArgument?.body as? UBlockExpression)?.let { body ->
57+
val resolvedBodyExpression = body.resolveFirstBodyExpression() ?: return@let
58+
val packageName = resolvedBodyExpression.getPackageName() ?: return@let
59+
60+
val expressionName = resolvedBodyExpression.name
61+
val isButton = expressionName?.contains(NAME_ORBIT_BUTTON) == true
62+
val isOrbit = packageName == PACKAGE_ORBIT_BUTTON
63+
if (isButton && isOrbit) {
64+
context.report(
65+
issue = ISSUE,
66+
scope = body,
67+
location = context.getLocation(body),
68+
message = "$expressionName from Orbit used in Material Dialog's confirmButton slot.",
69+
quickfixData = fix()
70+
.replace()
71+
.text(expressionName)
72+
.with(NAME_MATERIAL_TEXT_BUTTON)
73+
.imports(PACKAGE_MATERIAL_TEXT_BUTTON)
74+
.shortenNames()
75+
.build(),
76+
)
77+
}
78+
}
79+
80+
val dismissButtonArgument = getArgument(node, method, MATERIAL_ALERT_DISMISS_BUTTON_PARAM)
81+
82+
(dismissButtonArgument?.body as? UBlockExpression)?.let { body ->
83+
val resolvedBodyExpression = body.resolveFirstBodyExpression() ?: return@let
84+
val packageName = resolvedBodyExpression.getPackageName() ?: return@let
85+
86+
val expressionName = resolvedBodyExpression.name
87+
val isButton = expressionName?.contains(NAME_ORBIT_BUTTON) == true
88+
val isOrbit = packageName == PACKAGE_ORBIT_BUTTON
89+
if (isButton && isOrbit) {
90+
context.report(
91+
issue = ISSUE,
92+
scope = body,
93+
location = context.getLocation(body),
94+
message = "$expressionName from Orbit used in Material Dialog's dismissButton slot.",
95+
quickfixData = fix()
96+
.replace()
97+
.text(expressionName)
98+
.with(NAME_MATERIAL_TEXT_BUTTON)
99+
.imports(PACKAGE_MATERIAL_TEXT_BUTTON)
100+
.shortenNames()
101+
.build(),
102+
)
103+
}
104+
}
105+
}
106+
}
107+
108+
private fun getArgument(
109+
node: UCallExpression,
110+
method: PsiMethod,
111+
argumentName: String,
112+
): ULambdaExpression? = computeKotlinArgumentMapping(node, method)
113+
.orEmpty()
114+
.filter { (_, parameter) ->
115+
parameter.name == argumentName
116+
}
117+
.keys
118+
.filterIsInstance<ULambdaExpression>()
119+
.firstOrNull()
120+
121+
private fun PsiMethod.isInPackageName(packageName: String): Boolean {
122+
val actual = (containingFile as? PsiJavaFile)?.packageName
123+
return packageName == actual
124+
}
125+
126+
private fun PsiElement.getPackageName(): String? = when (this) {
127+
is PsiMember -> this.containingClass?.qualifiedName?.let { it.substring(0, it.lastIndexOf(".")) }
128+
else -> null
129+
}
130+
131+
private fun UBlockExpression.resolveFirstBodyExpression(): PsiNamedElement? {
132+
return expressions.firstOrNull()?.skipParenthesizedExprDown()?.tryResolveNamed()
133+
}
134+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package kiwi.orbit.compose.lint.detectors
2+
3+
import com.android.tools.lint.checks.infrastructure.TestFiles
4+
import com.android.tools.lint.checks.infrastructure.TestLintTask
5+
import org.junit.Test
6+
7+
8+
class MaterialDialogWithOrbitButtonsDetectorTest {
9+
10+
private val ALERT_INFO_STUB = TestFiles.kotlin(
11+
"""
12+
package androidx.compose.material3
13+
14+
fun AlertDialog(
15+
confirmButton: () -> Unit,
16+
dismissButton: (() -> Unit)? = null,
17+
icon: (() -> Unit)? = null,
18+
title: (() -> Unit)? = null,
19+
text: (() -> Unit)? = null,
20+
)
21+
""".trimIndent(),
22+
)
23+
24+
private val ORBIT_BUTTON_PRIMARY_STUB = TestFiles.kotlin(
25+
"""
26+
package kiwi.orbit.compose.ui.controls
27+
28+
fun ButtonPrimary(
29+
onClick: () -> Unit,
30+
)
31+
""".trimIndent(),
32+
)
33+
34+
private val ORBIT_BUTTON_CRITICAL_STUB = TestFiles.kotlin(
35+
"""
36+
package kiwi.orbit.compose.ui.controls
37+
38+
fun ButtonCritical(
39+
onClick: () -> Unit,
40+
)
41+
""".trimIndent(),
42+
)
43+
44+
@Test
45+
fun testDetector() {
46+
TestLintTask.lint()
47+
.files(
48+
TestFiles.kotlin(
49+
"""
50+
package foo
51+
52+
import androidx.compose.material3.AlertDialog
53+
import kiwi.orbit.compose.ui.controls.ButtonPrimary
54+
import kiwi.orbit.compose.ui.controls.ButtonCritical
55+
56+
fun test() {
57+
AlertDialog(confirmButton = { ButtonPrimary( { } ) })
58+
AlertDialog(confirmButton = { ButtonCritical( { } ) })
59+
AlertDialog(
60+
dismissButton = { ButtonPrimary( { } ) },
61+
)
62+
}
63+
""",
64+
),
65+
ALERT_INFO_STUB,
66+
ORBIT_BUTTON_PRIMARY_STUB,
67+
ORBIT_BUTTON_CRITICAL_STUB,
68+
)
69+
.issues(MaterialDialogWithOrbitButtonsDetector.ISSUE)
70+
.allowMissingSdk()
71+
.run()
72+
.expect(
73+
"""
74+
src/foo/test.kt:9: Error: ButtonPrimary from Orbit used in Material Dialog's confirmButton slot. [MaterialDialogWithOrbitButtonsDetector]
75+
AlertDialog(confirmButton = { ButtonPrimary( { } ) })
76+
~~~~~~~~~~~~~~~~~~~~
77+
src/foo/test.kt:10: Error: ButtonCritical from Orbit used in Material Dialog's confirmButton slot. [MaterialDialogWithOrbitButtonsDetector]
78+
AlertDialog(confirmButton = { ButtonCritical( { } ) })
79+
~~~~~~~~~~~~~~~~~~~~~
80+
src/foo/test.kt:12: Error: ButtonPrimary from Orbit used in Material Dialog's dismissButton slot. [MaterialDialogWithOrbitButtonsDetector]
81+
dismissButton = { ButtonPrimary( { } ) },
82+
~~~~~~~~~~~~~~~~~~~~
83+
3 errors, 0 warnings
84+
""".trimIndent(),
85+
)
86+
}
87+
}

0 commit comments

Comments
 (0)