Developing a custom Gradle plugin for formatting and static analysis

In the third and last blog of our series, we will help you to create and publish a custom Gradle plugin that automates ktfmt and detekt integration and simplifies your new projects' setup.

Reading Time

In our previous blogs, we explored how to integrate code formatting with ktfmt and static analysis with detekt into your projects. 

While these tools significantly improved our code quality and consistency, setting them up for each new project required a lot of repetitive work. To streamline this process, we decided to develop a custom Gradle plugin that integrates both ktfmt and detekt, along with a pre-commit hook, allowing consistent application across all our projects.

This blog will guide you through the steps of creating and publishing a custom Gradle plugin that automates the integration of ktfmt and detekt, which simplifies the setup process for your new projects.

We will cover publishing to a private Maven repository; however, if you want to publish your plugin publicly, please refer to the Gradle documentation.

Since we will talk about publishing to a private Maven repository, please make sure you already have a private Maven Repository Manager set up, such as Nexus, Artifactory, or similar. If you want to publish your plugin publicly to the Gradle Plugin Portal, you can check the following guide created by the Gradle team.

This blog won’t cover the specifics regarding ktfmt and detekt since we already explained it in the previous blogs mentioned above. 

Now let’s start with a short introduction to Gradle plugins!

Gradle plugins

Gradle plugins allow you to encapsulate reusable build logic, making it easy to apply standardized configurations across multiple projects. By developing a custom plugin, you can ensure that all of your projects adhere to the same formatting and static analysis rules, reducing setup time and eliminating inconsistencies.

For more information, please check out the Gradle documentation.

Let’s dive into the steps to create our Gradle plugin!

Setting up the plugin project

First, let’s create a new Gradle project for the plugin. This project will define the logic for integrating ktfmt and detekt and for applying the pre-commit hook.

  • Create a new directory for your plugin project:

 mkdir precommit
 cd precommit

  • Initialize a new Gradle project:

gradle init

Select the options to create a new Gradle plugin project. In this guide, we will use Kotlin and Kotlin DSL.

  • Configure the build.gradle.kts file: 

Open the build.gradle.kts file and add the following code in the plugins block:

...
plugins {
    ...
    `kotlin-dsl`
    `maven-publish`
}
...

In the code snippet above, we applied the Kotlin DSL plugin and the Maven publish plugin, which will be used upon publishing the plugin to a Maven repository.

Next, add the plugin project group and version:

...
group = "org.example"
version = "0.0.1" // You will use this version code when applying the plugin in other projects
...

After that, implement the ktfmt and detekt Gradle plugins:

...
dependencies {
    implementation("com.ncorti.ktfmt.gradle:0.19.0") // Replace with the latest version
    implementation("io.gitlab.arturbosch.detekt:detekt-gradle-plugin:1.23.6") // Replace with the latest version
}
...

Lastly, you should configure your plugin’s definition in the gradlePlugin block:

...
// Define the gradlePlugin block which contains the plugin configuration
gradlePlugin {
    // Define the plugins block which contains the individual plugin definitions
    plugins {
        // Create a new plugin configuration with the name "precommit"
        create("precommit") {
            // Set the unique identifier for the plugin
            id = "org.example.precommit"
            // Set the display name for the plugin
            displayName = "precommit"
            // Provide the plugin description
            description =
                "Gradle plugin that adds a pre-commit hook to your project that runs detekt and ktfmt"
            // Assign tags to the plugin for easier discovery and categorization
            tags.set(listOf("pre-commit", "kotlin", "detekt", "ktfmt"))
            // Specify the fully qualified name of the class implementing the plugin
            implementationClass = "org.example.PreCommitPlugin"
        }
    }
}

We wanted the plugin to be configurable to be used across all Kotlin projects. That’s why now we will allow the configurability by implementing a configuration interface.

Creating the configuration interface

The plugin should work across a wide variety of Kotlin projects, enable users to use the ktfmt style they prefer, and allow the users to use Jetpack Compose specific detekt configuration when necessary. To do that, we have to define a configuration interface.

  • Create the configuration interface: 

