diff --git a/build.gradle.kts b/build.gradle.kts index f383d7a4b0..3c4b9b8802 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -35,6 +35,8 @@ configurations.configureEach { dependencies { detektPlugins(libs.detekt.detekt.formatting) detektPlugins(libs.detekt.detekt.rules) + // Bitwarden custom rules. Not transitive, so Detekt keeps its own Kotlin standard library. + detektPlugins(project(":detekt-rules")) { isTransitive = false } kover(project(":annotation")) kover(project(":app")) @@ -59,6 +61,7 @@ detekt { "core/src", "cxf/src", "data/src", + "detekt-rules/src", "network/src", "testharness/src", "ui/src", diff --git a/detekt-config.yml b/detekt-config.yml index fa57c761e0..30e131ffe9 100644 --- a/detekt-config.yml +++ b/detekt-config.yml @@ -667,3 +667,8 @@ style: active: true excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] excludeImports: [ 'java.util.*', 'kotlinx.android.synthetic.*' ] + +bitwarden: + active: true + NamedFontWeight: + active: true diff --git a/detekt-rules/.gitignore b/detekt-rules/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/detekt-rules/.gitignore @@ -0,0 +1 @@ +/build diff --git a/detekt-rules/build.gradle.kts b/detekt-rules/build.gradle.kts new file mode 100644 index 0000000000..288fe3a2f7 --- /dev/null +++ b/detekt-rules/build.gradle.kts @@ -0,0 +1,27 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + // The Kotlin Gradle plugin is already on the root build classpath, so no version is needed. + id("org.jetbrains.kotlin.jvm") +} + +java { + sourceCompatibility = JavaVersion.toVersion(libs.versions.jvmTarget.get()) + targetCompatibility = JavaVersion.toVersion(libs.versions.jvmTarget.get()) +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.fromTarget(libs.versions.jvmTarget.get())) + } +} + +dependencies { + compileOnly(libs.detekt.detekt.api) + + testImplementation(libs.detekt.detekt.api) + testImplementation(libs.detekt.detekt.test) + testImplementation(platform(libs.junit.bom)) + testImplementation(libs.junit.jupiter) + testRuntimeOnly(libs.junit.platform.launcher) +} diff --git a/detekt-rules/src/main/kotlin/com/bitwarden/detekt/BitwardenRuleSetProvider.kt b/detekt-rules/src/main/kotlin/com/bitwarden/detekt/BitwardenRuleSetProvider.kt new file mode 100644 index 0000000000..820ddae338 --- /dev/null +++ b/detekt-rules/src/main/kotlin/com/bitwarden/detekt/BitwardenRuleSetProvider.kt @@ -0,0 +1,20 @@ +package com.bitwarden.detekt + +import io.gitlab.arturbosch.detekt.api.Config +import io.gitlab.arturbosch.detekt.api.RuleSet +import io.gitlab.arturbosch.detekt.api.RuleSetProvider + +/** + * Provides Bitwarden's custom Detekt rules. + */ +class BitwardenRuleSetProvider : RuleSetProvider { + override val ruleSetId: String = "bitwarden" + + override fun instance(config: Config): RuleSet = + RuleSet( + id = ruleSetId, + rules = listOf( + NamedFontWeight(config), + ), + ) +} diff --git a/detekt-rules/src/main/kotlin/com/bitwarden/detekt/NamedFontWeight.kt b/detekt-rules/src/main/kotlin/com/bitwarden/detekt/NamedFontWeight.kt new file mode 100644 index 0000000000..2a7b093a85 --- /dev/null +++ b/detekt-rules/src/main/kotlin/com/bitwarden/detekt/NamedFontWeight.kt @@ -0,0 +1,75 @@ +package com.bitwarden.detekt + +import io.gitlab.arturbosch.detekt.api.CodeSmell +import io.gitlab.arturbosch.detekt.api.Config +import io.gitlab.arturbosch.detekt.api.Debt +import io.gitlab.arturbosch.detekt.api.Entity +import io.gitlab.arturbosch.detekt.api.Issue +import io.gitlab.arturbosch.detekt.api.Rule +import io.gitlab.arturbosch.detekt.api.Severity +import org.jetbrains.kotlin.psi.KtCallExpression +import org.jetbrains.kotlin.psi.KtConstantExpression +import org.jetbrains.kotlin.psi.KtDotQualifiedExpression +import org.jetbrains.kotlin.psi.KtExpression + +/** + * Reports numeric `FontWeight` values, such as `FontWeight.W600` or `FontWeight(600)`, and + * suggests the named constant used in the Figma designs, such as `FontWeight.SemiBold`. + */ +class NamedFontWeight(config: Config) : Rule(config) { + + override val issue: Issue = Issue( + id = javaClass.simpleName, + severity = Severity.Style, + description = "Use named FontWeight constants to match the Figma designs.", + debt = Debt.FIVE_MINS, + ) + + override fun visitDotQualifiedExpression(expression: KtDotQualifiedExpression) { + super.visitDotQualifiedExpression(expression) + if (!expression.receiverExpression.text.isFontWeight()) return + val selector = expression.selectorExpression?.text ?: return + if (!selector.startsWith(prefix = "W")) return + val weight = selector.removePrefix(prefix = "W").toIntOrNull() ?: return + reportIfNamed(expression = expression, current = "FontWeight.$selector", weight = weight) + } + + override fun visitCallExpression(expression: KtCallExpression) { + super.visitCallExpression(expression) + if (expression.calleeExpression?.text?.isFontWeight() != true) return + val argument = expression.valueArguments.singleOrNull()?.getArgumentExpression() + val weight = (argument as? KtConstantExpression)?.text?.toIntOrNull() ?: return + reportIfNamed(expression = expression, current = "FontWeight($weight)", weight = weight) + } + + private fun reportIfNamed(expression: KtExpression, current: String, weight: Int) { + val name = NAMED_WEIGHTS[weight] ?: return + report( + CodeSmell( + issue = issue, + entity = Entity.from(expression), + message = "Use FontWeight.$name instead of $current.", + ), + ) + } + + private fun String.isFontWeight(): Boolean = + this == FONT_WEIGHT || endsWith(suffix = ".$FONT_WEIGHT") + + private companion object { + const val FONT_WEIGHT: String = "FontWeight" + + @Suppress("MagicNumber") + val NAMED_WEIGHTS: Map = mapOf( + 100 to "Thin", + 200 to "ExtraLight", + 300 to "Light", + 400 to "Normal", + 500 to "Medium", + 600 to "SemiBold", + 700 to "Bold", + 800 to "ExtraBold", + 900 to "Black", + ) + } +} diff --git a/detekt-rules/src/main/resources/META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProvider b/detekt-rules/src/main/resources/META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProvider new file mode 100644 index 0000000000..964d3022c3 --- /dev/null +++ b/detekt-rules/src/main/resources/META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProvider @@ -0,0 +1 @@ +com.bitwarden.detekt.BitwardenRuleSetProvider diff --git a/detekt-rules/src/main/resources/config/config.yml b/detekt-rules/src/main/resources/config/config.yml new file mode 100644 index 0000000000..97b90f9dd7 --- /dev/null +++ b/detekt-rules/src/main/resources/config/config.yml @@ -0,0 +1,4 @@ +bitwarden: + active: true + NamedFontWeight: + active: true diff --git a/detekt-rules/src/test/kotlin/com/bitwarden/detekt/NamedFontWeightTest.kt b/detekt-rules/src/test/kotlin/com/bitwarden/detekt/NamedFontWeightTest.kt new file mode 100644 index 0000000000..83e57f71cc --- /dev/null +++ b/detekt-rules/src/test/kotlin/com/bitwarden/detekt/NamedFontWeightTest.kt @@ -0,0 +1,66 @@ +package com.bitwarden.detekt + +import io.gitlab.arturbosch.detekt.api.Config +import io.gitlab.arturbosch.detekt.test.lint +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class NamedFontWeightTest { + + private val rule = NamedFontWeight(Config.empty) + + @Test + fun `numeric constant should be reported with the named replacement`() { + val findings = rule.lint("val weight = FontWeight.W600") + + assertEquals(1, findings.size) + assertEquals( + "Use FontWeight.SemiBold instead of FontWeight.W600.", + findings.single().message, + ) + } + + @Test + fun `fully qualified numeric constant should be reported`() { + val findings = rule.lint("val weight = androidx.compose.ui.text.font.FontWeight.W400") + + assertEquals(1, findings.size) + assertEquals( + "Use FontWeight.Normal instead of FontWeight.W400.", + findings.single().message, + ) + } + + @Test + fun `numeric constructor should be reported with the named replacement`() { + val findings = rule.lint("val weight = FontWeight(700)") + + assertEquals(1, findings.size) + assertEquals("Use FontWeight.Bold instead of FontWeight(700).", findings.single().message) + } + + @Test + fun `named constant should not be reported`() { + assertEquals(0, rule.lint("val weight = FontWeight.SemiBold").size) + } + + @Test + fun `weight without a named constant should not be reported`() { + assertEquals(0, rule.lint("val weight = FontWeight(650)").size) + } + + @Test + fun `weight property should not be reported`() { + assertEquals(0, rule.lint("val value = FontWeight.Bold.weight").size) + } + + @Test + fun `numeric constants in a text style should each be reported`() { + val code = """ + val style = TextStyle(fontWeight = FontWeight.W500) + val family = FontFamily(Font(1, weight = FontWeight.W900)) + """.trimIndent() + + assertEquals(2, rule.lint(code).size) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b3e2ecb2ac..fb45b91123 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -101,8 +101,10 @@ coil-coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } coil-coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil-coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" } coil-coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } +detekt-detekt-api = { module = "io.gitlab.arturbosch.detekt:detekt-api", version.ref = "detekt" } detekt-detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" } detekt-detekt-rules = { module = "io.gitlab.arturbosch.detekt:detekt-rules-libraries", version.ref = "detekt" } +detekt-detekt-test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version.ref = "detekt" } google-billing = { module = "com.android.billingclient:billing", version.ref = "googleBilling" } google-firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" } google-firebase-cloud-messaging = { module = "com.google.firebase:firebase-messaging" } diff --git a/settings.gradle.kts b/settings.gradle.kts index f2c3f2019b..cfce5a3aa1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -48,6 +48,7 @@ include( ":core", ":cxf", ":data", + ":detekt-rules", ":network", ":testharness", ":ui",