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.
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!