Skip to content

Commit 1199971

Browse files
Ignore composables that emit content in their own window. (#110)
* Ignore composables that emit content in their own window. Fixes #109 * Lower case the test names Co-authored-by: Nacho Lopez <[email protected]> * Refactor the sequence function to be tailrec. Co-authored-by: Nacho Lopez <[email protected]>
1 parent b76779f commit 1199971

File tree

4 files changed

+133
-11
lines changed

4 files changed

+133
-11
lines changed

core-common/src/main/kotlin/com/twitter/rules/core/util/Composables.kt

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
package com.twitter.rules.core.util
44

55
import com.twitter.rules.core.ComposeKtConfig.Companion.config
6+
import org.jetbrains.kotlin.com.intellij.psi.PsiElement
67
import org.jetbrains.kotlin.psi.KtCallExpression
78
import org.jetbrains.kotlin.psi.KtCallableDeclaration
89
import org.jetbrains.kotlin.psi.KtFunction
@@ -11,7 +12,36 @@ import org.jetbrains.kotlin.psi.KtProperty
1112
import org.jetbrains.kotlin.psi.psiUtil.referenceExpression
1213

1314
val KtFunction.emitsContent: Boolean
14-
get() = if (isComposable) findChildrenByClass<KtCallExpression>().any { it.emitsContent } else false
15+
get() {
16+
return if (isComposable) {
17+
sequence {
18+
tailrec suspend fun SequenceScope<KtCallExpression>.scan(elements: List<PsiElement>) {
19+
if (elements.isEmpty()) return
20+
val toProcess = elements
21+
.mapNotNull { current ->
22+
if (current is KtCallExpression) {
23+
if (current.emitExplicitlyNoContent) {
24+
null
25+
} else {
26+
yield(current)
27+
current
28+
}
29+
} else {
30+
current
31+
}
32+
}
33+
.flatMap { it.children.toList() }
34+
return scan(toProcess)
35+
}
36+
scan(listOf(this@emitsContent))
37+
}.any { it.emitsContent }
38+
} else {
39+
false
40+
}
41+
}
42+
43+
private val KtCallExpression.emitExplicitlyNoContent: Boolean
44+
get() = calleeExpression?.text in ComposableNonEmittersList
1545

1646
val KtCallExpression.emitsContent: Boolean
1747
get() {
@@ -28,6 +58,15 @@ private val KtCallExpression.containsComposablesWithModifiers: Boolean
2858
.filter { it.isNamed() }
2959
.any { it.getArgumentName()?.text == "modifier" }
3060

61+
/**
62+
* This is a denylist with common composables that emit content in their own window. Feel free to add more elements
63+
* if you stumble upon them in code reviews that should have triggered an error from this rule.
64+
*/
65+
private val ComposableNonEmittersList = setOf(
66+
"AlertDialog",
67+
"ModalBottomSheetLayout"
68+
)
69+
3170
/**
3271
* This is an allowlist with common composables that emit content. Feel free to add more elements if you stumble
3372
* upon them in code reviews that should have triggered an error from this rule.

core-common/src/main/kotlin/com/twitter/rules/core/util/PsiElements.kt

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,28 +5,27 @@ package com.twitter.rules.core.util
55
import org.jetbrains.kotlin.com.intellij.psi.PsiElement
66
import org.jetbrains.kotlin.com.intellij.psi.PsiNameIdentifierOwner
77
import org.jetbrains.kotlin.psi.psiUtil.startOffset
8-
import java.util.*
8+
import java.util.Deque
9+
import java.util.LinkedList
910

1011
inline fun <reified T : PsiElement> PsiElement.findChildrenByClass(): Sequence<T> =
1112
sequence {
12-
val klass = T::class
1313
val queue: Deque<PsiElement> = LinkedList()
1414
queue.add(this@findChildrenByClass)
1515
while (queue.isNotEmpty()) {
1616
val current = queue.pop()
17-
if (klass.isInstance(current)) {
18-
yield(current as T)
17+
if (current is T) {
18+
yield(current)
1919
}
2020
queue.addAll(current.children)
2121
}
2222
}
2323

2424
inline fun <reified T : PsiElement> PsiElement.findDirectFirstChildByClass(): T? {
25-
val klass = T::class
2625
var current = firstChild
2726
while (current != null) {
28-
if (klass.isInstance(current)) {
29-
return current as T
27+
if (current is T) {
28+
return current
3029
}
3130
current = current.nextSibling
3231
}
@@ -35,11 +34,10 @@ inline fun <reified T : PsiElement> PsiElement.findDirectFirstChildByClass(): T?
3534

3635
inline fun <reified T : PsiElement> PsiElement.findDirectChildrenByClass(): Sequence<T> =
3736
sequence {
38-
val klass = T::class
3937
var current = firstChild
4038
while (current != null) {
41-
if (klass.isInstance(current)) {
42-
yield(current as T)
39+
if (current is T) {
40+
yield(current)
4341
}
4442
current = current.nextSibling
4543
}

rules/detekt/src/test/kotlin/com/twitter/compose/rules/detekt/ComposeModifierMissingCheckTest.kt

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,4 +200,45 @@ class ComposeModifierMissingCheckTest {
200200
val errors = rule.lint(code)
201201
assertThat(errors).isEmpty()
202202
}
203+
204+
@Test
205+
fun `non content emitting root composables are ignored`() {
206+
@Language("kotlin")
207+
val code =
208+
"""
209+
@Composable
210+
fun MyDialog() {
211+
AlertDialog(
212+
onDismissRequest = { /*TODO*/ },
213+
buttons = { Text(text = "Button") },
214+
text = { Text(text = "Body") },
215+
)
216+
}
217+
""".trimIndent()
218+
219+
val errors = rule.lint(code)
220+
assertThat(errors).isEmpty()
221+
}
222+
223+
@Test
224+
fun `non content emitter with content emitter not ignored`() {
225+
@Language("kotlin")
226+
val code =
227+
"""
228+
@Composable
229+
fun MyDialog() {
230+
Text(text = "Unicorn")
231+
232+
AlertDialog(
233+
onDismissRequest = { /*TODO*/ },
234+
buttons = { Text(text = "Button") },
235+
text = { Text(text = "Body") },
236+
)
237+
}
238+
""".trimIndent()
239+
240+
val errors = rule.lint(code)
241+
assertThat(errors).hasTextLocations("MyDialog")
242+
assertThat(errors[0]).hasMessage(ComposeModifierMissing.MissingModifierContentComposable)
243+
}
203244
}

rules/ktlint/src/test/kotlin/com/twitter/compose/rules/ktlint/ComposeModifierMissingCheckTest.kt

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,4 +214,48 @@ class ComposeModifierMissingCheckTest {
214214

215215
modifierRuleAssertThat(code).hasNoLintViolations()
216216
}
217+
218+
@Test
219+
fun `non content emitting root composables are ignored`() {
220+
@Language("kotlin")
221+
val code =
222+
"""
223+
@Composable
224+
fun MyDialog() {
225+
AlertDialog(
226+
onDismissRequest = { /*TODO*/ },
227+
buttons = { Text(text = "Button") },
228+
text = { Text(text = "Body") },
229+
)
230+
}
231+
""".trimIndent()
232+
233+
modifierRuleAssertThat(code).hasNoLintViolations()
234+
}
235+
236+
@Test
237+
fun `non content emitter with content emitter not ignored`() {
238+
@Language("kotlin")
239+
val code =
240+
"""
241+
@Composable
242+
fun MyDialog() {
243+
Text(text = "Unicorn")
244+
245+
AlertDialog(
246+
onDismissRequest = { /*TODO*/ },
247+
buttons = { Text(text = "Button") },
248+
text = { Text(text = "Body") },
249+
)
250+
}
251+
""".trimIndent()
252+
253+
modifierRuleAssertThat(code).hasLintViolationsWithoutAutoCorrect(
254+
LintViolation(
255+
line = 2,
256+
col = 5,
257+
detail = ComposeModifierMissing.MissingModifierContentComposable
258+
)
259+
)
260+
}
217261
}

0 commit comments

Comments
 (0)