androidkotlinlintandroid-lint

Collect specific classes before running lint detector


I want to write a lint check to ensure that @ContributesAndroidInjector was added to all fragments that need it.

Is there a way to gather all classes that are annotated with @Module before visitClass(node: UClass) is called?
Now I have manually added all modules in a list (see code example), but that is not the right solution for me as I would constantly need to update my detector when new modules are added.

Detector:

class MissingContributorDetector : Detector(), Detector.UastScanner {
    override fun getApplicableUastTypes(): List<Class<out UElement>> {
        return listOf(UClass::class.java)
    }

    override fun createUastHandler(context: JavaContext) = Visitor(context)

    class Visitor(private val context: JavaContext) : UElementHandler() {

        private val returnTypes: List<String>

        init {
            returnTypes = MODULES.mapNotNull { context.evaluator.findClass(it) }
                    .flatMap { it.methods.toList() }
                    .filter { it.hasAnnotation(DAGGER_MODULE_ANNOTATION) }
                    .mapNotNull { it.returnType }
                    .mapNotNull { PsiUtil.resolveClassInType(it)?.qualifiedName }
        }

        override fun visitClass(node: UClass) {
            // logic to determine if there is an issue
        }
    }

    companion object {
       private val MODULES = listOf(
           "com.dagger.module.ModuleOne",
           "com.dagger.module.ModuleTwo",
           "com.dagger.module.ModuleThree",
       )
    }
}

Module:

@Module
abstract class ModuleOne {

    @ContributesAndroidInjector
    abstract fun contributesFragment(): HomeFragment
}

