KotlinでCLIツールを作る

こんにちは！tkhsktです。
みなさん、Kotlin書いてますか？Kotlin、楽しいですよねぇ。

この記事ではKotlinでCLIツールを作成する方法を紹介します。

## Kotlin MultiplatformとKotlin/Native

KotlinでCLIツールを作る場合、２つの方法が考えられます。

1. jarファイルを作成する
2. MacやWindowsのネイティブバイナリを作成する

この記事ではJavaの仮想マシン(JVM)が無い環境でも動作する、2つ目の方法を紹介します。

ここで肝となる技術が[Kotlin Multiplatform](https://kotlinlang.org/docs/multiplatform.html)と[Kotlin/Native](https://kotlinlang.org/docs/multiplatform.html)です。Kotlin Multiplatformは異なるプラットフォーム間でコードを共有するための技術で、Kotlin/NativeはKotlinで記述されたコードを仮想マシンなしで実行可能なバイナリにコンパイルする技術です。この記事では、これらの技術を使用してCLIツールを実装します。

## ゴール

この記事では`hello　--lang <言語コード>`のようにコマンドを実行すると、指定した言語コードと対応する言語で「こんにちは」と出力するCLIツールを作成します。

```shell
hello --lang ja
> こんにちは

hello --lang fr
> Bonjour
```

## セットアップ

まずはIntelliJ IDEAなどでKotlinのプロジェクトを作成しましょう。

:::note info
この記事では使用しませんが、[Kotlin Multiplatform Wizard](https://kmp.jetbrains.com/)を使用すると、目的に応じたKotlin Multiplatformのテンプレートをダウンロードできます。
:::

この時点でのディレクトリ構造は下記のようになります。(一部省略)

```
.
├── src
│   └── main
│       └── kotlin
│           └── Main.kt
├── build.gradle.kts
└── settings.gradle.kts
```

### mainディレクトリのリネーム

プロジェクトの作成後、`main`ディレクトリを`commonMain`にリネームします。これはKotlin Multiplatformでの開発では、`main`ではなく[`commonMain`をデフォルトのSourceSetとして認識する](https://kotlinlang.org/docs/multiplatform-dsl-reference.html#source-sets)ためです。

### build.gradle.kts

次に、ビルドの設定をします。Kotlin Multiplatformでの開発で一番面倒なのがgradleの設定です。最低限の設定は下記のようになります。コピーして使いましょう。

```kotlin
plugins {
    kotlin("multiplatform") version "1.9.20"
}

group = "com.example.cli"
version = "1.0-SNAPSHOT"

repositories {
    mavenCentral()
}

kotlin {
    macosArm64 { // Apple siliconのMacで動作するバイナリを生成する
        binaries {
            executable("hello") // hello.kexeという名前のバイナリを生成する
        }
    }
    sourceSets {
        commonMain {
            dependencies {
                // ここにライブラリの依存関係を追加する
            }
        }
    }
}
```

この例ではターゲットに`macosArm64`を指定していますが、他にも`linuxX64`や`mingwX64`などのターゲットを指定可能です。詳しくは[公式ドキュメント](https://kotlinlang.org/docs/native-target-support.html)をご確認ください。

### Clikt

次に、コマンドラインの入力をパースするパーサーを追加します。Kotlin Multiplatformに対応したコマンドラインパーサーとしては、公式のライブラリである[Kotlin/kotlinx-cli](https://github.com/Kotlin/kotlinx-cli)が存在しますが、これは実質メンテナンスされていない状態のよう[^1]なので、この記事では[ajalt/clikt](https://github.com/ajalt/clikt)というサードパーティー製のライブラリを使用します。

ライブラリの依存は、前のステップで設定した`build.gradle.kts`に記述します。

```kotlin
...
kotlin {
    ...
    sourceSets {
        commonMain {
            dependencies {
                implementation("com.github.ajalt.clikt:clikt:4.2.1") // 追加
            }
        }
    }
}
...
```

ここまででCLIツールの実装準備は完了です。

## 実装

まずは言語コードをenumで定義します。

```kotlin
enum class LanguageCode(val value: String) {
    EN("en"),
    JA("ja"),
    FR("fr"),
    ;

    companion object {
        fun findByValue(value: String): LanguageCode? {
            return entries.find { it.value == value }
        }
    }
}
```

次に、言語コードと対応した「こんにちは」を取得するクラスを作成します。

```kotlin
class Hello(languageCode: LanguageCode) {
    val value = when (languageCode) {
        LanguageCode.EN -> "Hello"
        LanguageCode.JA -> "こんにちは"
        LanguageCode.FR -> "Bonjour"
    }
}
```

そして、Cliktを使用してコマンドを実装します。

```kotlin
class HelloCommand : CliktCommand() {

    private val lang: LanguageCode by option()
        .convert { // コマンドラインの入力をLanguageCodeに変換
            LanguageCode.findByValue(it) ?: throw UsageError("サポートしていない言語コードです")
        }.default(LanguageCode.EN)

    override fun run() {
        val helloValue = Hello(lang).value
        echo(helloValue)
    }
}
```

最後に`Main.kt`を修正します。

```kotlin
fun main(args: Array<String>) = HelloCommand().main(args)
```

これで実装は完了です。

## ビルド

実行可能なバイナリは、下記のタスクを実行することで生成できます。

```shell
./gradlew macosArm64Binaries
```

コマンドの実行後、`<プロジェクトのルート>/build/bin/macosArm64/helloReleaseExecutable`を確認すると`hello.kexe`というファイルが生成されていることがわかります。これが実行ファイルです。あとは`hello.kexe`を`hello`にリネームすれば最終的な成果物の完成です。

## おわりに

この記事ではApple siliconのMacで動作するごくシンプルなCLIツールを作成しましたが、Kotlin/Nativeでは設定次第で様々な環境に向けたバイナリを生成することができます。また、[ktorio/ktor](https://github.com/ktorio/ktor)などのKotlin Multiplatformに対応したライブラリなどと組み合わせることで、より高度なCLIツールを作成することも可能です。

興味を持った方はぜひ試してみてください。

[^1]: 2023/12/05時点