diff --git a/docs/rules.md b/docs/rules.md index d6ee8ee2..f228bc21 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -223,6 +223,11 @@ interface AppComponent @Singleton MyOtherClass @Inject constructor() ``` +### Classes that use `@AssistedInject` cannot be scoped + +[//]: # (TODO) + + ## Anvil Rules ### Prefer using `@ContributesBinding` over `@Binds` diff --git a/lint/dagger/src/main/java/dev/whosnickdoglio/dagger/DaggerRulesIssueRegistry.kt b/lint/dagger/src/main/java/dev/whosnickdoglio/dagger/DaggerRulesIssueRegistry.kt index ee5494a3..09b3ba4d 100644 --- a/lint/dagger/src/main/java/dev/whosnickdoglio/dagger/DaggerRulesIssueRegistry.kt +++ b/lint/dagger/src/main/java/dev/whosnickdoglio/dagger/DaggerRulesIssueRegistry.kt @@ -14,6 +14,7 @@ import dev.whosnickdoglio.dagger.detectors.ConstructorInjectionOverFieldInjectio import dev.whosnickdoglio.dagger.detectors.CorrectBindsUsageDetector import dev.whosnickdoglio.dagger.detectors.MissingModuleAnnotationDetector import dev.whosnickdoglio.dagger.detectors.MultipleScopesDetector +import dev.whosnickdoglio.dagger.detectors.ScopedAssistedInjectedDetector import dev.whosnickdoglio.dagger.detectors.ScopedWithoutInjectAnnotationDetector import dev.whosnickdoglio.dagger.detectors.StaticProvidesDetector @@ -28,6 +29,7 @@ public class DaggerRulesIssueRegistry : IssueRegistry() { MissingModuleAnnotationDetector.ISSUE, MultipleScopesDetector.ISSUE, StaticProvidesDetector.ISSUE, + ScopedAssistedInjectedDetector.ISSUE, ScopedWithoutInjectAnnotationDetector.ISSUE, ) diff --git a/lint/dagger/src/main/java/dev/whosnickdoglio/dagger/detectors/ScopedAssistedInjectedDetector.kt b/lint/dagger/src/main/java/dev/whosnickdoglio/dagger/detectors/ScopedAssistedInjectedDetector.kt new file mode 100644 index 00000000..f0d4af79 --- /dev/null +++ b/lint/dagger/src/main/java/dev/whosnickdoglio/dagger/detectors/ScopedAssistedInjectedDetector.kt @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2023 Nicholas Doglio + * SPDX-License-Identifier: MIT + */ +package dev.whosnickdoglio.dagger.detectors + +import com.android.tools.lint.client.api.UElementHandler +import com.android.tools.lint.detector.api.Category +import com.android.tools.lint.detector.api.Detector +import com.android.tools.lint.detector.api.Implementation +import com.android.tools.lint.detector.api.Issue +import com.android.tools.lint.detector.api.JavaContext +import com.android.tools.lint.detector.api.Scope +import com.android.tools.lint.detector.api.Severity +import com.android.tools.lint.detector.api.SourceCodeScanner +import com.android.tools.lint.detector.api.TextFormat +import dev.whosnickdoglio.lint.shared.ASSISTED_INJECT +import dev.whosnickdoglio.lint.shared.SCOPE +import org.jetbrains.uast.UAnnotated +import org.jetbrains.uast.UClass +import org.jetbrains.uast.UElement +import org.jetbrains.uast.resolveToUElement + +/** + * A Lint rule that warns if a class is annotated with any scope annotation but does not have a + * `@Inject` annotation on any constructor that it will not be added to the Dagger graph. + */ +internal class ScopedAssistedInjectedDetector : Detector(), SourceCodeScanner { + + override fun getApplicableUastTypes(): List> = listOf(UClass::class.java) + + override fun createUastHandler(context: JavaContext): UElementHandler = + object : UElementHandler() { + override fun visitClass(node: UClass) { + val sourceScopeAnnotations = + node.uAnnotations + .map { annotation -> annotation.resolveToUElement() } + .filterIsInstance() + .filter { annotated -> + annotated.uAnnotations.any { annotation -> + annotation.qualifiedName == SCOPE + } + } + .filterIsInstance() + + val scopeAnnotationsOnCurrentClass = + node.uAnnotations.filter { annotation -> + sourceScopeAnnotations.any { scope -> + scope.qualifiedName == annotation.qualifiedName + } + } + + val usesAssistedInjection = + node.constructors.any { constructor -> + constructor.hasAnnotation(ASSISTED_INJECT) + } + + if (scopeAnnotationsOnCurrentClass.isNotEmpty() && usesAssistedInjection) { + scopeAnnotationsOnCurrentClass.forEach { scopeAnnotation -> + context.report( + issue = ISSUE, + location = context.getLocation(scopeAnnotation), + message = ISSUE.getExplanation(TextFormat.RAW), + quickfixData = + fix() + .name("Remove scope annotation") + .replace() + .pattern( + "(?i)(.*${scopeAnnotation.qualifiedName?.substringAfterLast(".")})", + ) + .reformat(true) + .with("") + .build(), + ) + } + } + } + } + + companion object { + private val implementation = + Implementation(ScopedAssistedInjectedDetector::class.java, Scope.JAVA_FILE_SCOPE) + + internal val ISSUE = + Issue.create( + id = "ScopedAssistedInject", + briefDescription = "Classes using assisted inject cannot be scoped", + explanation = "Classes using assisted inject cannot be scoped", + category = Category.CORRECTNESS, + priority = 5, + severity = Severity.ERROR, + implementation = implementation, + ) + } +} diff --git a/lint/dagger/src/test/java/dev/whosnickdoglio/dagger/detectors/ScopedAssistedInjectedDetectorTest.kt b/lint/dagger/src/test/java/dev/whosnickdoglio/dagger/detectors/ScopedAssistedInjectedDetectorTest.kt new file mode 100644 index 00000000..042fa53a --- /dev/null +++ b/lint/dagger/src/test/java/dev/whosnickdoglio/dagger/detectors/ScopedAssistedInjectedDetectorTest.kt @@ -0,0 +1,222 @@ +/* + * Copyright (C) 2023 Nicholas Doglio + * SPDX-License-Identifier: MIT + */ +package dev.whosnickdoglio.dagger.detectors + +import com.android.tools.lint.checks.infrastructure.TestFiles +import com.android.tools.lint.checks.infrastructure.TestLintTask +import dev.whosnickdoglio.stubs.daggerAnnotations +import dev.whosnickdoglio.stubs.daggerAssistedAnnotations +import dev.whosnickdoglio.stubs.javaxAnnotations +import org.junit.Test + +class ScopedAssistedInjectedDetectorTest { + + private val myScope = + TestFiles.kotlin( + """ + import javax.inject.Scope + @Scope annotation class MyScope + """ + .trimIndent(), + ) + + @Test + fun `scoped kotlin class using @AssistedInject triggers error`() { + TestLintTask.lint() + .files( + daggerAnnotations, + daggerAssistedAnnotations, + javaxAnnotations, + myScope, + TestFiles.kotlin( + """ + import dagger.assisted.AssistedInject + import dagger.assisted.Assisted + + @MyScope class MyAssistedClass @AssistedInject constructor( + private val myInt: Int, + @Assisted private val something: String + ) + """, + ) + .indented(), + ) + .issues(ScopedAssistedInjectedDetector.ISSUE) + .run() + .expect( + """ + src/MyAssistedClass.kt:4: Error: Classes using assisted inject cannot be scoped [ScopedAssistedInject] + @MyScope class MyAssistedClass @AssistedInject constructor( + ~~~~~~~~ + 1 errors, 0 warnings + """ + .trimIndent(), + ) + .expectErrorCount(1) + .expectFixDiffs( + """ + Fix for src/MyAssistedClass.kt line 4: Remove scope annotation: + @@ -4 +4 + - @MyScope class MyAssistedClass @AssistedInject constructor( + + class MyAssistedClass @AssistedInject constructor( + """ + .trimIndent(), + ) + } + + @Test + fun `scoped java class using @AssistedInject triggers error`() { + TestLintTask.lint() + .files( + daggerAnnotations, + daggerAssistedAnnotations, + javaxAnnotations, + myScope, + TestFiles.java( + """ + import dagger.assisted.AssistedInject; + import dagger.assisted.Assisted; + + @MyScope class MyAssistedClass { + + @AssistedInject MyAssistedClass( + String something, + @Assisted Boolean somethingElse + ) {} + } + """, + ) + .indented(), + ) + .issues(ScopedAssistedInjectedDetector.ISSUE) + .run() + .expect( + """ + src/MyAssistedClass.java:4: Error: Classes using assisted inject cannot be scoped [ScopedAssistedInject] + @MyScope class MyAssistedClass { + ~~~~~~~~ + 1 errors, 0 warnings + """ + .trimIndent(), + ) + .expectErrorCount(1) + .expectFixDiffs( + """ + Fix for src/MyAssistedClass.java line 4: Remove scope annotation: + @@ -4 +4 + - @MyScope class MyAssistedClass { + + class MyAssistedClass { + """ + .trimIndent(), + ) + } + + @Test + fun `kotlin class without scope using @AssistedInject does not triggers error`() { + TestLintTask.lint() + .files( + daggerAnnotations, + daggerAssistedAnnotations, + javaxAnnotations, + TestFiles.kotlin( + """ + import dagger.assisted.AssistedInject + import dagger.assisted.Assisted + + class MyAssistedClass @AssistedInject constructor( + private val myInt: Int, + @Assisted private val something: String + ) + """, + ) + .indented(), + ) + .issues(ScopedAssistedInjectedDetector.ISSUE) + .run() + .expectClean() + .expectErrorCount(0) + } + + @Test + fun `java class without scope using @AssistedInject does not triggers error`() { + TestLintTask.lint() + .files( + daggerAnnotations, + daggerAssistedAnnotations, + javaxAnnotations, + TestFiles.java( + """ + import dagger.assisted.AssistedInject; + import dagger.assisted.Assisted; + + class MyAssistedClass { + + @AssistedInject MyAssistedClass( + String something, + @Assisted Boolean somethingElse + ) {} + } + """, + ) + .indented(), + ) + .issues(ScopedAssistedInjectedDetector.ISSUE) + .run() + .expectClean() + .expectErrorCount(0) + } + + @Test + fun `scoped kotlin class not using @AssistedInject does not triggers error`() { + TestLintTask.lint() + .files( + daggerAnnotations, + daggerAssistedAnnotations, + javaxAnnotations, + myScope, + TestFiles.kotlin( + """ + import javax.inject.Inject + + @MyScope class MyAssistedClass @Inject constructor(something: String) + """, + ) + .indented(), + ) + .issues(ScopedAssistedInjectedDetector.ISSUE) + .run() + .expectClean() + .expectErrorCount(0) + } + + @Test + fun `scoped java class not using @AssistedInject does not triggers error`() { + TestLintTask.lint() + .files( + daggerAnnotations, + daggerAssistedAnnotations, + javaxAnnotations, + myScope, + TestFiles.java( + """ + import javax.inject.Inject; + + @MyScope class MyAssistedClass { + + @Inject MyAssistedClass( + String something, + Boolean somethingElse + ) {} + } + """, + ) + .indented(), + ) + .issues(ScopedAssistedInjectedDetector.ISSUE) + .run() + .expectClean() + .expectErrorCount(0) + } +}