diff --git a/timber-lint/src/main/java/timber/lint/PlantATreeDetector.kt b/timber-lint/src/main/java/timber/lint/PlantATreeDetector.kt new file mode 100644 index 000000000..d20ea7233 --- /dev/null +++ b/timber-lint/src/main/java/timber/lint/PlantATreeDetector.kt @@ -0,0 +1,80 @@ +@file:Suppress("UnstableApiUsage") + +package timber.lint + +import com.android.tools.lint.detector.api.* +import com.intellij.psi.PsiMethod +import org.jetbrains.uast.UCallExpression +import java.util.* + +/** + * A [Detector] which makes sure than anytime Timer APIs are used, there is at-least a single tree + * planted. + */ +class PlantATreeDetector : Detector(), SourceCodeScanner { + companion object { + val ISSUE = Issue.create( + id = "MustPlantATimberTree", + briefDescription = "A Timber tree needs to be planted", + explanation = """ + When using Timber's logging APIs, a `Tree` must be planted on at least a single \ + variant of the app. + """, + androidSpecific = true, + category = Category.CORRECTNESS, + severity = Severity.ERROR, + implementation = Implementation( + PlantATreeDetector::class.java, + EnumSet.of(Scope.JAVA_FILE) + ) + ) + + // Methods on the companion object are marked as @JvmStatic + // Therefore we need to check whether they can be resolved to either Timber or Forest. + private const val TIMBER = "timber.log.Timber" + private const val FOREST = "timber.log.Timber.Forest" + } + + // Do we need to check if a Tree is planted + private var checkForPlantedTrees = false + private var hasPlantedTree = false + private var location: Location? = null + + override fun getApplicableMethodNames() = listOf("v", "d", "i", "w", "e", "wtf", "plant") + + override fun visitMethodCall(context: JavaContext, node: UCallExpression, method: PsiMethod) { + val methodName = method.name + when (context.driver.phase) { + 1 -> { + if (methodName.matches(Regex("(v|d|i|w|e|wtf)")) + && context.evaluator.isMemberInClass(method, FOREST) + ) { + if (!checkForPlantedTrees) { + location = context.getLocation(node) + checkForPlantedTrees = true + // Request a second scan with the same scope + context.driver.requestRepeat(this, null) + } + } + } + else -> { + if (methodName.matches(Regex("plant")) && + (context.evaluator.isMemberInClass(method, TIMBER) || + context.evaluator.isMemberInClass(method, FOREST)) + ) { + hasPlantedTree = true + } + } + } + } + + override fun afterCheckRootProject(context: Context) { + if (checkForPlantedTrees && !hasPlantedTree && context.driver.phase > 1) { + context.report( + issue = ISSUE, + location = location ?: Location.create(context.file), + message = "A `Tree` must be planted for at least a single variant of the application." + ) + } + } +} diff --git a/timber-lint/src/main/java/timber/lint/TimberIssueRegistry.kt b/timber-lint/src/main/java/timber/lint/TimberIssueRegistry.kt index 5bcc3b507..75edc4619 100644 --- a/timber-lint/src/main/java/timber/lint/TimberIssueRegistry.kt +++ b/timber-lint/src/main/java/timber/lint/TimberIssueRegistry.kt @@ -10,7 +10,7 @@ import com.google.auto.service.AutoService @AutoService(value = [IssueRegistry::class]) class TimberIssueRegistry : IssueRegistry() { override val issues: List - get() = WrongTimberUsageDetector.issues + get() = WrongTimberUsageDetector.issues + PlantATreeDetector.ISSUE override val api: Int get() = CURRENT_API @@ -27,4 +27,4 @@ class TimberIssueRegistry : IssueRegistry() { identifier = "com.jakewharton.timber:timber:{version}", feedbackUrl = "https://github.com/JakeWharton/timber/issues", ) -} \ No newline at end of file +} diff --git a/timber-lint/src/test/java/timber/lint/PlantATreeDetectorTest.kt b/timber-lint/src/test/java/timber/lint/PlantATreeDetectorTest.kt new file mode 100644 index 000000000..54058762b --- /dev/null +++ b/timber-lint/src/test/java/timber/lint/PlantATreeDetectorTest.kt @@ -0,0 +1,145 @@ +package timber.lint + +import com.android.tools.lint.checks.infrastructure.LintDetectorTest.java +import com.android.tools.lint.checks.infrastructure.LintDetectorTest.kotlin +import com.android.tools.lint.checks.infrastructure.TestLintTask.lint +import org.junit.Test + +class PlantATreeDetectorTest { + + private val timber = kotlin("timber/log/Timber.kt", """ + package timber.log + class Timber private constructor() { + abstract class Tree { + + } + companion object Forest: Tree() { + @JvmStatic + fun e(message: String?, vararg args: Any?) { + + } + @JvmStatic + fun w(message: String?, vararg args: Any?) { + + } + @JvmStatic + fun i(message: String?, vararg args: Any?) { + + } + @JvmStatic + fun d(message: String?, vararg args: Any?) { + + } + @JvmStatic + fun v(message: String?, vararg args: Any?) { + + } + @JvmStatic + fun plant(tree: Tree) { + + } + } + } + """).indented().within("src") + + @Test + fun testNoTimberLoggingApisAreUsed() { + val application = kotlin("com/example/App.kt", """ + package com.example + + import timber.log.Timber + + class App { + fun onCreate() { + } + } + """).indented().within("src") + + lint() + .files(timber, application) + .issues(PlantATreeDetector.ISSUE) + .run() + .expectClean() + } + + @Test + fun testWhenTimberApisAreUsed() { + val application = kotlin("com/example/App.kt", """ + package com.example + + import timber.log.Timber + + class App { + fun onCreate() { + Timber.d("Log something") + } + } + """).indented().within("src") + + lint() + .files(timber, application) + .issues(PlantATreeDetector.ISSUE) + .run() + .expect(""" + src/com/example/App.kt:7: Error: A Tree must be planted for at least a single variant of the application. [MustPlantATimberTree] + Timber.d("Log something") + ~~~~~~~~~~~~~~~~~~~~~~~~~ + 1 errors, 0 warnings + """.trimIndent()) + } + + @Test + fun testWhenTimberApisAreUsedAndTreeIsPlanted() { + val application = kotlin("com/example/App.kt", """ + package com.example + + import timber.log.Timber + + class App { + fun onCreate() { + plantTree() + Timber.d("Log something") + } + + private fun plantTree() { + val tree = Timber.Tree() + Timber.plant(tree) + } + } + """).indented().within("src") + + lint() + .files(timber, application) + .issues(PlantATreeDetector.ISSUE) + .run() + .expectClean() + } + + @Test + fun testWhenTimberApisAreUsedAndTreeIsPlanted_java() { + val application = java("com/example/App.java", """ + package com.example; + + import timber.log.Timber; + + class App { + void onCreate() { + plantTree(); + Timber.d("Log something"); + } + + void plantTree() { + val tree = Timber.Tree(); + Timber.plant(tree); + } + } + """).indented().within("src") + + lint() + .files(timber, application) + .issues(PlantATreeDetector.ISSUE) + .run() + .expectClean() + } + +}