Inside the src/main/kotlin/org/example directory, create a new Kotlin file named PreCommitConfig.kt:

...
interface PreCommitConfig {
    val composeEnabled: Property
    val ktfmtStyle: Property
    val injectHookTaskPath: Property
}

enum class KtfmtStyle {
    GOOGLE_STYLE,
    KOTLIN_STYLE
}

We defined the following configuration fields:

After configurability is defined, the plugin logic implementation follows.

Implementing the plugin logic

Now, let's create the plugin class and define the logic to apply ktfmt, detekt, and the pre-commit hook.

Creating the plugin class

First, we will define the PreCommitPlugin class and create the PreCommitConfig extension:

class PreCommitPlugin : Plugin {
    override fun apply(project: Project) {
        // Create an extension for plugin configuration
        val ext = project.extensions.create("preCommitConfig")

        // Load the pre-commit script resource
        val preCommitScriptResource =
            javaClass.classLoader.getResourceAsStream("scripts/pre-commit")
        val detektConfigResource = javaClass.classLoader.getResourceAsStream("detekt-config.yml")
        ...

Here, we set up the class and created an extension to hold configuration values. We also loaded resources for the pre-commit script and the detekt configuration.

Applying plugins and configurations


Next, we will apply the ktfmt and detekt plugins and configure them based on the provided extension values:

   ...
        project.afterEvaluate {
            // Read configuration values, providing defaults if not set
            val composeEnabled = ext.composeEnabled.orNull ?: false
            val ktfmtStyle = ext.ktfmtStyle.orNull ?: KtfmtStyle.KOTLIN_STYLE
            val injectHookTask = ext.injectHookTaskPath.orNull ?: "app:clean"

            // Configure all subprojects
            allprojects {
                // Apply ktfmt plugin and configure based on the chosen style
                apply(plugin = "com.ncorti.ktfmt.gradle")
                extensions.configure {
                    when (ktfmtStyle) {
                        KtfmtStyle.GOOGLE_STYLE -> googleStyle()
                        KtfmtStyle.KOTLIN_STYLE -> kotlinLangStyle()
                    }
                }

                // Apply detekt plugin and configure
                apply(plugin = "io.gitlab.arturbosch.detekt")
                extensions.configure {
                    if (composeEnabled) {
                        // Use a specific config file for Compose projects
                        config.setFrom(file("$projectDir/config/detekt-config.yml"))
                        buildUponDefaultConfig = true
                    }
                    allRules = false // Don't enable all rules
                    autoCorrect = true // Enable auto-correction
                    parallel = true // Run detekt in parallel
                }

                // Add Compose detekt rules if Compose is enabled
                if (composeEnabled) {
                    dependencies { add("detektPlugins", "io.nlopez.compose.rules:detekt:0.4.5") } // Replace with latest version
                }
            }
                      ...

In the code above, we applied the necessary plugins and configured them according to the extension settings, including applying specific styles for ktfmt and setting up detekt with optional support for Compose projects.

Configuring detekt reports


We will now ensure that the detekt tasks generate the desired report formats:

                        ...
                  // Configure detekt tasks for reporting
            tasks.withType().configureEach {
                reports {
                    // Enable the generation of an HTML report
                    html.required.set(true)

                    // Enable the generation of a TXT report
                    txt.required.set(true)

                    // Enable the generation of a Markdown (MD) report
                    md.required.set(true)
                }
            }
            ...

This code sets up detekt to generate HTML, text, and markdown reports.

Copying configuration files


Next, we will define a task to copy the configuration file for detekt from the plugin resources to the project directory:

   ...
            // Task to copy the detekt config
            tasks.register("copyDetektConfig") {
                description =
                    "Copies the detekt config from the plugin's directory to the app/config folder."
                group = "detektConfig"

                if (detektConfigResource != null) {
                    // Use a temporary file to handle the resource stream
                    val tempDir = Files.createTempDirectory("detekt-config-temp").toFile()
                    val tempFile = File(tempDir, "detekt-config.yml")
                    try {
                        Files.copy(
                            detektConfigResource,
                            tempFile.toPath(),
                            StandardCopyOption.REPLACE_EXISTING)
                        from(tempFile)
                        logger.log(
                            LogLevel.DEBUG, "Copying detekt config from: ${tempFile.absolutePath}")
                    } catch (e: Exception) {
                        logger.log(
                            LogLevel.ERROR, "Failed to copy detekt config resource: ${e.message}")
                    } finally {
                        detektConfigResource.close() // Close the resource stream
                    }
                } else {
                    logger.log(LogLevel.ERROR, "detekt config resource not found in the plugin.")
                }

                into("${rootDir}/app/config/")
                logger.log(LogLevel.DEBUG, "Copying detekt config to: ${rootDir}/app/config/")
            }
            ...

The following code defines a task that copies the Git hook from the plugin’s resources to the project’s Git hooks directory:

            ...
            // Task to copy the pre-commit hook script
            tasks.register("copyGitHooks") {
                description = "Copies the git hooks from the plugin's directory to the .git folder."
                group = "gitHooks"

                if (preCommitScriptResource != null) {
                    // Use a temporary file to handle the resource stream
                    val tempDir = Files.createTempDirectory("git-hooks-temp").toFile()
                    val tempFile = File(tempDir, "pre-commit")
                    try {
                        Files.copy(
                            preCommitScriptResource,
                            tempFile.toPath(),
                            StandardCopyOption.REPLACE_EXISTING)
                        from(tempFile)
                        logger.log(
                            LogLevel.DEBUG, "Copying git hooks from: ${tempFile.absolutePath}")
                    } catch (e: Exception) {
                        logger.log(
                            LogLevel.ERROR, "Failed to copy git hooks resource: ${e.message}")
                    } finally {
                        preCommitScriptResource.close() // Close the resource stream
                    }
                } else {
                    logger.log(LogLevel.ERROR, "Git hooks resource not found in the plugin.")
                }

                into("${rootDir}/.git/hooks/")
                logger.log(LogLevel.DEBUG, "Copying git hooks to: ${rootDir}/.git/hooks/")
                if (composeEnabled) {
                    dependsOn("copyDetektConfig")
                }
            }
            ...

Installing Git hooks

Finally, we will create a task to make the Git hooks executable and inject this task as a dependency of another specified task:

            ...
            // Task to install (make executable) the pre-commit hook
            tasks.register("installGitHooks") {
                description = "Installs the pre-commit git hooks."
                group = "gitHooks"
                workingDir = rootDir
                commandLine = listOf("chmod")
                args("-R", "+x", ".git/hooks/")
                dependsOn("copyGitHooks")
                doLast { logger.info("Git hook installed successfully.") }
            }

            // Make the specified build task depend on installing the git hooks
            afterEvaluate { tasks.getByPath(injectHookTask).dependsOn(":installGitHooks") }
        }
    }
}

