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

CodeColor
\u001B[31mRed
\u001B[32mGreen
\u001B[33mYellow
\u001B[34mBlue
\u001B[36mCyan
\u001B[1mBold
\u001B[0mReset

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 patterns
  • groupingBy { it }.eachCount() for word frequency counting
  • filter, map, sortedByDescending for 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

MethodDescription
file.exists()Check if file exists
file.isFileIs 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.extensionFile extension (“txt”, “kt”)
file.nameFile name (“test.txt”)
file.absolutePathFull 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

ConceptDescription
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
mapIndexedNotNullMap with index, skip nulls
when expressionRoute commands
Temp filesFile.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.