1. Overview
Groovy is the default language for writing Gradle scripts. However, since Gradle version 5.0, we can write these scripts in Kotlin, too.
In this tutorial, we’ll look at how we can write Gradle scripts in Kotlin. We will also look at some advantages and disadvantages of using Kotlin DSL scripts.
2. How to Create a Kotlin DSL Script
To write Kotlin DSL scripts, we need to use Gradle version 5.0 or later. To activate the Kotlin DSL and get the best IDE support, we need to use the following extensions:
- .gradle.kts instead of .gradle for our build scripts
- .settings.gradle.kts instead of .settings.gradle for all settings scripts
- .init.gradle.kts instead of .init.gradle for initialization scripts
3. How to Write a Basic Gradle Script for a Java Library
In this section, we’ll go through the different building blocks of a Gradle script written in Kotlin DSL script. We will also look at the differences compared to when writing the same script in Groovy DSL.
3.1. Applying Plugins
We can apply the java-library plugin, which is a core plugin:
plugins {
`java-library`
}
Please note that we don’t need to specify the id function – unlike in Groovy DSL – for core plugins.
We can apply a community plugin by specifying the fully qualified plugin id and version:
plugins {
id("org.flywaydb.flyway") version "8.0.2"
}
3.2. Declaring Dependencies
We can declare different types of dependencies using type-safe accessors:
dependencies {
api("com.google.inject:guice:5.0.1")
implementation("com.google.guava:guava:31.0-jre")
testImplementation("org.junit.jupiter:junit-jupiter-api:5.6.0")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
}
The important thing to note here is that the Kotlin DSL makes all four accessors available in the body of the Gradle script immediately after the plugins {} block. This means that when composing this script, we will have type-safe auto-completion in supported IDEs. This results in a fast and superior scripting experience.
3.3. Declaring Repositories
We can declare both built-in and custom repositories using type-safe accessors:
repositories {
mavenCentral()
maven {
url = uri("https://maven.springframework.org/release")
}
}
While resolving a dependency, Gradle will first check the maven-central repository followed by the springframework repository.
3.4. Configuring Source Sets
Let’s say we want to define a separate source set for our integration tests. The project layout is:
gradle-kotlin-dsl
├── src
│ └── main
│ | ├── java
│ | │ ├── DefaultSorter.java
│ | │ ├── InMemoryRepository.java
│ | │ ├── Reporter.java
│ | │ ├── Repository.java
│ | │ ├── Sorter.java
│ ├── test
│ | ├── java
│ | │ └── DefaultSorterTest.java
│ | │ └── InMemoryRepositoryTest.java
│ | │ └── ReporterTest.java
│ └── integrationTest
│ └── java
│ └── ReporterIntegrationTest.java
└── build.gradle
We can define the integrationTest source set as:
sourceSets {
create("integrationTest") {
compileClasspath += sourceSets.main.get().output
runtimeClasspath += sourceSets.main.get().output
}
}
With this setup, we create a Gradle task called compileIntegrationTestJava. Using this task, we can compile the source files inside the src/integrationTest/java directory.
3.5. Defining a Custom Gradle Task
We need a task to run the integration tests. We can create it using Kotlin DSL:
val integrationTest = task<Test>("integrationTest") {
description = "Task to run integration tests"
group = "verification"
testClassesDirs = sourceSets["integrationTest"].output.classesDirs
classpath = sourceSets["integrationTest"].runtimeClasspath
shouldRunAfter("test")
}
Here, we created a custom task using the Kotlin extension function task that is attached to the Project object. This is different from the Groovy syntax because there we would use the TaskContainer object to create a custom task. This is possible in Kotlin DSL too, but it’s more verbose.
4. Running Command-Line Command With Kotlin DSL
We can use Kotlin DSL to run command-line commands, enhancing our build workflow. In this section, we’ll explore a few scenarios to learn this in detail.
4.1. Creating Command-Line Task
First, let’s create the helloUserCmd task in the build.gradle.kts build script, and look at it in its entirety:
tasks.register("helloUserCmd") {
val user: String? = System.getenv("USER")
project.exec {
commandLine("echo", "Hello,", "$user!")
}
}
Let’s break this down to understand the building blocks of the helloUserCmd task. Firstly, we used the tasks.register() function to define the task. Further, we used the project.exec function to configure the execution of an external command with the help of the commandLine() method. Lastly, it’s important to note that we used the System.getenv() method to retrieve the username from the environment.
Next, let’s run the helloUserCmd task and see it in action:
$ ./gradlew helloUserCmd
7:23:17 AM: Executing 'helloUserCmd'...
> Task :helloUserCmd
Hello, tavasthi!
BUILD SUCCESSFUL in 1s
1 actionable task: 1 executed
7:23:19 AM: Execution finished 'helloUserCmd'.
Great! It worked as expected.
4.2. Capturing Command Output in a Variable
We might sometimes want to capture the command output in a variable for later reuse. Let’s learn how to solve this use case.
Let’s begin by using the logic from the helloUserCmd task to define the helloUserInVarCmd task:
tasks.register("helloUserInVarCmd") {
val user: String? = System.getenv("USER")
val outputStream = ByteArrayOutputStream()
project.exec {
standardOutput = outputStream
commandLine("echo", "Hello,", "$user!")
}
val output = outputStream.toString().trim()
println("Command output: $output")
}
We can notice that the only additional logic in this task is the redirection of standard output to an instance of ByteArrayOutputStream class. Furthermore, we captured the value of outputStream in the output variable to use later in a println() statement.
Now, let’s run the helloUserInVarCmd task and verify its behavior:
$ ./gradlew helloUserInVarCmd
7:25:15 AM: Executing 'helloUserInVarCmd'...
> Task :helloUserInVarCmd
Command output: Hello, tavasthi!
BUILD SUCCESSFUL in 137ms
1 actionable task: 1 executed
7:25:16 AM: Execution finished 'helloUserInVarCmd'.
Perfect! We can see that the println() statement containing the command output worked as expected.
4.3. Capturing Command Output in a File
Let’s solve a similar use case where we want to capture the output in a file. For this purpose, let’s imagine that we want to capture the filenames from the /tmp directory in the output.txt file. So, let’s go ahead and write the tmpFilesCmd task in the build.gradle.kts build script:
tasks.register("tmpFilesCmd") {
val outputFile = File("/tmp/output.txt")
val outputStream: OutputStream = FileOutputStream(outputFile)
project.exec {
standardOutput = outputStream
workingDir = project.file("/tmp")
commandLine("ls", workingDir)
}
}
It’s interesting to note that we’ve defined an instance of the FileOutputStream to redirect the standard output to the underlying file. Additionally, we used the project.file() method to use /tmp as an argument for the ls command.
Now, let’s run the tmpFileCmd task and validate the contents of the output.txt file:
$ ./gradlew tmpFilesCmd
7:26:10 AM: Executing 'tmpFilesCmd'...
> Task :tmpFilesCmd
BUILD SUCCESSFUL in 214ms
1 actionable task: 1 executed
7:26:11 AM: Execution finished 'tmpFilesCmd'.
$ head -2 /tmp/output.txt
114A32AE-CD3B-45DB-98FB-B0E47CB393DF
1A377CC5-E52F-49B6-BDC6-2AD4A8E60FA5
Fantastic! With this, we’ve got a good understanding of capturing command output.
4.4. Handling Failure
When we execute an external command using the project.exec function, Gradle fails execution of the task if the command returns with a non-zero exit status. Although it might be the desired behavior in some use cases, we might want to handle failures in some cases.
To understand this, let’s write the alwaysFailCmd task that executes the ls command for a non-existent path:
tasks.register("alwaysFailCmd") {
val result = project.exec {
commandLine("ls", "invalid_path")
isIgnoreExitValue = true
}
if (result.exitValue == 0) {
println("Command executed successfully.")
} else {
println("Command execution failed.")
}
println("Command status: $result")
}
Now, let’s understand the nitty gritty of the logic. The foremost thing to note is that we’ve set the isIgnoreExitValue to true so that Gradle doesn’t consider task execution a failure when the ls command fails. Further, we’ve captured the execution result of the project.exec() function in the result variable to get the exit status of the command. Lastly, we check the exitValue attribute of the result variable to add the failure handler logic, which in this case, is a simple println() statement.
Now, let’s execute the alwaysFailCmd task:
$ ./gradlew alwaysFailCmd
7:27:08 AM: Executing 'alwaysFailCmd'...
> Task :alwaysFailCmd
Command execution failed.
Command status: {exitValue=1, failure=null}
BUILD SUCCESSFUL in 239ms
1 actionable task: 1 executed
ls: invalid_path: No such file or directory
7:27:09 AM: Execution finished 'alwaysFailCmd'.
As expected, even though the ls command failed with an exitValue=1, Gradle executed the task successfully.
5. IDE Support
Since Kotlin is a statically typed language, unlike Groovy, IDEs can provide several useful features like auto-completion, source code navigation, refactoring support, and error highlighting for Gradle scripts. This means we can enjoy a similar coding experience as we normally do when writing application code in Kotlin or Java. Currently, IntelliJ IDEA and Android Studio provide the best support for Kotlin DSLs.
6. Limitations
The Kotlin DSL has some limitations compared to Groovy especially when comparing the speed of script compilation. There are some other uncommon limitations mentioned on the Gradle official website.
7. Conclusion
In this article, we learned how to compose Gradle scripts using the Kotlin DSL. We also looked at some differences between Gradle scripts written in Groovy and those written in Kotlin DSL. Furthermore, we learned how to use Kotlin DSL for running command-line commands.
As usual, all code examples are available over on GitHub.