This task guarantees the pre-commit hook script is executable and sets it up to run as a dependency of another specified task (e.g., app:clean).

The specified task depends on the installGitHooks task, installGitHooks depends on copyGitHooks, and copyGitHooks depends on copyDetektConfig; in that way, we made sure all tasks are run as a dependency on the specified task.

Your PreCommitPlugin.kt should now look like this:

class PreCommitPlugin : Plugin {
    override fun apply(project: Project) {
        // Create an extension for plugin configuration
        val ext = project.extensions.create("preCommitConfig")

        // Load the pre-commit script resource
        val preCommitScriptResource =
            javaClass.classLoader.getResourceAsStream("scripts/pre-commit")
        val detektConfigResource = javaClass.classLoader.getResourceAsStream("detekt-config.yml")

        project.afterEvaluate {
            // Read configuration values, providing defaults if not set
            val composeEnabled = ext.composeEnabled.orNull ?: false
            val ktfmtStyle = ext.ktfmtStyle.orNull ?: KtfmtStyle.KOTLIN_STYLE
            val injectHookTask = ext.injectHookTaskPath.orNull ?: "app:clean"

            // Configure all subprojects
            allprojects {
                // Apply ktfmt plugin and configure based on chosen style
                apply(plugin = "com.ncorti.ktfmt.gradle")
                extensions.configure {
                    when (ktfmtStyle) {
                        KtfmtStyle.GOOGLE_STYLE -> googleStyle()
                        KtfmtStyle.KOTLIN_STYLE -> kotlinLangStyle()
                    }
                }

                // Apply detekt plugin and configure
                apply(plugin = "io.gitlab.arturbosch.detekt")
                extensions.configure {
                    if (composeEnabled) {
                        // Use a specific config file for Compose projects
                        config.setFrom(file("$projectDir/config/detekt-config.yml"))
                        buildUponDefaultConfig = true
                    }
                    allRules = false // Don't enable all rules
                    autoCorrect = true // Enable auto-correction
                    parallel = true // Run detekt in parallel
                }

                // Add Compose detekt rules if Compose is enabled
                if (composeEnabled) {
                    dependencies { add("detektPlugins", "io.nlopez.compose.rules:detekt:0.4.5") }
                }
            }

            // Configure detekt tasks for reporting
            tasks.withType().configureEach {
                reports {
                    // Enable the generation of an HTML report
                    html.required.set(true)

                    // Enable the generation of a TXT report
                    txt.required.set(true)

                    // Enable the generation of a Markdown (MD) report
                    md.required.set(true)
                }
            }

            // Task to copy the detekt config
            tasks.register("copyDetektConfig") {
                description =
                    "Copies the detekt config from the plugin's directory to the app/config folder."
                group = "detektConfig"

                if (detektConfigResource != null) {
                    // Use a temporary file to handle the resource stream
                    val tempDir = Files.createTempDirectory("detekt-config-temp").toFile()
                    val tempFile = File(tempDir, "detekt-config.yml")
                    try {
                        Files.copy(
                            detektConfigResource,
                            tempFile.toPath(),
                            StandardCopyOption.REPLACE_EXISTING)
                        from(tempFile)
                        logger.log(
                            LogLevel.DEBUG, "Copying detekt config from: ${tempFile.absolutePath}")
                    } catch (e: Exception) {
                        logger.log(
                            LogLevel.ERROR, "Failed to copy detekt config resource: ${e.message}")
                    } finally {
                        detektConfigResource.close() // Close the resource stream
                    }
                } else {
                    logger.log(LogLevel.ERROR, "detekt config resource not found in the plugin.")
                }

                into("${rootDir}/app/config/")
                logger.log(LogLevel.DEBUG, "Copying detekt config to: ${rootDir}/app/config/")
            }

            // Task to copy the pre-commit hook script
            tasks.register("copyGitHooks") {
                description = "Copies the git hooks from the plugin's directory to the .git folder."
                group = "gitHooks"

                if (preCommitScriptResource != null) {
                    // Use a temporary file to handle the resource stream
                    val tempDir = Files.createTempDirectory("git-hooks-temp").toFile()
                    val tempFile = File(tempDir, "pre-commit")
                    try {
                        Files.copy(
                            preCommitScriptResource,
                            tempFile.toPath(),
                            StandardCopyOption.REPLACE_EXISTING)
                        from(tempFile)
                        logger.log(
                            LogLevel.DEBUG, "Copying git hooks from: ${tempFile.absolutePath}")
                    } catch (e: Exception) {
                        logger.log(
                            LogLevel.ERROR, "Failed to copy git hooks resource: ${e.message}")
                    } finally {
                        preCommitScriptResource.close() // Close the resource stream
                    }
                } else {
                    logger.log(LogLevel.ERROR, "Git hooks resource not found in the plugin.")
                }

                into("${rootDir}/.git/hooks/")
                logger.log(LogLevel.DEBUG, "Copying git hooks to: ${rootDir}/.git/hooks/")
                if (composeEnabled) {
                    dependsOn("copyDetektConfig")
                }
            }

            // Task to install (make executable) the pre-commit hook
            tasks.register("installGitHooks") {
                description = "Installs the pre-commit git hooks."
                group = "gitHooks"
                workingDir = rootDir
                commandLine = listOf("chmod")
                args("-R", "+x", ".git/hooks/")
                dependsOn("copyGitHooks")
                doLast { logger.info("Git hook installed successfully.") }
            }

            // Make the specified build task depend on installing the git hooks
            afterEvaluate { tasks.getByPath(injectHookTask).dependsOn(":installGitHooks") }
        }
    }
}

