|
| 1 | +package com.hedvig.android.ktlint |
| 2 | + |
| 3 | +import com.pinterest.ktlint.rule.engine.core.api.ElementType |
| 4 | +import com.pinterest.ktlint.rule.engine.core.api.Rule |
| 5 | +import com.pinterest.ktlint.rule.engine.core.api.RuleId |
| 6 | +import org.jetbrains.kotlin.com.intellij.lang.ASTNode |
| 7 | + |
| 8 | +/** |
| 9 | + * Reports imports that shorten a qualified reference past the point where the short name still says |
| 10 | + * what it is, such as `import hedvig.resources.Res.string` turning `Res.string.FOO` into `string.FOO`. |
| 11 | + * |
| 12 | + * This runs through kotlinter on every source set, `commonMain` and `iosMain` included, which is why |
| 13 | + * the rule lives here rather than as an Android Lint check: AGP's KMP library plugin registers no |
| 14 | + * task that runs Android Lint (https://issuetracker.google.com/issues/246751841), so a lint check |
| 15 | + * cannot see a KMP module at all, and the design system is one. |
| 16 | + * |
| 17 | + * The cost of that reach is precision. ktlint has no type resolution, so an owner is recognized by |
| 18 | + * the shape of the import path rather than by resolving it, and [CAPITALIZED_PACKAGES] carries the |
| 19 | + * exceptions. Trading the heuristic for a resolved owner across the whole codebase needs an analyzer |
| 20 | + * that both resolves types and runs on every source set: Android Lint, once the issue above is |
| 21 | + * fixed, or detekt with type resolution. |
| 22 | + */ |
| 23 | +internal class NamespaceImportRule : |
| 24 | + Rule( |
| 25 | + ruleId = RuleId("$CUSTOM_RULE_SET_ID:namespace-import"), |
| 26 | + about = About( |
| 27 | + maintainer = "Hedvig", |
| 28 | + repositoryUrl = "https://github.com/HedvigInsurance/android", |
| 29 | + issueTrackerUrl = "https://github.com/HedvigInsurance/android/issues", |
| 30 | + ), |
| 31 | + ) { |
| 32 | + override fun beforeVisitChildNodes( |
| 33 | + node: ASTNode, |
| 34 | + autoCorrect: Boolean, |
| 35 | + emit: (offset: Int, errorMessage: String, canBeAutoCorrected: Boolean) -> Unit, |
| 36 | + ) { |
| 37 | + if (node.elementType != ElementType.IMPORT_DIRECTIVE) return |
| 38 | + val text = node.text |
| 39 | + // An alias is a deliberate act of renaming, and gives the use site a name of its own. |
| 40 | + if (text.contains(" as ")) return |
| 41 | + val qualifiedName = text.removePrefix("import").trim() |
| 42 | + if (qualifiedName.isEmpty() || qualifiedName.endsWith("*")) return |
| 43 | + |
| 44 | + val importedName = qualifiedName.substringAfterLast('.') |
| 45 | + val ownerPath = qualifiedName.substringBeforeLast('.', "") |
| 46 | + val ownerName = ownerPath.substringAfterLast('.') |
| 47 | + if (importedName.isEmpty() || ownerName.isEmpty()) return |
| 48 | + if (!ownerName.first().isUpperCase()) return |
| 49 | + // `Duration.Companion.seconds` and friends exist to enable the `5.seconds` receiver idiom. |
| 50 | + if (ownerName == "Companion") return |
| 51 | + if (CAPITALIZED_PACKAGES.any { qualifiedName.startsWith(it) }) return |
| 52 | + |
| 53 | + val importsAMember = importedName.first().isLowerCase() |
| 54 | + if (!importsAMember && qualifiedName !in DENIED_IMPORTS) return |
| 55 | + |
| 56 | + emit( |
| 57 | + node.startOffset, |
| 58 | + "Import $ownerName and write $ownerName.$importedName at the use site. " + |
| 59 | + "On its own, $importedName no longer says what it is.", |
| 60 | + false, |
| 61 | + ) |
| 62 | + } |
| 63 | + |
| 64 | + private companion object { |
| 65 | + /** |
| 66 | + * Kotlin/Native interop packages are capitalized after the framework they bind, so their |
| 67 | + * top-level declarations look identical to members of a class. |
| 68 | + */ |
| 69 | + val CAPITALIZED_PACKAGES = listOf("platform.") |
| 70 | + |
| 71 | + /** Imports that read as a type but still leave nothing meaningful at the use site. */ |
| 72 | + val DENIED_IMPORTS = setOf("kotlin.time.Clock.System") |
| 73 | + } |
| 74 | +} |
0 commit comments