Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Gradle Convention Plugin to automatically add kover(...) dependency and to automatically merge feature-module-filters #715

Closed
OneFiveFour opened this issue Dec 16, 2024 · 4 comments
Assignees
Labels
Feature Feature request issue type S: untriaged Status: issue reported but unprocessed

Comments

@OneFiveFour
Copy link

I have written a gradle convention plugin in Kotlin to be used in an mutlimodule Android project that does the following:

  1. Scan all feature modules (i.e. all modules except for the app module) for the kover plugin
  2. Add the kover(project(":my:feature")) dependency for each of these modules to the app.build.gradle to enable the merged/total report via ./gradlew koverHtmlReportDebug
  3. merge all kover classes filters of each module into the classes filter of the app.build.gradle to allow for a merged/total report that still applies all filters of the feature modules.

I am no expert in gradle, but it works. If anyone knows how to improve this script, feel free to comment. @shanshin Feel free to add this piece of code to the wiki or even add it to Kover.

So far only the classes filters can be read as SetProperty. Packages and sourceset are for some reason only writeable. Should that change, then they can be easily added in collectAndApplyFilters.

Add this to your build-logic module that contains Gradle Convention Plugins

import kotlinx.kover.gradle.plugin.dsl.KoverNames
import kotlinx.kover.gradle.plugin.dsl.KoverProjectExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.artifacts.Configuration

class KoverRootPlugin : Plugin<Project> {

    private val koverPluginId = "org.jetbrains.kotlinx.kover"

    override fun apply(target: Project) {

        target.allprojects {
            project.plugins.withId(koverPluginId) {

                // Find the app module
                val appModule = project.rootProject.allprojects
                    .find { it.name == "app" }

                if (appModule != null) {

                    // Find all feature modules with Kover plugin
                    val koverModules = project.rootProject.allprojects
                        .filter { it.plugins.hasPlugin(koverPluginId) }
                        .filter { it != project.rootProject }
                        .filter { it != appModule }

                    // kover configuration in app module
                    val configuration = appModule.configurations.getByName(KoverNames.configurationName)

                    // add kover dependencies in app module
                    koverModules.forEach { koverModule ->
                        addKoverDependency(configuration, koverModule)
                        collectAndApplyFilters(
                            appModule.koverExtension(),
                            koverModule.koverExtension()
                        )
                    }

                    println(appModule.koverExtension()!!.reports.filters.excludes.classes.get().joinToString(",\n"))
                }
            }
        }
    }

    private fun Project.koverExtension() = this.extensions.findByType(KoverProjectExtension::class.java)

    /**
     * adds the kover dependency to the given configuration:
     * kover(project(":your:feature"))
     */
    private fun Project.addKoverDependency(
        configuration: Configuration,
        koverModule: Project?
    ) {
        checkNotNull(koverModule)

        configuration.dependencies.add(
            project.dependencies.create(koverModule)
        )
    }

    /**
     * adds all kover classes filters of the given featureKover to the appKover.
     */
    private fun collectAndApplyFilters(
        appKover: KoverProjectExtension?,
        featureKover: KoverProjectExtension?
    ) {
        checkNotNull(appKover)
        checkNotNull(featureKover)
        check(appKover != featureKover)

        // Collect filters for source sets, classes, and packages
        val classFilters = featureKover.reports.filters.excludes.classes

        // Apply them to the app module's Kover configuration
        appKover.reports.filters.excludes {
            classes.addAll(classFilters)
        }
    }
}

Register the plugin in your /build-logic/build.gradle.kts

gradlePlugin {
    plugins {
        register("koverRoot") {
            id = "my.kover.root"
            implementationClass = "KoverRootPlugin"
        }
    }
}

Apply this plugin in your root build.gradle

plugins {
    id("my.kover.root")
}
@OneFiveFour OneFiveFour added Feature Feature request issue type S: untriaged Status: issue reported but unprocessed labels Dec 16, 2024
@bsarias
Copy link

bsarias commented Dec 20, 2024

Hi @OneFiveFour, first of all, thanks for this! I was stuck trying to create a plugin with the same purpose as yours, and your post really helped clarify some things.

I tried to improve the code a bit, and I also added some stuff I needed for my project:

class KoverPlugin : Plugin<Project> {

    private val koverPluginId = "org.jetbrains.kotlinx.kover"
    private val applicationModuleName = "application"
    private val excludedModules = listOf("moduleA") // Modules you want to exclude manually

    override fun apply(target: Project) {
        if (target != target.rootProject) {
            target.logger.info("KoverPlugin should be applied to the root project only. Current project: ${target.name}")
            return
        }
        target.subprojects {
            excludedModules.forEach {
                if (name == getModuleByName(it)?.name) {
                    return@subprojects // Skip configuration if excluded module
                }
            }
            if (buildFile.exists().not()) {
                return@subprojects // Skip configuration if no build file, this to avoid dependencies with modules/directories that have no gradle config
            }
            target.logger.info("KoverPlugin applied to project: $name")
            plugins.apply(koverPluginId) // Apply plugin to all modules with this we avoid to add the plugin manually and we can assure is being used in the whole project
        }
        
        // This was being executed inside allProjects, is not needed, because it should be executed a single time, not per each module
        target.configureForKoverPlugin()
    }