In the following chapter, we will add the pre-commit script and detekt configuration files.

Adding pre-commit script and detekt configuration


For our plugin to work, we need to include the pre-commit script and detekt configuration file in our plugin resources. We talked about creating these files in the previous blogs, so we will just share the code for them.

For more information, please refer to the blogs mentioned in the introduction of this article. 

  • Create the pre-commit script: 

Inside the src/main/resources/scripts directory, create a file named pre-commit with the following content:

#!/bin/bash
set -e

echo "
===================================
|  Formatting code with ktfmt...  |
==================================="

if ! ./gradlew --quiet --no-daemon ktfmtFormat --stacktrace; then
    echo "Ktfmt failed"
    exit 1
fi

echo "
=======================
|  Running detekt...  |
======================="

if ! ./gradlew --quiet --no-daemon detekt --stacktrace -PdisablePreDex; then
    echo "detekt failed"
    exit 1
fi

if ! command -v git &> /dev/null; then
    echo "Git could not be found"
    exit 1
fi

git add -u

exit 0
  • Create the pre-commit script: 

Inside the src/main/resources directory, create a file named detekt-config.yml with the following content:

Compose:
 ComposableAnnotationNaming:
   active: true
 ComposableNaming:
   active: true
 ComposableParamOrder:
   active: true
 CompositionLocalAllowlist:
   active: true
   allowedCompositionLocals: LocalCustomColorsPalette,LocalCustomTypography
 CompositionLocalNaming:
   active: true
 ContentEmitterReturningValues:
   active: true
 DefaultsVisibility:
   active: true
 LambdaParameterInRestartableEffect:
   active: true
 ModifierClickableOrder:
   active: true
 ModifierComposable:
   active: true
 ModifierMissing:
   active: true
 ModifierNaming:
   active: true
 ModifierNotUsedAtRoot:
   active: true
 ModifierReused:
   active: true
 ModifierWithoutDefault:
   active: true
 MultipleEmitters:
   active: true
 MutableParams:
   active: true
 MutableStateParam:
   active: true
 PreviewAnnotationNaming:
   active: true
 PreviewPublic:
   active: true
 RememberMissing:
   active: true
 RememberContentMissing:
   active: true
 UnstableCollections:
   active: true
 ViewModelForwarding:
   active: true
 ViewModelInjection:
   active: true
   
