Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<org.jmailen.gradle.kotlinter.tasks.LintTask>().configureEach {
exclude { it.file.path.contains("generated/") }
reports.set(
Expand Down
4 changes: 4 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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" }
Expand Down
9 changes: 9 additions & 0 deletions hedvig-ktlint/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
plugins {
id("hedvig.jvm.library")
id("hedvig.gradle.plugin")
}

dependencies {
compileOnly(libs.ktlint.ruleEngineCore)
compileOnly(libs.ktlint.cliRulesetCore)
}
Original file line number Diff line number Diff line change
@@ -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<RuleProvider> = setOf(
RuleProvider { NamespaceImportRule() },
)
}
Original file line number Diff line number Diff line change
@@ -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")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
com.hedvig.android.ktlint.HedvigRuleSetProvider
4 changes: 4 additions & 0 deletions hedvig-lint/lint-baseline/lint-baseline-hedvig-ktlint.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<issues format="6" by="lint 9.3.1" type="baseline" client="gradle" dependencies="false" name="AGP (9.3.1)" variant="all" version="9.3.1">

</issues>
6 changes: 6 additions & 0 deletions hedvig-lint/lint.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@
config entry for a check absent from a given module isn't flagged. -->
<issue id="UnknownIssueId" severity="ignore" />

<!-- Renovate (renovate.json) owns dependency freshness, so lint's version-staleness notices are
duplicate signal. They also resolve against the shared root gradle/libs.versions.toml rather
than the module being linted, so they report identically no matter which module runs lint. -->
<issue id="GradleDependency" severity="ignore" />
<issue id="NewerVersionAvailable" severity="ignore" />

<issue id="ButtonCase" severity="ignore" />
<issue id="TypographyDashes" severity="ignore" />
<issue id="TypographyEllipsis" severity="ignore" />
Expand Down
1 change: 1 addition & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Loading