これなに?

Swift Package Managerに含まれるコマンドライン引数パーサ ArgumentParser が僕の好みの仕様だったので紹介したい

対象

コマンドラインツールを作る人でオプションや引数のパースが面倒臭い人
Swift Package Managerのことをちょっと知ってる人
getopt_long使ってる人

僕好みって?

値付きのロングオプションがgetopt_long互換で
--longopt value
--longopt=value
の両方に対応していること。

CommandantもCommanderもOptionKitも =付きは対応してなかった。

あと、自動生成されるhelpがオプション(--help, -h)だったこと。

準備

ArgumentParserはSwift Package Managerにビルトインされていて、一部だけ持ってこれません。
Swift Package Manager全部持って来る必要があります。

SwiftPMを利用するなら

Package.swift
// swift-tools-version:4.0

import PackageDescription

let package = Package(
    name: "SuddenDieGenerator",
    dependencies: [
        .package(url: "https://github.com/apple/swift-package-manager.git", from: "0.2.0")
    ],
    targets: [
        .target(
            name: "SuddenDieGenerator",
            dependencies: ["SuddenDieGeneratorCore"]),

        .target(
            name: "SuddenDieGeneratorCore",
            dependencies: ["Utility"]),

        .testTarget(
            name: "SuddenDieGeneratorCoreTests",
            dependencies: ["SuddenDieGeneratorCore"]),

    ]
)

こんな感じ。
ArgumentParserはswift-package-manager内のUtilityターゲットに入っているのでそこだけに依存する形でいいです。

ソースコード

これからの説明に用いるコードはGitHubに置いてます。
章ごとにタグを打ってますのでご利用ください。
SuddenDieGenerator

使ってみる

日本語がとても下手なのでソースで説明です。
題材として突然の死ジェネレータをあげました。
ここではArgumentParserに焦点を当てたいので、ジェネレータ部分はgithub参照してください。

@karno さん、勝手につかってます。

_人人人人人人_
> 突然の死 <
 ̄Y^Y^Y^Y^Y ̄

使い方説明 序

ではいきなりソースを。

main.swift
import Utility
import SuddenDieGeneratorCore

/// ベーストなるパーサ
/// usage, overviewはhelpに使用される
let parser = ArgumentParser(usage: "text", overview: "”Sudden die“ generator")

/// パーサに引数の指定を追加
/// positionalは識別子であり、heplに使用される
/// kindは引数の型
/// usageはhelpに使用される
let arguments = parser.add(positional: "text", kind: String.self, usage: "Base text")

do {

    /// コマンドライン引数からツール名を取り除きパーサにパースさせる
    let result = try parser.parse(Array(CommandLine.arguments.dropFirst()))

    /// パースの結果からarguments(="text")の値を取り出す
    if let value = result.get(arguments) {

        let generator = Generator(value)
        print(generator.generate())
    }

} catch let error as ArgumentParserError {

    print(error.description)

} catch {

    print(error.localizedDescription)
}

はじめにArgumentParserを生成します。これが、メインのパーサとなります。
他のコマンドライン引数パーサと同じように help オプション(コマンド)は自動生成されます。
ArgumentParser.initに与える値はこのhelp生成に利用されるものです。

次にどのようにパースするかを指定します。
この指定はArgumentProtocolに準拠した型によって行います。
OptionArgument<Kind>,PositionalArgument<Kind>の二つが標準で用意されています。

OptionArgumentはオプションのパースに利用されます。
値の有無や複数の値にも対応しています。
先に書いたようにロングオプションは--longopt valueおよび--longopt=valueのどちらの書き方にも対応しています。
ex)

tool --output file
tool --output=file
tool -o file

--versionオプションのように値を取らないオプションの場合は、kindにBool.selfを指定します。
オプションが指定されていればtrueが設定されます。

PositionalArgumentはサブコマンドや入力ファイル指定などに使われる単独の値をパースします。
後ほど説明しますが、これも複数の値に対応しています。
ex)

tool command --option  # command部分
tool --output=file inputfile0 inputfile1 # inputfile0 inputfile1部分

上のコードでは直接PositionalArgumentを生成せずArgumentParserにaddしつつ値としてPositionalArgumentを受け取るようにしています。

let arguments = parser.add(positional: "text", kind: String.self, usage: "Base text")

この書き方が標準的です。
kindに取り出す値の型を指定することで値の型を気にする必要がなくなっています。

次にパーサを用いてコマンドライン引数をパースします。

/// コマンドライン引数からツール名を取り除きパーサにパースさせる
let result = try parser.parse(Array(CommandLine.arguments.dropFirst()))

よくある方法でCommandLine.argumentsからツール名を取り除いて与えています。
Result型の値が帰ってきます。(この名前はもうちょっとどうにかならなかったのだろうか...)

最後にResultから値を受け取ります.

/// パースの結果からarguments(="text")の値を取り出す
if let value = result.get(arguments) {

    let generator = Generator(value)
    print(generator.generate())
}

パーサから受け取ったPositionalArgumentをget()関数に与えることで対応する値が取り出せます。
PositionalArgument生成時に型を指定しているので取り出した値の型を気にする必要はありません。