naming:
 FunctionNaming:
   ignoreAnnotated: [ 'Composable' ]
 TopLevelPropertyNaming:
   constantPattern: '[A-Z][A-Za-z0-9]*'
   
complexity:
 LongParameterList:
   functionThreshold: 15
   ignoreDefaultParameters: true
   
 LongMethod:
   active: false
   
style:
 MagicNumber:
   active: false
 UnusedPrivateMember:
   ignoreAnnotated: [ 'Preview' ]

And that’s it; you’ve implemented a Gradle plugin that will apply ktfmt and detekt, necessary configuration, and a pre-commit hook on any project!

By doing this, you avoided repeating the setup process for every project.

But before applying it to your projects, there is one thing left to do: publish your plugin.

Publishing the Gradle plugin to a private repository

After implementing the plugin, the final step is to publish it so you can use it across your projects. This process involves setting up your project for publication and configuring the necessary details for publishing to a repository. 

We will only cover publishing to a private Maven repository. If you want to publish your plugin publicly, please check the references in the blog’s introduction.

Step 1: Check requirements 

You need to be certain that the maven-publish plugin is applied in your build.gradle.kts. This plugin provides the necessary tasks and configurations to publish your artifacts to a Maven repository. Also, make sure you have set up the gradlePlugin block:

