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.
-
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, we will use Kotlin and Kotlin DSL in this guide.
-
Configure the
build.gradle.kts
file: Open thebuild.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
}
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")
...
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
}
}
...
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)
}
}
...
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/")
}
...
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")
}
}
...
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") }
}
}
}
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") }
}
}
}
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.
-
Create the pre-commit script: Inside the
src/main/resources/scripts
directory, create a file namedpre-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 nameddetekt-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"
}
}
}
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
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 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
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 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.
Top comments (0)