diff --git a/build-logic/convention/src/main/kotlin/HedvigGradlePlugin.kt b/build-logic/convention/src/main/kotlin/HedvigGradlePlugin.kt index d5e62a119d..773d36609e 100644 --- a/build-logic/convention/src/main/kotlin/HedvigGradlePlugin.kt +++ b/build-logic/convention/src/main/kotlin/HedvigGradlePlugin.kt @@ -38,6 +38,14 @@ private fun Project.configureKtlint(libs: LibrariesForLibs) { reporters = arrayOf(ReporterType.checkstyle.name) } + // Our own rules run on every source set, which is what gives KMP modules the coverage that + // Android Lint cannot reach. + if (name != "hedvig-ktlint") { + dependencies { + add("ktlint", project(":hedvig-ktlint")) + } + } + tasks.withType().configureEach { exclude { it.file.path.contains("generated/") } reports.set( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f3bd52e4e1..0e33cc1aae 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,6 +27,8 @@ kmpNativeCoroutines = "1.0.5" kotlin = "2.4.10" kotlinpoet = "2.3.0" kotlinter = "5.6.0" +# Must match the ktlint that kotlinter bundles, so the custom ruleset links against the same API +ktlintCore = "1.8.0" ksp = "2.3.10" ktor = "3.5.1" license = "0.9.9" @@ -271,6 +273,8 @@ kmpNativeCoroutines-gradlePlugin = { module = "com.rickclephas.kmp.nativecorouti kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } kotlinSerialization-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-serialization", version.ref = "kotlin" } kotlinter-gradlePlugin = { module = "org.jmailen.gradle:kotlinter-gradle", version.ref = "kotlinter" } +ktlint-ruleEngineCore = { module = "com.pinterest.ktlint:ktlint-rule-engine-core", version.ref = "ktlintCore" } +ktlint-cliRulesetCore = { module = "com.pinterest.ktlint:ktlint-cli-ruleset-core", version.ref = "ktlintCore" } ksp-gradlePlugin = { module = "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" } license-gradlePlugin = { module = "com.jaredsburrows.license:com.jaredsburrows.license.gradle.plugin", version.ref = "license" } metro-gradlePlugin = { module = "dev.zacsweers.metro:dev.zacsweers.metro.gradle.plugin", version.ref = "metro" } diff --git a/hedvig-ktlint/build.gradle.kts b/hedvig-ktlint/build.gradle.kts new file mode 100644 index 0000000000..d22714ecab --- /dev/null +++ b/hedvig-ktlint/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + id("hedvig.jvm.library") + id("hedvig.gradle.plugin") +} + +dependencies { + compileOnly(libs.ktlint.ruleEngineCore) + compileOnly(libs.ktlint.cliRulesetCore) +} diff --git a/hedvig-ktlint/src/main/kotlin/com/hedvig/android/ktlint/HedvigRuleSetProvider.kt b/hedvig-ktlint/src/main/kotlin/com/hedvig/android/ktlint/HedvigRuleSetProvider.kt new file mode 100644 index 0000000000..9e599aab53 --- /dev/null +++ b/hedvig-ktlint/src/main/kotlin/com/hedvig/android/ktlint/HedvigRuleSetProvider.kt @@ -0,0 +1,13 @@ +package com.hedvig.android.ktlint + +import com.pinterest.ktlint.cli.ruleset.core.api.RuleSetProviderV3 +import com.pinterest.ktlint.rule.engine.core.api.RuleProvider +import com.pinterest.ktlint.rule.engine.core.api.RuleSetId + +internal const val CUSTOM_RULE_SET_ID = "hedvig" + +class HedvigRuleSetProvider : RuleSetProviderV3(RuleSetId(CUSTOM_RULE_SET_ID)) { + override fun getRuleProviders(): Set = setOf( + RuleProvider { NamespaceImportRule() }, + ) +} diff --git a/hedvig-ktlint/src/main/kotlin/com/hedvig/android/ktlint/NamespaceImportRule.kt b/hedvig-ktlint/src/main/kotlin/com/hedvig/android/ktlint/NamespaceImportRule.kt new file mode 100644 index 0000000000..2eda8d8d7e --- /dev/null +++ b/hedvig-ktlint/src/main/kotlin/com/hedvig/android/ktlint/NamespaceImportRule.kt @@ -0,0 +1,68 @@ +package com.hedvig.android.ktlint + +import com.pinterest.ktlint.rule.engine.core.api.ElementType +import com.pinterest.ktlint.rule.engine.core.api.Rule +import com.pinterest.ktlint.rule.engine.core.api.RuleId +import org.jetbrains.kotlin.com.intellij.lang.ASTNode + +/** + * Reports imports that shorten a qualified reference past the point where the short name still says + * what it is, such as `import hedvig.resources.Res.string` turning `Res.string.FOO` into `string.FOO`. + * + * This is the multiplatform counterpart of the `NamespaceImport` Android Lint check, which cannot run + * on KMP source sets. ktlint has no type resolution, so an owner is recognized by the shape of the + * import path rather than by resolving it, and [CAPITALIZED_PACKAGES] carries the exceptions that + * costs us. + */ +internal class NamespaceImportRule : + Rule( + ruleId = RuleId("$CUSTOM_RULE_SET_ID:namespace-import"), + about = About( + maintainer = "Hedvig", + repositoryUrl = "https://github.com/HedvigInsurance/android", + issueTrackerUrl = "https://github.com/HedvigInsurance/android/issues", + ), + ) { + override fun beforeVisitChildNodes( + node: ASTNode, + autoCorrect: Boolean, + emit: (offset: Int, errorMessage: String, canBeAutoCorrected: Boolean) -> Unit, + ) { + if (node.elementType != ElementType.IMPORT_DIRECTIVE) return + val text = node.text + // An alias is a deliberate act of renaming, and gives the use site a name of its own. + if (text.contains(" as ")) return + val qualifiedName = text.removePrefix("import").trim() + if (qualifiedName.isEmpty() || qualifiedName.endsWith("*")) return + + val importedName = qualifiedName.substringAfterLast('.') + val ownerPath = qualifiedName.substringBeforeLast('.', "") + val ownerName = ownerPath.substringAfterLast('.') + if (importedName.isEmpty() || ownerName.isEmpty()) return + if (!ownerName.first().isUpperCase()) return + // `Duration.Companion.seconds` and friends exist to enable the `5.seconds` receiver idiom. + if (ownerName == "Companion") return + if (CAPITALIZED_PACKAGES.any { qualifiedName.startsWith(it) }) return + + val importsAMember = importedName.first().isLowerCase() + if (!importsAMember && qualifiedName !in DENIED_IMPORTS) return + + emit( + node.startOffset, + "Import $ownerName and write $ownerName.$importedName at the use site. " + + "On its own, $importedName no longer says what it is.", + false, + ) + } + + private companion object { + /** + * Kotlin/Native interop packages are capitalized after the framework they bind, so their + * top-level declarations look identical to members of a class. + */ + val CAPITALIZED_PACKAGES = listOf("platform.") + + /** Imports that read as a type but still leave nothing meaningful at the use site. */ + val DENIED_IMPORTS = setOf("kotlin.time.Clock.System") + } +} diff --git a/hedvig-ktlint/src/main/resources/META-INF/services/com.pinterest.ktlint.cli.ruleset.core.api.RuleSetProviderV3 b/hedvig-ktlint/src/main/resources/META-INF/services/com.pinterest.ktlint.cli.ruleset.core.api.RuleSetProviderV3 new file mode 100644 index 0000000000..61cf4aff39 --- /dev/null +++ b/hedvig-ktlint/src/main/resources/META-INF/services/com.pinterest.ktlint.cli.ruleset.core.api.RuleSetProviderV3 @@ -0,0 +1 @@ +com.hedvig.android.ktlint.HedvigRuleSetProvider diff --git a/hedvig-lint/lint-baseline/lint-baseline-hedvig-ktlint.xml b/hedvig-lint/lint-baseline/lint-baseline-hedvig-ktlint.xml new file mode 100644 index 0000000000..661c123ac6 --- /dev/null +++ b/hedvig-lint/lint-baseline/lint-baseline-hedvig-ktlint.xml @@ -0,0 +1,4 @@ + + + + diff --git a/hedvig-lint/lint.xml b/hedvig-lint/lint.xml index 5152aacc2b..036ff3c1f0 100644 --- a/hedvig-lint/lint.xml +++ b/hedvig-lint/lint.xml @@ -8,6 +8,12 @@ config entry for a check absent from a given module isn't flagged. --> + + + + diff --git a/settings.gradle.kts b/settings.gradle.kts index 0c0abfeb19..cfe8b6f178 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -69,3 +69,4 @@ include("design-showcase-desktop") project(":design-showcase-desktop").projectDir = rootProject.projectDir.resolve("micro-apps").resolve("design-showcase-desktop") include("hedvig-lint") +include("hedvig-ktlint")