plugins {
    ...
    `maven-publish`
}

gradlePlugin {
    plugins {
        create("precommit") {
            id = "org.example.precommit"
            displayName = "PreCommit"
            description = "Gradle plugin that adds a pre-commit hook to your project that runs detekt and ktfmt"
            tags.set(listOf("pre-commit", "kotlin", "detekt", "ktfmt"))
            implementationClass = "org.example.PreCommitPlugin"
        }
    }
}

Step 2: Prepare for publication

Just in case, check if your project has the necessary metadata and credentials to publish the plugin. To tell the maven-publish plugin where to publish the plugin, you need to set up the publishing block. Define the URL to your Maven repository and the accompanying credentials. Since we are working with sensitive information, store these inside the gradle.properties file, which should be inside the .gitignore file to avoid credentials leaking.

Your gradle.properties should look like this:

username=your_username
password=your_password
url=https://nexus.example.org/repository/kotlin-packages

Set up the publishing block by adding the following code to your build.gradle.kts file:

publishing {
    repositories {
        maven {
            credentials {
                username = project.properties["username"].toString()
                password = project.properties["password"].toString()
            }
            url = URI(project.properties["url"].toString())
        }
    }
}

Step 3: Publish the plugin

With everything configured, you can now publish your plugin. Use the following command to publish the plugin to your repository:

./gradlew publish

This command will build the plugin and upload it to the specified Maven repository. Ensure you have network access and that the correct credentials are configured.

Step 4: Using the published plugin

If you published your plugin publicly or to the local Maven repository, you can skip this step.

Since you published your plugin to the private Maven repository, you need to provide the URL and credentials so it can be downloaded into your projects. 

Inside your project, create a local.properties file and add it to .gitignore. 

Add the following contents to local.properties:

username=your_username
password=your_password
url=https://nexus.example.org/repository/kotlin-packages

Next, define the Maven repository inside settings.gradle.kts:

repositories {
    ...
    val localProperties = java.util.Properties().apply {
        load(java.io.FileInputStream(File(rootDir, "local.properties")))
    }

    maven {
        credentials {
            username = localProperties.getProperty("username").toString()
            password = localProperties.getProperty("password").toString()
        }
        url = java.net.URI(localProperties.getProperty("url").toString())
    }
}

After adding the Maven URL and credentials, you can apply the plugin inside your project by including it in the top-level build.gradle.kts file:

plugins {
    ...
    id("org.example.precommit") version "0.0.1" // The plugin version we defined in the plugin build.gradle.kts
}

preKommitConfig {
    composeEnabled = false
    ktfmtStyle = KtfmtStyle.KOTLIN_STYLE
    injectHookTaskPath = "app:preBuild"
}

By doing this, you applied ktfmt and detekt to all of your project modules, guaranteeing consistent code quality and code styling across your entire codebase.

The end of our trilogy


By developing a custom Gradle plugin, we’ve streamlined the process of integrating ktfmt and detekt across multiple projects. This plugin provides consistent code formatting and static analysis, reducing setup time and improving code quality. 

This blog incorporated the essential steps to create, configure, and publish a Gradle plugin that automates the integration of ktfmt and detekt, along with setting up a pre-commit hook. With this plugin, you can guarantee that all your projects follow the same standards with minimal effort.

Hey, you! What do you think?

They say knowledge has power only if you pass it on - we hope our blog post gave you valuable insight.

If you want to share your opinion or you need help with your own projects with codes that need polishing, feel free to contact us.

We'd love to hear what you have to say!