    private fun Project.configureForKoverPlugin() {
        val appModule = getModuleByName(applicationModuleName)
        if (appModule == null) {
            logger.warn("Application module '$applicationModuleName' was not found. Kover configuration skipped.")
            return
        }

        val koverModules = getKoverModules(koverPluginId)
        if (koverModules.isEmpty()) {
            logger.warn("No Kover feature modules found.")
            return
        }

        val configuration = appModule.getKoverConfiguration()

        // Add each Kover module as a dependency and configure custom variant
        koverModules.forEach { koverModule ->
            // this was a special config for my project because i needed to use debug but application did not have a debug variant :)
            koverModule.asKoverExtension()?.currentProject {
                createVariant("custom") {
                    add("debug", optional = true)
                }
            }
            appModule.addKoverDependency(configuration, koverModule)
        }

        // Apply filters once after all dependencies are added
        appModule.asKoverExtension()?.collectAndApplyFilters()
    }

    private fun Project.getModuleByName(name: String): Project? {
        return allprojects.find { it.name == name }
    }

    private fun Project.getKoverModules(koverPluginId: String): List<Project> {
        return allprojects.filter { it.plugins.hasPlugin(koverPluginId) && it.name != rootProject.name && it.name != "application" }
    }

    private fun Project.getKoverConfiguration(): Configuration {
        return configurations.getByName(KoverNames.configurationName)
    }

    private fun Project.asKoverExtension() = extensions.findByType(KoverProjectExtension::class.java)

    private fun Project.addKoverDependency(configuration: Configuration, koverModule: Project) {
        configuration.dependencies.add(dependencies.create(koverModule))
    }

    // Here I added the filters because I had them centralized in application instead of having them in each module or in application, so all Kover config is centralized here
    private fun KoverProjectExtension.collectAndApplyFilters() {
       
        reports {
            filters {
                includes {
                ...
                }
                excludes {
                ...
                }
            }
        }
    }
}

Hope this helps :) and thanks again!

@shanshin
Copy link
Collaborator

Hi,
@bsarias, your case is quite typical, there is no need to write a custom plugin for it.

It is enough to use a special merge API for group configuration:

// in the root project
kover {
    merge {
        allProjects {
            it.name !in excludedModules && it.buildFile.exists()
        }

        createVariant("custom") {
            add("debug", optional = true)
        }
    }

    reports {
        filters {
            // report filters
        }
    }
}

@shanshin
Copy link
Collaborator

@OneFiveFour, thank you for your efforts!

It helps to understand what problems beginners face when working with Gradle and configuring plugins in it.

In many ways, the functionality you specified duplicates the merge API (which I specified in the previous post), perhaps we should have been more active in specifying it in the documentation - we are not doing this now, because this approach contradicts the Gradle philosophy.

Also, let me give you a little feedback on your code, which may help you understand Gradle more.


I would not recommend writing custom plugins based on allprojects - this is a general Gradle recommendation and this approach has a number of notable problems.

the recommended method is precompiled convention plugins. Yes, it's not very convenient due to the lack of a single point of configuration.
However, when using allprojects, there are a number of non-obvious problems, especially if you do not know some pitfalls of Gradle.


        target.allprojects {
            project.plugins.withId(koverPluginId) {
                ...
                    // add kover dependencies in app module
                    koverModules.forEach { koverModule ->
                        addKoverDependency(configuration, koverModule)
                        collectAndApplyFilters(
                            appModule.koverExtension(),
                            koverModule.koverExtension()
                        )
                    }
                ...
            }

this code will be processed as many times as there are projects in the build, this may have non-obvious consequences. The configuration must be performed once.


            project.plugins.withId(koverPluginId) {
                   ...
                    val koverModules = project.rootProject.allprojects
                        .filter { it.plugins.hasPlugin(koverPluginId) }

this is one of the non-obvious problems of cross-project calls, this approach is very sensitive to the configuration order of the projects in the build - first, the root project is configured, then the child subprojects are recursively configured.

For example, at the time when the root project is configured, its subprojects have not yet been configured, respectively, plug-ins are not applied there, and it.plugins.hasPlugin(koverPluginId) will return false.

As far as I understand, it is precisely because of this problem that in the previous example you use target.allprojects { so that the configuration is guaranteed to work when all the projects in the assembly are already configured.


merge all kover classes filters of each module into the classes filter of the app.build.gradle to allow for a merged/total report that still applies all filters of the feature modules.

I recommend not doing this, because this mixes two configuration methods: configuration in one place and configuration distributed across projects.

There are no individual filters for each project in Kover, and the ability to specify a filter in a specific project may give the false impression that the classes of this project are filtered only by the filters specified in it.
However, in your implementation, if you write an com.example.* exclusion filter in some project, then the foo.Bar class can also be excluded if the foo.* filter is specified in another project.

Also, if you make all the basic Kover settings in one place, then you should specify the filters in the same place.
In addition, it may be a little confusing, because you are not copying includes filters.

@OneFiveFour
Copy link
Author

OneFiveFour commented Dec 22, 2024

@shanshin Wow, thank you for this detailed response and explanations! It really helped and you actually reconstructed my thoughts when creating this thing pretty much 100% :) I will try out the merge api soon. Also thanks @bsarias for improving on my work, much appreciated!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Feature Feature request issue type S: untriaged Status: issue reported but unprocessed
Projects
None yet
Development

No branches or pull requests

3 participants