DEV Community

Marko Pavičić
Marko Pavičić

Posted on

Developing a Custom Gradle Plugin for Formatting and Static Analysis

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 much 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, ensuring consistent application across all our projects.

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

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

Note: We will cover publishing to a private Maven repository, therefore, this blog assumes you 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 there are some small differences to this guide that you can check in this guide by the Gradle team.

This blog won't cover the specifics regarding ktfmt and detekt since that was covered in the previous blogs:

Integrating Code Formatting into Your Android Projects: dev.to, Medium

Enhancing Code Quality with detekt for Static Analysis: dev.to, Medium

Let's start with a short introduction to Gradle plugins.

Introduction to 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, we can ensure that all our projects adhere to the same formatting and static analysis rules, reducing setup time and eliminating inconsistencies.

For more information refer to the Gradle documentation.

Let's dive into the steps to create our Gradle plugin.

Setting Up the Plugin Project

First, we need to create a new Gradle project our plugin. This project will define the logic for integrating ktfmt and detekt and applying the pre-commit hook.

  1. Create a new directory for your plugin project:

      mkdir precommit
      cd precommit
    
  2. Initialize a new Gradle project:

      gradle init
    

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

    Gradle plugin init

  3. 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 apply 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 want for the plugin to be configurable so it can be used across all Kotlin projects. We will allow the configurability by implementing a configuration interface.

Creating the Configuration Interface

The plugin should be able to 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<Boolean>
    val ktfmtStyle: Property<KtfmtStyle>
    val injectHookTaskPath: Property<String>
}

enum class KtfmtStyle {
    GOOGLE_STYLE,
    KOTLIN_STYLE
}
Enter fullscreen mode Exit fullscreen mode

We define the following configuration fields:

Parameter Type Description
composeEnabled Boolean Indicates whether Compose-specific configuration is enabled. If set to true, detekt will include additional checks specific to Jetpack Compose projects
ktfmtStyle KtfmtStyle Specifies the ktfmt style to use for code formatting.
injectHookTaskPath String Defines the path to the Gradle task before which the pre-commit hook script will be installed, app:clean by default

After defining configurability let's implement the plugin logic.

Implementing the Plugin Logic

Next, we will create the plugin class and define the logic to apply ktfmt, detekt, and the pre-commit hook.

1. Creating the Plugin Class

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

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

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

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

2. Applying Plugins and Configurations

Next, we 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<KtfmtExtension> {
                    when (ktfmtStyle) {
                        KtfmtStyle.GOOGLE_STYLE -> googleStyle()
                        KtfmtStyle.KOTLIN_STYLE -> kotlinLangStyle()
                    }
                }

                // Apply detekt plugin and configure
                apply(plugin = "io.gitlab.arturbosch.detekt")
                extensions.configure<DetektExtension> {
                    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
                }
            }
                    ...
Enter fullscreen mode Exit fullscreen mode

Here, we apply the necessary plugins and configure them according to the extension settings, including applying specific styles for ktfmt and setting up detekt with optional support for Compose projects.

3. Configuring detekt Reports

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

                        ...
                  // Configure detekt tasks for reporting
            tasks.withType<Detekt>().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)
                }
            }
            ...
Enter fullscreen mode Exit fullscreen mode

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

4. Copying Configuration Files

Next, we 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<Copy>("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/")
            }
            ...
Enter fullscreen mode Exit fullscreen mode

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<Copy>("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")
                }
            }
            ...
Enter fullscreen mode Exit fullscreen mode

5. Installing Git Hooks

Finally, we 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<Exec>("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") }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This task ensures 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 ensure all tasks are run as a dependency on the specified task.

Your PreCommitPlugin.kt should now look like this:

class PreCommitPlugin : Plugin<Project> {
    override fun apply(project: Project) {
        // Create an extension for plugin configuration
        val ext = project.extensions.create<PreCommitConfig>("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<KtfmtExtension> {
                    when (ktfmtStyle) {
                        KtfmtStyle.GOOGLE_STYLE -> googleStyle()
                        KtfmtStyle.KOTLIN_STYLE -> kotlinLangStyle()
                    }
                }

                // Apply detekt plugin and configure
                apply(plugin = "io.gitlab.arturbosch.detekt")
                extensions.configure<DetektExtension> {
                    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<Detekt>().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<Copy>("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<Copy>("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<Exec>("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") }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Next, 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 created these files in the previous blogs, so we will just share the code for them, for more information please refer to the blogs mentioned at the top.

  1. 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
    
  2. 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 implemented a Gradle plugin that will apply ktfmt and detekt, neccessary configuration and a pre-commit hook on any project. By doing this you avoided repeating the project for every project. But, before you can apply it to your projects, there is one thing left to do and that is the publishing of 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 cover publishing to a private Maven repository. If you want to publish your plugin publicly, please check the notes at the beginning of this blog.

Step 1: Check Requirements

Ensure 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"
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Prepare for Publication

Ensure your project is set up with 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 leaking credentials. Your gradle.properties should look like this:

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

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())
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Step 4: Using the Published Plugin

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. If you published your plugin publicly or to the local Maven repository, you can skip this step. 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
Enter fullscreen mode Exit fullscreen mode

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())
    }
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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

Conclusion

By developing a custom Gradle plugin, we've streamlined the process of integrating ktfmt and detekt across multiple projects. This plugin ensures consistent code formatting and static analysis, reducing setup time and improving code quality. This blog covered 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 ensure that all your projects follow the same standards with minimal effort.

References:

Top comments (0)