Solution

  • Is there a way to gather all classes that are annotated with @Module before visitClass(node: UClass) is called?

    The detector can be written to perform two passes. The first pass will collect all the class in a data structure which will be available to the detector during the second pass. In this scheme, visitClass() will be called for each class during the first and second passes.

    MissingContributorDetector.kt

    /*
        Process this lint check in two passes. The fist pass collects all the classes that have
        the @Module annotation. The second pass does the actual check but has a the class list
        produced in the first pass at its disposal.
     */
    class MissingContributorDetector : Detector(), Detector.UastScanner {
        private val mModuleClasses: MutableList<UClass> = ArrayList()
    
        override fun getApplicableUastTypes(): List<Class<out UElement>> {
            return listOf(UClass::class.java)
        }
    
        override fun createUastHandler(context: JavaContext) = Visitor(context)
    
        // Cues up the second phase for the actual lint check.
        override fun afterCheckEachProject(context: Context) {
            super.afterCheckEachProject(context)
            if (context.phase == 1) { // Rescan classes
                context.requestRepeat(this, MissingContributorIssue.implementation.scope)
            }
        }
    
        inner class Visitor(private val context: JavaContext) : UElementHandler() {
            // Search for classes that are annotated with @Module
            override fun visitClass(node: UClass) {
                if (context.phase == 1) { // Just collect class names
                    if (hasAnnotation(node.annotations, DAGGER_MODULE_ANNOTATION_QUALIFIED_NAME)) {
                        // Build the class list that will be used during the second pass.
                        mModuleClasses.add(node)
                    }
                } else { // phase 2
                    // Do whatever processing is necessary. Here we just check for
                    // @ContributesAndroidInjector on each method in a class annotated with @Module.
                    // The mModuleClasses structure is fully populated from the first pass.
                    if (mModuleClasses.contains(node)) {
                        node.methods.forEach { checkMethodForContributesAndroidInjector(it) }
                    }
                }
            }
    
            // Check for @ContributesAndroidInjector on non-constructor methods
            private fun checkMethodForContributesAndroidInjector(node: UMethod) {
                if (node.isConstructor ||
                        !isFragmentReturnType(node) ||
                        hasAnnotation(node.annotations, DAGGER_CONTRIBUTESANDROIDINJECTOR_QUALIFIED_NAME)) {
                    return
                }
                context.report(
                        MissingContributorIssue,
                        node,
                        context.getNameLocation(node),
                        MissingContributorIssue.getExplanation(TextFormat.TEXT)
                )
            }
    
            private fun isFragmentReturnType(node: UMethod): Boolean {
                val returnTypeRef = node.returnTypeReference
                return returnTypeRef?.getQualifiedName() == HOME_FRAGMENT
            }
    
            private fun hasAnnotation(annotations: List<UAnnotation>, toCheck: String): Boolean {
                return annotations.any { it.qualifiedName == toCheck }
            }
    
        }
    
        companion object {
            const val DAGGER_MODULE_ANNOTATION_QUALIFIED_NAME = "dagger.Module"
            const val DAGGER_CONTRIBUTESANDROIDINJECTOR_QUALIFIED_NAME = "dagger.android.ContributesAndroidInjector"
            const val HOME_FRAGMENT = "com.dagger.module.HomeFragment"
    
            val MissingContributorIssue: Issue = Issue.create(
                    id = "MissingContributesAndroidInjector",
                    briefDescription = "Must specify @ContributesAndroidInjector",
                    implementation = Implementation(
                            MissingContributorDetector::class.java,
                            Scope.JAVA_FILE_SCOPE),
                    explanation = "Method must be annotated with @ContributesAndroidInjector if enclosing class is annotated with @Module.",
                    category = Category.CORRECTNESS,
                    priority = 1,
                    severity = Severity.FATAL
            )
        }
    }
    


    Old answer with just one pass

    The detector can be written to look at each class and to select only those classes that are annotated with @Module. Once a class is selected, each method that returns a HomeFragment can be checked for the @ContributesAndroidInjector annotation. Classes can then be added and scanned without updating a list of modules.

    MissingContributorDetector.kt

    class MissingContributorDetector : Detector(), Detector.UastScanner {
    
        override fun getApplicableUastTypes(): List<Class<out UElement>> {
            return listOf(UClass::class.java)
        }
    
        override fun createUastHandler(context: JavaContext) = Visitor(context)
    
        class Visitor(private val context: JavaContext) : UElementHandler() {
    
            // Search for classes that are annotated with @Module
            override fun visitClass(node: UClass) {
                if (hasAnnotation(node.annotations, DAGGER_MODULE_ANNOTATION_QUALIFIED_NAME)) {
                    node.methods.forEach { checkMethodForContributesAndroidInjector(it) }
                }
            }
    
            // Check for @ContributesAndroidInjector on non-constructor methods
            private fun checkMethodForContributesAndroidInjector(node: UMethod) {
                if (node.isConstructor ||
                        !isFragmentReturnType(node) ||
                        hasAnnotation(node.annotations, DAGGER_CONTRIBUTESANDROIDINJECTOR_QUALIFIED_NAME)) {
                    return
                }
    
                context.report(
                        MissingContributorIssue.ISSUE,
                        node,
                        context.getNameLocation(node),
                        MissingContributorIssue.ISSUE.getExplanation(TextFormat.TEXT)
                )
            }
    
            private fun isFragmentReturnType(node: UMethod): Boolean {
                val returnTypeRef = node.returnTypeReference
                return returnTypeRef?.getQualifiedName() == HOME_FRAGMENT
            }
    
            private fun hasAnnotation(annotations: List<UAnnotation>, toCheck: String): Boolean {
                return annotations.any { it.qualifiedName == toCheck }
            }
        }
    
        object MissingContributorIssue {
            private const val ID = "MissingContributesAndroidInjector"
            private const val DESCRIPTION = "Must specify @ContributesAndroidInjector"
            private const val EXPLANATION = ("Method must be annotated with @ContributesAndroidInjector if enclosing class is annotated with @Module.")
            private val CATEGORY: Category = Category.CORRECTNESS
            private const val PRIORITY = 1
            private val SEVERITY = Severity.FATAL
            val ISSUE: Issue = Issue.create(
                    ID,
                    DESCRIPTION,
                    EXPLANATION,
                    CATEGORY,
                    PRIORITY,
                    SEVERITY,
                    Implementation(
                            MissingContributorDetector::class.java,
                            Scope.JAVA_FILE_SCOPE)
            )
        }
    
        companion object {
            const val DAGGER_MODULE_ANNOTATION_QUALIFIED_NAME = "dagger.Module"
            const val DAGGER_CONTRIBUTESANDROIDINJECTOR_QUALIFIED_NAME = "dagger.android.ContributesAndroidInjector"
            const val HOME_FRAGMENT = "com.dagger.module.HomeFragment"
        }
    }
    

    A file to test this detector:

    ModuleOne.kt

    @Module  
    abstract class ModuleOne {  
      
        @ContributesAndroidInjector  
      abstract fun isAnnotated(): HomeFragment  
      
        abstract fun shouldBeAnnotated(): HomeFragment  
      
        abstract fun notAnnotated()  
    }  
      
    abstract class ModuleTwo {  
      
        abstract fun okIsNotAnnotated(): HomeFragment  
    }
    

    The lint report showing flagged items:

    enter image description here