Skip to content

Commit

Permalink
Add ComposePreviewNaming rule and support multipreview annotations (#93)
Browse files Browse the repository at this point in the history
Multipreview annotations weren't correctly supported, which caused issues with the `ModifierMissing` rule.
This patch does two things to address that:

- Have the annotations for multipreviews to be properly named (add Preview/s suffix)
- Expand the preview checks to look for `@Preview` / `@WhateverPreview(s)`

Fixes #91
  • Loading branch information
mrmans0n authored Oct 3, 2022
1 parent eb07123 commit 698284e
Show file tree
Hide file tree
Showing 14 changed files with 348 additions and 17 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,3 @@ import org.jetbrains.kotlin.psi.KtAnnotated

val KtAnnotated.isComposable: Boolean
get() = annotationEntries.any { it.calleeExpression?.text == "Composable" }

val KtAnnotated.isPreview: Boolean
get() = annotationEntries.any { it.calleeExpression?.text == "Preview" }

val KtAnnotated.isPreviewParameter: Boolean
get() = annotationEntries.any { it.calleeExpression?.text == "PreviewParameter" }
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Copyright 2022 Twitter, Inc.
// SPDX-License-Identifier: Apache-2.0
package com.twitter.rules.core.util

import org.jetbrains.kotlin.psi.KtAnnotated
import org.jetbrains.kotlin.psi.KtAnnotationEntry

val KtAnnotated.isPreview: Boolean
get() = annotationEntries.any { it.isPreviewAnnotation }

val KtAnnotationEntry.isPreviewAnnotation: Boolean
get() = calleeExpression?.text?.let { PreviewNameRegex.matches(it) } == true

val KtAnnotated.isPreviewParameter: Boolean
get() = annotationEntries.any { it.calleeExpression?.text == "PreviewParameter" }

val PreviewNameRegex by lazy {
Regex(".*Preview[s]*$")
}
4 changes: 3 additions & 1 deletion docs/detekt.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ TwitterCompose:
# You can optionally define a list of CompositionLocals that are allowed here
# allowedCompositionLocals: LocalSomething,LocalSomethingElse
CompositionLocalNaming:
active: true
active: true
ContentEmitterReturningValues:
active: true
# You can optionally add your own composables here
Expand All @@ -48,6 +48,8 @@ TwitterCompose:
active: true
ComposableParamOrder:
active: true
PreviewNaming:
active: true
PreviewPublic:
active: true
RememberMissing:
Expand Down
9 changes: 9 additions & 0 deletions docs/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,15 @@ More information: [Naming CompositionLocals](https://android.googlesource.com/pl

Related rule: [twitter-compose:compositionlocal-naming](https://github.com/twitter/compose-rules/blob/main/rules/common/src/main/kotlin/com/twitter/compose/rules/ComposeCompositionLocalNaming.kt)

### Naming multipreview annotations properly

Multipreview annotations should be named by using `Previews` as suffix (or `Preview` if just one). These annotations have to be explicitly named to make sure that they are clearly identifiable as a `@Preview` alternative on its usages.

More information: [Multipreview annotations](https://developer.android.com/jetpack/compose/tooling#preview-multipreview)

Related rule: [twitter-compose:preview-naming](https://github.com/twitter/compose-rules/blob/main/rules/common/src/main/kotlin/com/twitter/compose/rules/ComposePreviewNaming.kt)


### Naming @Composable functions properly

Composable functions that return `Unit` should start with an uppercase letter. They are considered declarative entities that can be either present or absent in a composition and therefore follow the naming rules for classes.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Copyright 2022 Twitter, Inc.
// SPDX-License-Identifier: Apache-2.0
package com.twitter.compose.rules

import com.twitter.rules.core.ComposeKtVisitor
import com.twitter.rules.core.Emitter
import com.twitter.rules.core.report
import com.twitter.rules.core.util.isPreview
import com.twitter.rules.core.util.isPreviewAnnotation
import org.jetbrains.kotlin.psi.KtClass

class ComposePreviewNaming : ComposeKtVisitor {
override fun visitClass(clazz: KtClass, autoCorrect: Boolean, emitter: Emitter) {
if (!clazz.isAnnotation()) return
if (!clazz.isPreview) return

// We know here that we are in an annotation that either has a @Preview or other preview annotations
val count = clazz.annotationEntries.count { it.isPreviewAnnotation }
val name = clazz.nameAsSafeName.asString()
if (count == 1 && !name.endsWith("Preview")) {
emitter.report(clazz, createMessage(count, "Preview"))
} else if (count > 1 && !name.endsWith("Previews")) {
emitter.report(clazz, createMessage(count, "Previews"))
}
}

companion object {
fun createMessage(count: Int, suggestedSuffix: String): String = """
Preview annotations with $count preview annotations should end with the `$suggestedSuffix` suffix.
See https://twitter.github.io/compose-rules/rules/#naming-multipreview-annotations-properly for more information.
""".trimIndent()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Copyright 2022 Twitter, Inc.
// SPDX-License-Identifier: Apache-2.0
package com.twitter.compose.rules.detekt

import com.twitter.compose.rules.ComposePreviewNaming
import com.twitter.rules.core.ComposeKtVisitor
import com.twitter.rules.core.detekt.TwitterDetektRule
import io.gitlab.arturbosch.detekt.api.Config
import io.gitlab.arturbosch.detekt.api.Debt
import io.gitlab.arturbosch.detekt.api.Issue
import io.gitlab.arturbosch.detekt.api.Severity

class ComposePreviewNamingCheck(config: Config) :
TwitterDetektRule(config),
ComposeKtVisitor by ComposePreviewNaming() {

override val issue: Issue = Issue(
id = "PreviewNaming",
severity = Severity.CodeSmell,
description = "Multipreview annotations should end with the `Previews` suffix",
debt = Debt.FIVE_MINS
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@ class ComposePreviewPublicCheck(config: Config) :
TwitterDetektRule(config),
ComposeKtVisitor by ComposePreviewPublic() {

override val autoCorrect: Boolean = true

override val issue: Issue = Issue(
id = "PreviewPublic",
severity = Severity.CodeSmell,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class TwitterComposeRuleSetProvider : RuleSetProvider {
ComposeMutableParametersCheck(config),
ComposeNamingCheck(config),
ComposeParameterOrderCheck(config),
ComposePreviewNamingCheck(config),
ComposePreviewPublicCheck(config),
ComposeRememberMissingCheck(config),
ComposeViewModelForwardingCheck(config),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// Copyright 2022 Twitter, Inc.
// SPDX-License-Identifier: Apache-2.0
package com.twitter.compose.rules.detekt

import com.twitter.compose.rules.ComposePreviewNaming
import io.gitlab.arturbosch.detekt.api.Config
import io.gitlab.arturbosch.detekt.api.SourceLocation
import io.gitlab.arturbosch.detekt.test.assertThat
import io.gitlab.arturbosch.detekt.test.lint
import org.intellij.lang.annotations.Language
import org.junit.jupiter.api.Test

class ComposePreviewNamingCheckTest {

private val rule = ComposePreviewNamingCheck(Config.empty)

@Test
fun `passes for non-preview annotations`() {
@Language("kotlin")
val code =
"""
annotation class Banana
""".trimIndent()
val errors = rule.lint(code)
assertThat(errors).isEmpty()
}

@Test
fun `passes for preview annotations with the proper names`() {
@Language("kotlin")
val code =
"""
@Preview
annotation class BananaPreview
@BananaPreview
annotation class DoubleBananaPreview
@Preview
@Preview
annotation class ApplePreviews
@Preview
@ApplePreviews
annotation class CombinedApplePreviews
@BananaPreview
@ApplePreviews
annotation class FruitBasketPreviews
""".trimIndent()
val errors = rule.lint(code)
assertThat(errors).isEmpty()
}

@Test
fun `errors when a multipreview annotation is not correctly named for 1 preview`() {
@Language("kotlin")
val code =
"""
@Preview
annotation class Banana
@Preview
annotation class BananaPreviews
@BananaPreview
annotation class WithBananaPreviews
""".trimIndent()
val errors = rule.lint(code)
assertThat(errors).hasSourceLocations(
SourceLocation(2, 18),
SourceLocation(4, 18),
SourceLocation(6, 18)
)
for (error in errors) {
assertThat(error).hasMessage(ComposePreviewNaming.createMessage(1, "Preview"))
}
}

@Test
fun `errors when a multipreview annotation is not correctly named for multi previews`() {
@Language("kotlin")
val code =
"""
@Preview
@Preview
annotation class BananaPreview
@BananaPreview
@BananaPreview
annotation class BananaPreview
""".trimIndent()
val errors = rule.lint(code)
assertThat(errors).hasSourceLocations(
SourceLocation(3, 18),
SourceLocation(6, 18)
)
for (error in errors) {
assertThat(error).hasMessage(ComposePreviewNaming.createMessage(2, "Previews"))
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ class ComposePreviewPublicCheckTest {
@Preview
@Composable
fun MyComposable() { }
@CombinedPreviews
@Composable
fun MyComposable() { }
""".trimIndent()
val errors = rule.lint(code)
assertThat(errors).isEmpty()
Expand All @@ -48,10 +51,16 @@ class ComposePreviewPublicCheckTest {
@Composable
fun MyComposable(@PreviewParameter(User::class) user: User) {
}
@CombinedPreviews
@Composable
fun MyComposable(@PreviewParameter(User::class) user: User) {
}
""".trimIndent()
val errors = rule.lint(code)
assertThat(errors).hasSize(1)
.hasSourceLocations(SourceLocation(3, 5))
assertThat(errors).hasSourceLocations(
SourceLocation(3, 5),
SourceLocation(7, 5)
)
for (error in errors) {
assertThat(error).hasMessage(ComposePreviewPublic.ComposablesPreviewShouldNotBePublic)
}
Expand All @@ -66,7 +75,7 @@ class ComposePreviewPublicCheckTest {
@Composable
private fun MyComposable(@PreviewParameter(User::class) user: User) {
}
@Preview
@CombinedPreviews
@Composable
internal fun MyComposable(@PreviewParameter(User::class) user: User) {
}
Expand All @@ -84,6 +93,10 @@ class ComposePreviewPublicCheckTest {
@Composable
private fun MyComposable(@PreviewParameter(User::class) user: User) {
}
@CombinedPreviews
@Composable
private fun MyComposable(@PreviewParameter(User::class) user: User) {
}
""".trimIndent()
val errors = rule.lint(code)
assertThat(errors).isEmpty()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Copyright 2022 Twitter, Inc.
// SPDX-License-Identifier: Apache-2.0
package com.twitter.compose.rules.ktlint

import com.twitter.compose.rules.ComposePreviewNaming
import com.twitter.rules.core.ComposeKtVisitor
import com.twitter.rules.core.ktlint.TwitterKtlintRule

class ComposePreviewNamingCheck :
TwitterKtlintRule("twitter-compose:preview-naming"),
ComposeKtVisitor by ComposePreviewNaming()
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class TwitterComposeRuleSetProvider :
ComposeMutableParametersCheck(),
ComposeNamingCheck(),
ComposeParameterOrderCheck(),
ComposePreviewNamingCheck(),
ComposePreviewPublicCheck(),
ComposeRememberMissingCheck(),
ComposeViewModelForwardingCheck(),
Expand All @@ -50,6 +51,7 @@ class TwitterComposeRuleSetProvider :
RuleProvider { ComposeMutableParametersCheck() },
RuleProvider { ComposeNamingCheck() },
RuleProvider { ComposeParameterOrderCheck() },
RuleProvider { ComposePreviewNamingCheck() },
RuleProvider { ComposePreviewPublicCheck() },
RuleProvider { ComposeRememberMissingCheck() },
RuleProvider { ComposeViewModelForwardingCheck() },
Expand Down
Loading

0 comments on commit 698284e

Please sign in to comment.