ここまでとりあえずの使い方を見ていただきました。
簡単でしょ?

$ swift run SuddenDieGenerator 日本語が下手!
_人人人人人人人人人_
> 日本語が下手! <
 ̄Y^Y^Y^Y^Y^Y^Y^Y ̄

ここまでのソースはTag "1.0"にあります。

使い方説明 OptionArgument

次に本題(?)のオプションのパースです。
ソースはここ

パーサにoptionArgumentsを追加します。
入力に語尾をつけられるようにします。

/// パーサにオプションの指定を追加
/// optionは "--" から始めなければならない
/// shortNameは "-" から始めなければならない
/// kindはオプションの値の型
/// usageはhelpに使用される
let suffix = parser.add(option: "--suffix",
                        shortName: "-s",
                        kind: String.self,
                        usage: "Suffix of text")

えーと、コメントの通りです。
ロングオプション、ショートオプションの指定の方法が独特で--および-が必要です。
こちらも値を受け取る場合の型を指定します。

パース後の値の取り方はPositionalArgumentと同じです。

/// オプション --suffixの値を取り出す
var suffixValue: String?
if let value = result.get(suffix) {

    suffixValue = value
}

とても簡単です。

$ swift run SuddenDieGenerator --suffix=にゃん 日本語が下手
_人人人人人人人人人人人_
> 日本語が下手にゃん <
 ̄Y^Y^Y^Y^Y^Y^Y^Y^Y^Y ̄

ここまでのソースはTag "2.0"にあります。

使い方説明 ArgumentBinder

ソースはここ

パースした値の取り出し部分を見て見ましょう。

/// コマンドライン引数からツール名を取り除きパーサにパースさせる
let result = try parser.parse(Array(CommandLine.arguments.dropFirst()))

/// オプション --suffixの値を取り出す
var suffixValue: String?
if let value = result.get(suffix) {

    suffixValue = value
}
/// パースの結果からarguments(="text")の値を取り出す
if let value = result.get(arguments) {

    let generator = Generator(value)
    print(generator.generate(suffix: suffixValue))
}

素直な実装ですが、オプションやサブコマンドが増えて来ると見通しが悪くなりそうです。

ArgumentParserにはArgumentBinderという値の取り出し方法をあらかじめ登録しておく機構が存在しています。
それを使って見ましょう。

まず、オプションなどの値をひとまとめにしたstructを用意します。

/// 引数やオプションを保持するstruct
struct SuddenDieOptions {

    var suffix: String?

    var text = ""
}

ついでArgumentBinderを用意します。型引数としてSuddenDieOptionsを与えます。

let binder = ArgumentBinder<SuddenDieOptions>()

値の取得方法を登録します。これはpaserへのaddと同時に行います。(同時でなくても構いませんが...)

/// ArgumentBinderを利用し値を取得する
binder.bind(positional: parser.add(positional: "text", kind: String.self, usage: "Base text"),
            to: { options, text in

                options.text = text
})

引数toに与える第1引数はArgumentBinderに与えた型パラメータのインスタンスです。
第2引数はパースで得られた値です。
パースで値が得られなかった場合はこの関数は呼び出されません。

オプションも同様です。

/// ArgumentBinderを利用し値を取得する
binder.bind(option: parser.add(option: "--suffix",
                               shortName: "-s",
                               kind: String.self,
                               usage: "Suffix of text"),
            to: { options, suffix in

                options.suffix = suffix
})

これらのように値の取得方法を登録しておくことで、実際の値の取得は次のようになります。

/// コマンドライン引数からツール名を取り除きパーサにパースさせる
let result = try parser.parse(Array(CommandLine.arguments.dropFirst()))

/// bindに利用するSuddenDieOptionsを用意
var options = SuddenDieOptions()

/// パースの結果を用いてfillすることで与えたoptionsに値が設定される
binder.fill(result, into: &options)

let generator = Generator(options.text)
print(generator.generate(suffix: options.suffix))

とてもスッキリしました。

ここまでのソースはTag "3.0"にあります。

使い方説明 複数の引数を受け取る

ソースはここ

コマンドライツールでは複数の引数を受け取りたいこともよくあります。
これも簡単に扱えます。

複数のワードを受け取って同時に複数の「突然の死」を生成できるようにします。
SuddenDieOptionsを変更します。

/// 引数やオプションを保持するstruct
struct SuddenDieOptions {

    var suffix: String?

    var texts = [String]()
}

PositionalArgumentの生成とArgumentBinderへのbindを変更します。

/// ArgumentBinderを利用し値を取得する
binder.bindArray(positional: parser.add(positional: "text[s]",
                                        kind: [String].self,
                                        strategy: .upToNextOption,
                                        usage: "Base texts"),
            to: { options, texts in

                options.texts = texts
})

PositionalArgumentの生成はkind[String].selfとArrayを受け取るようにし、strategyとしてArrayParsingStrategy.upToNextOptionを与えます。

