Skip to content

2. The same CLI, with annotations ​

In part 1 you built greet with delegates. Here you build the same program the other way: a data class with annotations, and a parser written for you at compile time.

Neither style is the better one. They produce the same program: by the end, the two --help screens are identical character for character.

Add the build setup ​

The annotation style needs three artifacts and the KSP plugin:

kotlin
// build.gradle.kts
plugins {
    kotlin("jvm") version "2.3.20"
    id("com.google.devtools.ksp") version "2.3.11"
}

repositories { mavenCentral() }

dependencies {
    implementation("org.draftcode:argot-core:0.1.2")
    implementation("org.draftcode:argot-annotations:0.1.2")
    ksp("org.draftcode:argot-processor:0.1.2")
}

argot-processor is a ksp dependency, not an implementation one, so it runs during your build and never reaches your runtime classpath.

Kotlin and KSP move together

KSP is published against one specific Kotlin version. 2.3.11 is built for Kotlin 2.3.20; raising one without the other leaves the processor compiling against a Kotlin it was never built for.

Declare the command ​

Where the delegate style used properties, here you annotate constructor parameters:

kotlin
import org.draftcode.argot.annotations.Command
import org.draftcode.argot.annotations.Flag
import org.draftcode.argot.annotations.Option

@Command(name = "greet", description = "Print a friendly greeting.")
data class GreetCommand(
    @Option(names = ["--name", "-n"], help = "Who to greet", default = "world")
    val name: String,
    @Option(names = ["--count", "-c"], help = "How many times", default = "1")
    val count: Int,
    @Flag(names = ["--loud", "-l"], help = "Shout the greeting")
    val loud: Boolean = false,
)

Three things differ from part 1:

  • The type does the work .required() and .default() did. name: String with default = "world" is optional; a non-null parameter with no default would be required; a nullable one would be optional and null when absent.
  • Defaults are strings. Annotation arguments must be compile-time constants, so default = "1" is text. The generated code runs it through the same converter the parameter's type selects, so it becomes a real Int — a typo fails on the first parse rather than becoming something else.
  • The converter comes from the type. count: Int selects the Int converter; you never say .int(). The cost is that you cannot name a converter of your own — see choosing a style.

Use the parsed values ​

The result is an ordinary data class, so the code that consumes it knows nothing about Argot:

kotlin
fun greetings(args: GreetCommand): List<String> {
    val line = "Hello, ${args.name}!"
    return List(args.count) { if (args.loud) line.uppercase() else line }
}

Wire up main ​

Building the project generates parseGreetCommand, named after your class. Wrap it in cli { } to get a command-line program's usual manners — --help prints and exits 0, bad input prints the usage line and an error to stderr and exits 2:

kotlin
import org.draftcode.argot.cli

fun main(argv: Array<String>) {
    val args = cli { parseGreetCommand(argv) }
    greetings(args).forEach(::println)
}

Run it ​

console
$ greet
Hello, world!

$ greet --name Ada --count 2 --loud
HELLO, ADA!
HELLO, ADA!

$ greet --count banana
Usage: greet [options]
error: invalid value 'banana' for --count (expected Int)

What the processor actually wrote ​

The generated file builds a specification and calls the same engine the delegate style calls:

kotlin
// Generated by Argot. Do not edit.
public fun parseGreetCommand(argv: Array<String>): GreetCommand {
    val spec = CommandSpec(
        programName = "greet",
        description = "Print a friendly greeting.",
        params = listOf(
            OptionSpec(
                names = listOf("--name", "-n"),
                converter = StringConverter,
                help = "Who to greet",
                default = StringConverter.convert("world"),
            ),
            // ... --count and --loud
        ),
    )

    val parsed = ArgotEngine.parse(spec, argv)

    return GreetCommand(
        name = parsed.value("--name"),
        count = parsed.value("--count"),
        loud = parsed.flag("--loud"),
    )
}

There is no parser in there. There is a description of your command line, a call to ArgotEngine, and a call to your constructor — roughly what you would have typed by hand. You can open the real thing under build/generated/ksp/, step into it in a debugger, and see it in a stack trace.

The two styles produce identical output

GreetArgs().parse(arrayOf("--help")) and parseGreetCommand(arrayOf("--help")) return the same string, because both reach one implementation of help rendering rather than two that resemble each other. See two styles, one engine.

What next ​

Released under the Apache License 2.0.