In the previous tutorial, you learned about Kotlin Serialization. Now let’s build something real. In this tutorial, you will build a complete CLI (Command-Line Interface) tool: a text analyzer that counts words, finds patterns, and shows statistics.
In this tutorial, you will learn:
- Reading command-line arguments
- File I/O (reading files)
- Text analysis (word count, frequency)
- Colored terminal output with ANSI codes
- Argument parsing
- Error handling
- Structuring a real project
What We’re Building
A text analysis tool called TextAnalyzer with these commands:
textanalyzer analyze <file> Full text analysis
textanalyzer count <file> Word/line/character counts
textanalyzer search <file> --word=<word> Find a word in a file
textanalyzer top <file> Top 10 most common words
textanalyzer help Show help
Step 1: Colored Output
Good CLI tools have colored output. ANSI escape codes add colors to terminal text.
object Colors {
const val RESET = "\u001B[0m"
const val RED = "\u001B[31m"
const val GREEN = "\u001B[32m"
const val YELLOW = "\u001B[33m"
const val BLUE = "\u001B[34m"
const val CYAN = "\u001B[36m"
const val BOLD = "\u001B[1m"
fun red(text: String) = "$RED$text$RESET"
fun green(text: String) = "$GREEN$text$RESET"
fun yellow(text: String) = "$YELLOW$text$RESET"
fun blue(text: String) = "$BLUE$text$RESET"
fun cyan(text: String) = "$CYAN$text$RESET"
fun bold(text: String) = "$BOLD$text$RESET"
}
Usage:
println(Colors.red("Error: File not found"))
println(Colors.green("Success!"))
println(Colors.bold("=== Report ==="))
The RESET code returns the terminal to its normal color. Always put it at the end.
Common ANSI Codes
| Code | Color |
|---|---|
\u001B[31m | Red |
\u001B[32m | Green |
\u001B[33m | Yellow |
\u001B[34m | Blue |
\u001B[36m | Cyan |
\u001B[1m | Bold |
\u001B[0m | Reset |
Step 2: Text Analysis
The core of our tool. Analyze text and return statistics.
data class TextStats(
val characters: Int,
val charactersNoSpaces: Int,
val words: Int,
val lines: Int,
val sentences: Int,
val paragraphs: Int,
val averageWordLength: Double,
val longestWord: String,
val topWords: List<Pair<String, Int>>
)
The analysis function:
fun analyzeText(text: String): TextStats {
val lines = text.lines()
val words = text.split(Regex("\\s+")).filter { it.isNotBlank() }
val sentences = text.split(Regex("[.!?]+")).filter { it.isNotBlank() }
val paragraphs = text.split(Regex("\n\\s*\n")).filter { it.isNotBlank() }
val wordLengths = words.map {
it.replace(Regex("[^a-zA-Z0-9]"), "").length
}
val avgWordLength = if (wordLengths.isNotEmpty()) {
wordLengths.average()
} else 0.0
val longestWord = words.maxByOrNull {
it.replace(Regex("[^a-zA-Z0-9]"), "").length
}?.replace(Regex("[^a-zA-Z0-9]"), "") ?: ""
// Top 10 most common words
val wordFrequency = words
.map { it.lowercase().replace(Regex("[^a-zA-Z0-9]"), "") }
.filter { it.isNotBlank() }
.groupingBy { it }
.eachCount()
.entries
.sortedByDescending { it.value }
.take(10)
.map { it.key to it.value }
return TextStats(
characters = text.length,
charactersNoSpaces = text.replace(Regex("\\s"), "").length,
words = words.size,
lines = lines.size,
sentences = sentences.size,
paragraphs = paragraphs.size,
averageWordLength = avgWordLength,
longestWord = longestWord,
topWords = wordFrequency
)
}
Key techniques used:
split(Regex(...))to split text by patternsgroupingBy { it }.eachCount()for word frequency countingfilter,map,sortedByDescendingfor data processing
Step 3: File I/O
Read files and get file information:
fun readFile(path: String): String? {
val file = File(path)
if (!file.exists()) return null
if (!file.isFile) return null
return file.readText()
}
data class FileInfo(
val name: String,
val path: String,
val size: Long,
val extension: String
)
fun getFileInfo(path: String): FileInfo? {
val file = File(path)
if (!file.exists()) return null
return FileInfo(
name = file.name,
path = file.absolutePath,
size = file.length(),
extension = file.extension
)
}
Format file sizes for humans:
fun formatSize(bytes: Long): String {
return when {
bytes < 1024 -> "$bytes B"
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
bytes < 1024 * 1024 * 1024 -> "${bytes / (1024 * 1024)} MB"
else -> "${bytes / (1024 * 1024 * 1024)} GB"
}
}
File Class Methods
| Method | Description |
|---|---|
file.exists() | Check if file exists |
file.isFile | Is it a file (not directory)? |
file.readText() | Read entire file as string |
file.readLines() | Read file as list of lines |
file.writeText(text) | Write string to file |
file.length() | File size in bytes |
file.extension | File extension (“txt”, “kt”) |
file.name | File name (“test.txt”) |
file.absolutePath | Full file path |
Step 4: Argument Parsing
Parse command-line arguments into a structured format:
data class CliArgs(
val command: String,
val filePath: String?,
val flags: Set<String>
)
fun parseArgs(args: Array<String>): CliArgs {
if (args.isEmpty()) {
return CliArgs("help", null, emptySet())
}
val command = args[0]
val filePath = args.getOrNull(1)?.takeIf { !it.startsWith("--") }
val flags = args.filter { it.startsWith("--") }.toSet()
return CliArgs(command, filePath, flags)
}
Extract flag values:
fun extractFlagValue(flags: Set<String>, name: String): String? {
return flags.find { it.startsWith("--$name=") }
?.substringAfter("=")
}
// Usage
val word = extractFlagValue(flags, "word") // --word=hello -> "hello"
args in main()
The args parameter in main() contains the command-line arguments:
fun main(args: Array<String>) {
// ./textanalyzer analyze test.txt --verbose
// args = ["analyze", "test.txt", "--verbose"]
}
Step 5: Building Commands
Each command is a separate function that returns a string:
Help Command
fun printHelp(): String {
return buildString {
appendLine("${Colors.bold("TextAnalyzer")} — A Kotlin CLI tool")
appendLine()
appendLine("${Colors.yellow("Usage:")}")
appendLine(" textanalyzer analyze <file>")
appendLine(" textanalyzer count <file>")
appendLine(" textanalyzer search <file> --word=<word>")
appendLine(" textanalyzer top <file>")
appendLine(" textanalyzer help")
}
}
Analyze Command
fun analyzeCommand(filePath: String, verbose: Boolean = false): String {
val content = readFile(filePath)
?: return Colors.red("Error: File not found: $filePath")
val fileInfo = getFileInfo(filePath)
?: return Colors.red("Error: Cannot read file: $filePath")
val stats = analyzeText(content)
return buildString {
appendLine(Colors.bold("=== Text Analysis: ${fileInfo.name} ==="))
appendLine()
appendLine("${Colors.cyan("File:")} ${fileInfo.path}")
appendLine("${Colors.cyan("Size:")} ${formatSize(fileInfo.size)}")
appendLine()
appendLine("${Colors.green("Words:")} ${stats.words}")
appendLine("${Colors.green("Lines:")} ${stats.lines}")
appendLine("${Colors.green("Sentences:")} ${stats.sentences}")
appendLine("${Colors.green("Paragraphs:")} ${stats.paragraphs}")
if (verbose) {
appendLine()
appendLine(Colors.bold("--- Top 10 Words ---"))
stats.topWords.forEachIndexed { i, (word, count) ->
appendLine(" ${i + 1}. ${Colors.yellow(word)} ($count)")
}
}
}
}
Count Command
A simple wc-like command:
fun countCommand(filePath: String): String {
val content = readFile(filePath)
?: return Colors.red("Error: File not found: $filePath")
val stats = analyzeText(content)
return "${stats.lines}\t${stats.words}\t${stats.characters}\t$filePath"
}
Search Command
Find a word in a file and show matching lines:
fun searchCommand(filePath: String, word: String): String {
val content = readFile(filePath)
?: return Colors.red("Error: File not found: $filePath")
val lines = content.lines()
val matches = lines.mapIndexedNotNull { index, line ->
if (line.contains(word, ignoreCase = true)) {
Pair(index + 1, line)
} else null
}
return buildString {
if (matches.isEmpty()) {
appendLine(Colors.yellow("No matches found for '$word'"))
} else {
appendLine(Colors.green("Found ${matches.size} line(s) matching '$word':"))
matches.forEach { (lineNum, line) ->
appendLine(" ${Colors.cyan("$lineNum:")} $line")
}
}
}
}
Top Words Command
Show the most common words with a bar chart:
fun topCommand(filePath: String): String {
val content = readFile(filePath)
?: return Colors.red("Error: File not found: $filePath")
val stats = analyzeText(content)
return buildString {
appendLine(Colors.bold("Top 10 words in $filePath:"))
val maxCount = stats.topWords.firstOrNull()?.second ?: 0
stats.topWords.forEachIndexed { index, (word, count) ->
val bar = "=".repeat((count.toDouble() / maxCount * 30).toInt())
appendLine(" ${index + 1}. $word $count $bar")
}
}
}
Step 6: The Main Router
Route commands to the right function:
fun runCli(args: Array<String>): String {
val cliArgs = parseArgs(args)
val verbose = "--verbose" in cliArgs.flags
return when (cliArgs.command) {
"analyze" -> {
val path = cliArgs.filePath
?: return Colors.red("Error: Please provide a file path")
analyzeCommand(path, verbose)
}
"count" -> {
val path = cliArgs.filePath
?: return Colors.red("Error: Please provide a file path")
countCommand(path)
}
"search" -> {
val path = cliArgs.filePath
?: return Colors.red("Error: Please provide a file path")
val word = extractFlagValue(cliArgs.flags, "word")
?: return Colors.red("Error: Please provide --word=<word>")
searchCommand(path, word)
}
"top" -> {
val path = cliArgs.filePath
?: return Colors.red("Error: Please provide a file path")
topCommand(path)
}
"help" -> printHelp()
else -> Colors.red("Unknown command: ${cliArgs.command}")
}
}
fun main(args: Array<String>) {
println(runCli(args))
}
The when expression makes routing clean and readable. Each branch validates its inputs and returns an error if something is missing.
Step 7: Error Handling
Good CLI tools give helpful error messages:
// File not found
readFile(path) ?: return Colors.red("Error: File not found: $path")
// Missing argument
cliArgs.filePath ?: return Colors.red("Error: Please provide a file path")
// Missing flag
extractFlagValue(flags, "word")
?: return Colors.red("Error: Please provide --word=<word>")
Always tell the user what went wrong and what they should do instead.
Step 8: Testing
Every function returns a String, which makes testing easy:
@Test
fun `analyzeText counts words correctly`() {
val stats = analyzeText("hello world foo bar")
assertEquals(4, stats.words)
}
@Test
fun `parseArgs parses command and file`() {
val args = parseArgs(arrayOf("analyze", "test.txt"))
assertEquals("analyze", args.command)
assertEquals("test.txt", args.filePath)
}
@Test
fun `searchCommand finds word`() {
// Create a temp file for testing
val tmpFile = File.createTempFile("test", ".txt")
tmpFile.writeText("Hello Kotlin\nGoodbye Java\nHello World")
tmpFile.deleteOnExit()
val result = searchCommand(tmpFile.absolutePath, "Hello")
assertTrue(result.contains("2 line(s)"))
}
Key testing tips:
- Use
File.createTempFile()for file tests - Call
deleteOnExit()to clean up temp files - Test both success and error cases
- Test edge cases (empty input, missing files)
Running the Tool
With Gradle
./gradlew run --args="analyze myfile.txt"
./gradlew run --args="count myfile.txt"
./gradlew run --args="search myfile.txt --word=kotlin"
./gradlew run --args="top myfile.txt"
./gradlew run --args="help"
Building a JAR
You can package the tool as a standalone JAR:
./gradlew distZip
This creates a distributable ZIP with scripts that run the tool.
Example Output
=== Text Analysis: article.txt ===
File: /home/alex/article.txt
Size: 4 KB
--- Statistics ---
Characters: 4523
Characters (no spaces): 3891
Words: 742
Lines: 85
Sentences: 42
Paragraphs: 12
Avg word length: 5.2
Longest word: programming
--- Top 10 Words ---
1. the (45)
2. kotlin (23)
3. is (18)
4. a (15)
5. function (12)
Design Principles
Return Strings, Don’t Print
Functions return strings instead of printing directly. This makes them testable:
// BAD — hard to test
fun analyze(path: String) {
println("Words: ${countWords(path)}")
}
// GOOD — easy to test
fun analyze(path: String): String {
return "Words: ${countWords(path)}"
}
Separate Parsing from Logic
Keep argument parsing separate from business logic:
// Parsing layer
val cliArgs = parseArgs(args)
// Logic layer
val result = analyzeCommand(cliArgs.filePath)
// Output layer
println(result)
Fail Early with Clear Messages
Check inputs at the start and fail with a helpful message:
val path = cliArgs.filePath
?: return Colors.red("Error: Please provide a file path")
val content = readFile(path)
?: return Colors.red("Error: File not found: $path")
Summary
| Concept | Description |
|---|---|
| ANSI codes | \u001B[31m for colors in terminal |
File.readText() | Read entire file as string |
args: Array<String> | Command-line arguments in main() |
buildString { } | Build strings with appendLine |
groupingBy().eachCount() | Count occurrences |
mapIndexedNotNull | Map with index, skip nulls |
when expression | Route commands |
| Temp files | File.createTempFile() for tests |
Source Code
You can find the source code for this tutorial on GitHub: tutorial-23-cli-tool
What’s Next?
In the next tutorial, you will build a REST API with Ktor.