Never write Shell scripts again, use Kotlin

Umang Chamaria
4 min readNov 24, 2023

Kotlin is a modern and mature programming language designed to make developers happier. It’s concise, safe, and provides many ways to reuse code.

Among so many other things, Kotlin can also be used for writing scripts. Writing scripts in a programming language you use in your everyday work makes it easier. Refer to my article Scripting using Kotlin to learn how to get started with scripting in Kotlin.

Motivation to migrate to Kotlin Scripting

  • For developers using Kotlin in their daily work, it is easier to write scripts in Kotlin. It is more readable than shell scripts(for most of us who are not pros at shell scripts).
  • It provides compile-time safety and IDE support. Making it easy to write scripts.
  • Allows usage of a plethora of Java/Kotlin libraries to do a lot more with scripts with minimum effort.
  • If you are someone who is already writing code in Kotlin no need to learn another language.

To replace Shell scripts with Kotlin Scripts there are two important things we need

  • Execute commands in the shell environment from a Kotlin script
  • Process the output of the executed shell command

Executing commands in Shell Environment

One of the most important things in shell scripts is running commands in the bash environment and performing the next steps based on the output. To run a command in the bash shell environment via Kotlin script use ProcessBuilder. Using the ProcessBuilder APIs you can specify the environment you want to run a command. Below is a sample of how to run a shell command from a Kotlin script.

#!/usr/bin/env kotlin

val process = ProcessBuilder("/bin/bash", "-c", "<command to be executed>").inheritIO().start()
process.waitFor()

The above snippet executes the given command in a new process in a bash shell environment and prints the output on the terminal.

While the above is helpful, in most cases, the output has to be processed further in a script.

Processing output of Shell command

To further process the output of the command the output has to be captured/stored in a String. To do so, we can stream the output of the command as a stream and save it as a String. Refer to the below code snippet to learn how to do it.

/**
* Executes the given command on bash shell and returns the output as a string.
* @param command command to be executed on the shell
* @return [String] output/result of the command execution in string format.
*/
fun executeShellCommandWithStringOutput(command: String): String {
val process = ProcessBuilder("/bin/bash", "-c", command).start()
val reader = BufferedReader(InputStreamReader(process.inputStream))
var line: String? = ""
val builder = StringBuilder()
while (reader.readLine().also { line = it } != null) {
builder.append(line).append(System.getProperty("line.separator"))
}
// remove the extra new line added in the end while reading from the stream
return builder.toString().trim()
}

The above code executes a given command on the bash shell and returns the output of the command as a String. with each line separated by a new line.

Once we have the output from the command we can process it further as needed.

Let's look at a sample script for example. The script below compares the HEAD of the current master branch and the last tagged release in the repository and lists down all the folders/modules(in a multi-module project) that have changed since the last tagged release.

#!/usr/bin/env kotlin

import java.io.BufferedReader
import java.io.InputStreamReader

executeShellCommandWithStringOutput("git fetch")
val tagId = executeShellCommandWithStringOutput("git rev-list --tags --max-count=1")
val latestTag = executeShellCommandWithStringOutput("git describe --tags $tagId")
val lastReleaseCommitId = executeShellCommandWithStringOutput("git rev-parse $latestTag")
val masterCommitId = executeShellCommandWithStringOutput("git rev-parse master")
val diff =
executeShellCommandWithStringOutput("git diff --name-only $masterCommitId $lastReleaseCommitId")
val diffFiles = diff.split(System.getProperty("line.separator"))

val modules = mutableSetOf<String>()
val changeLogFiles = mutableSetOf<String>()
for (file in diffFiles) {
val moduleName = file.split("/").first()
modules.add(moduleName)
}
println("Modules with changes")
println(modules.joinToString("\n"))

/**
* Executes the given command on bash shell and returns the output as a string.
* @param command command to be executed on the shell
* @return [String] output/result of the command execution in string format.
*/
fun executeShellCommandWithStringOutput(command: String): String {
val process = ProcessBuilder("/bin/bash", "-c", command).start()
val reader = BufferedReader(InputStreamReader(process.inputStream))
var line: String?
val builder = StringBuilder()
while (reader.readLine().also { line = it } != null) {
builder.append(line).append(System.getProperty("line.separator"))
}
// trim() is required as an additional line gets appended in the end.
return builder.toString().trim()
}

The same could be done in a shell script in a lesser number of lines as shown below.

git fetch
latestTag=$(git describe --tags `git rev-list --tags --max-count=1`)
lastReleaseCommitId=$(git rev-parse $latestTag)
masterCommitId=$(git rev-parse master)
diffFiles=$(git diff --name-only $masterCommitId $lastReleaseCommitId)
filesArray=( $diffFiles )
moduleNameArray=()
for i in "${filesArray[@]}"
do
moduleNameArray[${#moduleNameArray[@]}]=$(cut -f 1 -d "/"<<<$i)
done
echo "${moduleNameArray[@]}" | tr ' ' '\n' | sort -u

The real advantage of using Kotlin is code readability and maintenance. Even though the shell script consists of fewer lines it is not easily understandable by everyone. The Kotlin script is much easier to read and understandable in comparison.

The place where Kotlin scripts really shine when the tasks are complicated like fetching some data for an API or reading data from a file or if you want to slice and dice some dataset.

Happy Scripting

--

--