ArrayParsingStrategyはどこまでパースを繰り返すかを示すenumです。
値は3種類ありoneByOneは直後の値のみを受け取る場合に、upToNextOptionは次のオプションが現れる間の値全てを受け取る場合、remainingは残り全てを値として受け取る場合に用います。

複数の値を受け取る場合はArgumentBinder.bindArray()を用いてbindします。

値の取得は変わりません。

/// コマンドライン引数からツール名を取り除きパーサにパースさせる
let result = try parser.parse(Array(CommandLine.arguments.dropFirst()))

/// bindに利用するSuddenDieOptionsを用意
var options = SuddenDieOptions()

/// パースの結果を用いてbindすることで与えたoptionsに値が設定される
binder.fill(result, into: &options)

options
    .texts
    .map { Generator($0) }
    .map { $0.generate(suffix: options.suffix) }
    .forEach { print($0) }

とっても簡単。

$ swift run SuddenDieGenerator --suffix=にゃん 日本語が下手 やばい
_人人人人人人人人人人人_
> 日本語が下手にゃん <
 ̄Y^Y^Y^Y^Y^Y^Y^Y^Y^Y ̄
_人人人人人人人人_
> やばいにゃん <
 ̄Y^Y^Y^Y^Y^Y^Y ̄

ここまでのソースはTag "4.0"にあります。

使い方説明 PathArgument便利

ソースはここ

コマンドラインツールを作っているとオプションや引数からファイルパスを受けたくなることがとてもよくあります。
ところがこれが、絶対パスだったり相対パスだったりでもう面倒臭てく仕方がありません。
でも、もう大丈夫!
ArgumentParserにはPathArgumentというめちゃ便利なアレがついてきます。

「突然の死」をファイルに出力できるようにしてみましょう。

SuddenDieOptionsの変更。

import Basic

/// 引数やオプションを保持するstruct
struct SuddenDieOptions {

    var suffix: String?

    var outputPath: AbsolutePath?

    var texts = [String]()
}

AbsolutePathは絶対パスを表すstructです。
これはUtilityモジュールではなく、Basicモジュールに定義があるのでBasicモジュールもimportしてください。

outputオプション用のOptionArgumentの追加。

/// 複数のオプションを同時にbind可能
binder.bind(parser.add(option: "--suffix",
                       shortName: "-s",
                       kind: String.self,
                       usage: "Suffix of text"),
            parser.add(option: "--output",
                       shortName: "-o",
                       kind: PathArgument.self,
                       usage: "output file"),
    to: { options, suffix, output in

        options.suffix = suffix
        options.outputPath = output?.path
})

outputオプションの'kind'としてPathArgument.selfを指定します。
これで値としてPathArgumentが受け取れます。

ArgumentBinder.bindには3つまでの同種のArgumentProtocolを与えられるバリエーションがあります。今回はこれを使いました。
複数のArgumentProtocolを与えた場合は、どれかあるいは全部の値がない場合もあるため、toに与える関数の第2、第3(第4)の値はOptionalとなっています。

今回はPathArgument自身ではなく、AbsolutePathが欲しかったのでpathプロパティでAbsolutePathを取り出しています。

値の取得は以下のように今までと変わりありません。

/// パースの結果を用いてbindすることで与えたoptionsに値が設定される
binder.fill(result, into: &options)

let displayText = options
    .texts
    .map { Generator($0) }
    .map { $0.generate(suffix: options.suffix) }
    .reduce(into: [String]()) { $0.append($1) }
    .joined(separator: "\n")

if let outputPath = options.outputPath?.asString {

    try displayText.data(using: .utf8)?.write(to: Foundation.URL(fileURLWithPath: outputPath))

} else {

    print(displayText)
}

SwiftPMで独自にURLstructが定義されているため、普通のURLを使用する時はFoundationを明示する必要があります。
面倒臭いね、これ。

$ swift run SuddenDieGenerator --suffix=にゃん -o hoge.text 日 語が下手 やばい
$ cat hoge.text 
_人人人人人人人人人人人_
> 日本語が下手にゃん <
 ̄Y^Y^Y^Y^Y^Y^Y^Y^Y^Y ̄
_人人人人人人人人_
> やばいにゃん <
 ̄Y^Y^Y^Y^Y^Y^Y ̄$

ここまでのソースはTag "4.0"またはmasterのheadにあります。

まだ説明が足りてない

このほかにも、サブコマンドにサブコマンド用のオプションや引数がある時に使えるsubparserとかあるのですが、実際に使ってないのでご自分で調べてください。

当然 SwiftPMはこのArgumentParserを利用していますので、それを参考にするのが手っ取り早いです。

SwiftPMのCommandsディレクトリ

感想

まだまだ調べきれていませんが、すごくいいと思ったので詳細させていただきました。

ただ、やっぱりArgumentParserを使うためだけにSwift Package Manager全部cloneしないといけないのはつらいかも?

参考にさせていただいたページ

GitHub Apple swift-package-manager
Swift Package Manager (SwiftPM) で作るコマンドラインツール
Swift Package Manager (SwiftPM) Version 4 概要
突然の死ジェネレータ

Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.