【Swift Argument Parser入門】Swiftでコマンドラインツールを作る

2020年2月、AppleがSwift向けライブラリーArgumentParserをリリースしました。 これはSwiftでコマンドラインツールを作る際にコマンド引数を簡単に扱えるライブラリーです。 CarthageやXcodeGenなどSwift製のコマンドラインツールはたくさんありますが、いざ自分で作ろうと思ったときにこのライブラリーが役に立ちます。 この記事ではArgumentParserの使い方を解説します。 Swiftでコマンドラインツールを作成した方にぴったりの記事です。

【Swift Argument Parser入門】Swiftでコマンドラインツールを作る

2020年2月、AppleがSwift向けライブラリーArgumentParserをリリースしました。
これはSwiftでコマンドラインツールを作る際にコマンド引数を簡単に扱えるライブラリーです。
CarthageやXcodeGenなどSwift製のコマンドラインツールはたくさんありますが、いざ自分で作ろうと思ったときにこのライブラリーが役に立ちます。

この記事ではArgumentParserの使い方を解説します。
Swiftでコマンドラインツールを作成したい方にぴったりの記事です。

実行環境

  • Xcode 11.5

ソース

https://github.com/SatoTakeshiX/swift-argument-parser-example-sounds

今回作るコマンドラインツール

今回はsoundsという動物の鳴き声を出力するコマンドラインツールを作ります。

$ sounds
cat: meow meow

プロジェクト作成

まずはプロジェクト作成です。Xcodeを立ち上げて File > New > Project ... で新しいプロジェクトを作成します。
選ぶテンプレートは macOS > Command Line Toolです。

----------2020-06-02-23.32.38-1
プロジェクト名はsoundsとしておきます。
プロジェクトを開いてmain.swiftファイルをみてみましょう。
テンプレートとしてprintが書かれているはずです。

// main.swift
import Foundation
print("Hello, World!")

プロジェクトが作成できたら一度⌘+Bしましょう。
XcodeのProductsグループの下にコンパイル済みのコマンドラインツールのバイナリファイルが出力されています。
ターミナルを起動してドロップ&ドラッグして実行しましょう。

----------2020-06-03-23.28.09

Hello, World!がターミナルに出力されるはずです。

Copy Files

開発をスムーズに進められるようにコマンドラインツールのバイナリファイルを/usr/local/binにコピーしましょう。
XcodeのプロジェクトファイルのBuild Phasesタブに移動します。
Copy Filesを選びます。+ ボタンをクリックしてsoundsファイルを選択します。

----------2020-06-03-23.36.46

その後Pathのテキストフィールドに/usr/local/binを指定しましょう。

----------2020-06-03-23.34.44

⌘+Bでビルドすればsounds/usr/local/binにコピーされているはずです。

$ ls -l /usr/local/bin/sounds 
-rwxr-xr-x  1 satoutakeshi  wheel  14164  6  3 23:22 /usr/local/bin/sounds

改めてターミナルでsoundsを実行してみましょう。
Hello, World!が出力されるはずです。

$ sounds
Hello, World!

うまくコピーされない場合はShift+⌘+Kでクリーンビルドするとうまくいきます。

ArgumentParserをインストール

ArgumentParserをSwiftPMを使ってプロジェクトにインストールします。

XcodeのメニューからFile > Swift Packages > Add Package Dependencyを選びます。
Choose Package ReposigoryにArgumentParserのGitHub URLを入れます。

https://github.com/apple/swift-argument-parser

----------2020-06-03-23.57.01

Choose Package Optionsではそのまま最新のバージョンを指定しましょう。

----------2020-06-03-23.57.11

Nextボタンをクリックします。
しばらくするとXcodeがArgumentParserをプロジェクトにダウンロードします。

これでArgumentParserをプロジェクトで利用する準備が終了しました。

始めてのコマンドラインツール

ArgumentParser用にコードを書き換えます。
main.swiftを開きます。

import Foundation
import ArgumentParser

struct Sounder: ParsableCommand {
}

ArgumentParserをインポートし、Sounderという型を定義します。これがコマンドライン引数を扱う型になります。
そしてSounderParsableCommandを準拠します。

configurationプロパティを定義します。
コマンドラインツールのメタ情報を設定できます。



struct Sounder: ParsableCommand {
    static var configuration = CommandConfiguration(
        commandName: "sounds",
        abstract: "Output some animal sounds",
        discussion: """
        Demonstrationg how the Swift Argument Parser works.
        """,
        version: "1.0.0",
        shouldDisplay: true,
        //subcommands: <#T##[ParsableCommand.Type]#>, // non use
        //defaultSubcommand: <#T##ParsableCommand.Type?#>, // non use
        helpNames: [.long, .short]
    )
    

CommandConfigurationのイニシャライザ引数は次の通りです。
コマンドラインのヘルプの表示を指定することができます。

  • commandName: String コマンドラインの名前
  • abstract: String コマンドラインの短い説明
  • discussion: String コマンドラインの長い説明
  • version: String バージョン
  • shouldDisplay: Bool コマンド引数のヘルプを表示するか
  • subcommands: [ParsableCommand.Type] サブコマンド(この記事では解説しません)
  • defaultSubcommand: ParsableCommand.Type? サブコマンド指定がない場合のデフォルトコマンド(この記事では解説しません)
  • helpNames: NameSpecification ヘルプコマンド指定。デフォルトで--help, -h[.long, .short]を指定しても同様

続いてrunメソッドを実装します。

func run() throws {
    print("Hello, World!")
}

最後にグローバルなところでSounder.main()を実行すれば完成です。

Sounder.main()

ここまでの全体コードはこちらです。

import Foundation
import ArgumentParser

struct Sounder: ParsableCommand {
    static var configuration = CommandConfiguration(
        commandName: "sounds",
        abstract: "Output some animal sounds",
        discussion: """
        Demonstrationg how the Swift Argument Parser works.
        """,
        version: "1.0.0",
        shouldDisplay: true,
        //subcommands: <#T##[ParsableCommand.Type]#>, // non use
        //defaultSubcommand: <#T##ParsableCommand.Type?#>, // non use
        helpNames: [.long, .short]
    )

    func run() throws {
        print("Hello, World!")
    }
}

Sounder.main()

ビルドしてsoundsコマンドのヘルプを出力しましょう。

$ sounds --help
OVERVIEW: Output some animal sounds

Demonstrationg how the Swift Argument Parser works.

USAGE: sounds

OPTIONS:
  --version               Show the version.
  -h, --help              Show help information.

configurationで指定したものが出力されていますね。ここまでくればOKです。

ArgumentParserで設定できる引数

ArgumentParserではコマンドライン引数を定義する方法としてProperty Wrapperの@Argument@Option、そして@Flagの3つが使用できます。

それぞれの特徴は次の通りです。

  • Arguments: ユーザーから与えられる値で、前から順番に読み込まれます。例として次のコマンドラインは3つのファイル名をコマンド引数として与えられています。
% example file1.swift file2.swift file3.swift
  • Option: Key-Valueペアを指定できます。Keyの前に---をつけ、Valueの前にスペースか=をつけて指定します。
% example --count=2 # countというKeyにValueが2。--と=で指定。
% example -count 2 # -とスペースでも指定できる。
  • Flag: Valueが指定できないOptionみたいなものです。Keyのみを指定してそれが特定の値を表します。指定する場合は--をKeyの前につけます。たいてい与えられたKeyがtureの意味を表すときに使います。
% example --verbose # debugモードを表すときに使える

@Argument@Option@Flagそれぞれ実装方法とコマンドラインの指定がどうなるかをみていきます。

Flag

まずはFlagからです。
Flagはデフォルト値を持ち、コマンド引数が指定があれば特定の値に変化する引数です。

指定できる型はBool, Int, enumの3つです。

Bool型のFlag

まずはBool型のFlagを作ってみましょう。
verboseというFlagを作って、コマンドラインの処理をステップ・バイ・ステップでユーザーにわかるようにします。
main.swiftverboseプロパティを定義します。

@Flag(help: "show detail logs")
var verbose: Bool

@Flag(help: "show detail logs")でヘルプ出力した際の説明を指定します。
続いてrunメソッドでverboseを使ったコードを実装します。

func run() throws {
    if verbose {
        print("start sounds")
    }
    print("Meow Meow")

    if verbose {
        print("end sounds")
    }
}

処理の最初と最後にログを出すだけです。ビルドしてコマンドを実行します。
最初は引数なしで実行してみます。

$ sounds
Meow Meow

続いて--verbose引数を追加して実行します。

$ sounds --verbose
start sounds
Meow Meow
end sounds

見事にログが出力されました。
ついでにヘルプも出してみましょう。

$ sounds --help
OVERVIEW: Output some animal sounds

Demonstrationg how the Swift Argument Parser works.

USAGE: sounds [--verbose]

OPTIONS:
  --verbose               show detail logs 
  --version               Show the version.
  -h, --help              Show help information.

きちんとOptionとして--verbose引数とその使い方が表示されているのがわかります。

Int型のFlag

FlagはInt型でも定義できます。
さっきのverboseをIntに直してみましょう。
Int型のFlagはコマンド引数指定がなければ0,指定があれば1の値になります。

@Flag(help: "show detail logs")
var verbose: Int

func run() throws {
    if verbose == 1 {
        print("start sounds")
    }
    print("Meow Meow")

    if verbose == 1 {
        print("end sounds")
    }
}

--verboseをつけてコマンドを実行するとさきほどと同じ出力になるはずです。

$ sounds --verbose
start sounds
Meow Meow
end sounds

Intが0か1かしか値を取らないのならBool型のほうを使ったほうがいいかもしれませんね。

enum型のFlag

最後にFlagはenum型も取ることができます。enum型にEnumerableFlagプロトコルの準拠が必要です。

動物の鳴き声を指定するためにAnimalKindというenumを作ります。

enum AnimalKind: EnumerableFlag {
    case cat
    case dog
    case mouse
    static func name(for value: Sounder.AnimalKind) -> NameSpecification {
        switch value {
            case .cat:
                return [.customShort("c"), .long]
            case .dog:
                return [.customShort("d"), .long]
            case .mouse:
                return [.customShort("m"), .long]
        }
    }
}

EnumerableFlagstatic func name(for:)メソッドを実装することができ、コマンド引数の指定方法を設定することができます。
例えばcatの場合[.customShort("c"), .long]を返しているので引数は-c--cを指定できます。.customShortは任意の文字列一文字をコマンド引数に指定する方法です。.longはcase名がそのままコマンド引数名に指定する方法です。

続いてAnimalKindプロパティとrunメソッドを実装します。

@Flag(help: "specify the kind of animal")
var animalKind: AnimalKind

func run() throws {
    let sounds: String
    switch animalKind {
        case .cat:
            sounds = "Meow Meow"
        case .dog:
            sounds = "bow-wow bow-wow"
        case .mouse:
            sounds = "squeak squeak"
    }
    print(sounds)
}

animalKindによって鳴き声を出し分けています。
ビルドしてまずはヘルプを出力させます。

$ sounds -h
OVERVIEW: Output some animal sounds

Demonstrationg how the Swift Argument Parser works.

USAGE: sounds [--verbose] --cat --dog --mouse

OPTIONS:
  --verbose               show detail logs 
  -c, --cat/-d, --dog/-m, --mouse
                          specify the kind of animal 
  --version               Show the version.
  -h, --help              Show help information.

-c, --cat/-d, --dog/-m, --mouseのコマンド説明が追加されています。

コマンドを実行してみます。

$ sounds --cat
Meow Meow
$ sounds -c
Meow Meow
$ sounds --dog
bow-wow bow-wow
$ sounds -d
bow-wow bow-wow
$ sounds --mouse
squeak squeak
$ sounds -m
squeak squeak

指定した動物の鳴き声が出力されます。

ちなみに、引数を間違うと自動的にエラーが出力されます。

$ sounds --dd # 存在しないFlagを指定した
Error: Missing one of: '--cat', '--dog', '--mouse'
Usage: sounds [--verbose] --cat --dog --mouse

また、現状animalKindプロパティは非オプショナル型なので、コマンド引数が必須です。
引数なしでコマンドを実行してもエラーが表示されます。

$ sounds
Error: Missing one of: '--cat', '--dog', '--mouse'

OVERVIEW: Output some animal sounds

Demonstrationg how the Swift Argument Parser works.

USAGE: sounds [--verbose] --cat --dog --mouse

OPTIONS:
  --verbose               show detail logs 
  -c, --cat/-d, --dog/-m, --mouse
                          specify the kind of animal 
  --version               Show the version.
  -h, --help              Show help information.**

デフォルト値の設定

コマンド引数が必須だと入力が面倒ですね。
デフォルト値を設定しましょう。
やり方は2つあります。プロパティをオプショナル型にするかPropertyWrapperのイニシャライザーを指定するかです。

まずはプロパティをオプショナル型にする方法です。

animalKindプロパティをオプショナル型にしてデフォルト値をつけるように変更します。

@Flag(help: "specify the kind of animal")
var animalKind: AnimalKind? // オプショナル型へ変更

func run() throws {
    let sounds: String
    if let animalKind = animalKind { 
        switch animalKind {
            case .cat:
                sounds = "Meow Meow"
            case .dog:
                sounds = "bow-wow bow-wow"
            case .mouse:
                sounds = "squeak squeak"
        }
    } else {
        sounds = "Meow Meow"
    }
    print(sounds)
}

ビルドをしてコマンドを実行すればデフォルト値としてネコが泣くようになります。

$ sounds
Meow Meow

続いて、PropertyWrapperのイニシャライザーで指定する方法です。
@Flag, @Option, @Argumentにはイニシャライザーの引数にデフォルト値を渡すことができます。

@Flag(default: .cat, help: "specify the kind of animal")
var animalKind: AnimalKind

オプショナルバインディングのコードを書かなくて済むようになるのでコードがスッキリします。

func run() throws {
    let sounds: String
    switch animalKind {
        case .cat:
            sounds = "Meow Meow"
        case .dog:
            sounds = "bow-wow bow-wow"
        case .mouse:
            sounds = "squeak squeak"
    }
    print(sounds)
}

Option

OptionはKey-Valueの値を指定できるコマンド引数を定義します。
使える型はExpressibleByArgumentプロトコルを準拠したものでVersion 1.0の現在String, Int, UInt,Double, Boolなどが使えます。

鳴き声の回数を指定するcounterプロパティを定義します。

@Option(default: 2,
        help: "the number of sounds")
var counter: Int

runメソッドもcounterプロパティによって鳴き声の回数を変えるように変更します。

func run() throws {
    let sounds: String
    switch animalKind {
        case .cat:
            sounds = "Meow"
        case .dog:
            sounds = "bow-wow"
        case .mouse:
            sounds = "squeak"
    }
    var outputs = ""
    for _ in 0 ..< counter {
        outputs += sounds + " "
    }
    print(outputs)
}

ビルドして--counter引数をつけてsoundsを実行します。

$ sounds --counter=3
Meow Meow Meow 
$ sounds --counter=5
Meow Meow Meow Meow Meow 
$ sounds
Meow Meow 

期待通りcounterの数だけ鳴き声が増えるようになりました。
また引数がない場合はデフォルトで2回出力されています。

Errorハンドリング

さて、counter引数を定義しましたが、とくに数値の上限は現状設けていないため、どんな巨大な数字も受け入れてしまっています。

$ sounds --counter=100
Meow Meow Meow Meow Meow Meow Meow Meow Meow Meow Meow Meow Meow Meow Meow ...

このままでは動物たちも鳴くのに疲れてしまうでしょう。
またマイナス値が入力されてもエラーになります。

$ sounds --counter=-2
Fatal error: Can't form Range with upperBound < lowerBound: file /Library/Caches/com.apple.xbs/Binaries/SwiftPrebuiltSDKModules_macOS/install/TempContent/Objects/EmbeddedProjects/CrossTrain_macOS_SDK/macOS_SDK/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/usr/lib/swift/Swift.swiftmodule/x86_64.swiftinterface, line 12682
Illegal instruction: 4

このエラーは直接的には次のコードが実行できないことを表すエラーです。

for _ in 0 ..< counter {
    outputs += sounds + " "
}

しかしコマンドを使うユーザーにとっては有益なエラー内容ではありません。
どのような値を入力し直せばいいのかのフィードバックが必要です。

適切なエラーを出力されるように追加実装をしてみましょう。
RuntimeErrorを定義します。

struct RuntimeError: Error, CustomStringConvertible {
    var description: String
    init(_ description: String) {
        self.description = description
    }
}

そして、runメソッドでcounterの値がマイナス値と10より上の値だったらエラーをthrowするように変更します。

func run() throws {
    if counter < 0 {
        throw(RuntimeError("Counter must be positive."))
    }
    if counter > 10 {
        throw(RuntimeError("Too many counter. Max 10."))
    }
    ...
}

ビルドすれば、--counterがマイナス値と10より上の値でエラーを返すようになります。

$ sounds --counter=-5
Error: Counter must be positive.
$ sounds --counter=11
Error: Too many counter. Max 10.

これでユーザーにわかりやすいエラーを実装することができました。

Argument

続いて最後の引数の指定方法の@Argumentについて解説します。
明示的なコマンド引数を表し、前から順番に読み込まれるものです。

鳴いている動物の名前を指定できるコマンド引数nameを追加でき定義します。

@Argument(help: "the name of sounds animal")
var name: String

runメソッドでnameプロパティを出力するよう変更します。

func run() throws {
    let sounds: String
    switch animalKind {
        case .cat:
            sounds = "Meow"
        case .dog:
            sounds = "bow-wow"
        case .mouse:
            sounds = "squeak"
    }
    var outputs = ""
    for _ in 0 ..< counter {
        outputs += sounds + " "
    }
    print("\(name): \(outputs)")
}

ビルドしてsoundsコマンドを実行します。

$ sounds tama
tama: Meow Meow 

無事にタマが鳴くようになりました。

ところで、引数を2つにするとどうなるでしょう?

$ sounds tama Éclair
Error: Unexpected argument 'Éclair'
Usage: sounds [--verbose] [--cat] [--dog] [--mouse] [--counter <counter>] <name>
  See 'sounds --help' for more information.

エラーが出力されました。nameプロパティはString型なので複数の値を受け付けていません。
複数の値を受け入れるようにプロパティを配列に変更します。

@Argument(help: "the names of sounds animal")
var names: [String]

runメソッドもnamesプロパティごとに出力できるように変更します。

for name in names {
    print("\(name): \(outputs)")
}

ビルドしてsoundsコマンドを複数引数で実行します。

$ sounds tama Éclair
tama: Meow Meow 
Éclair: Meow Meow 

期待通り、複数引数が名前として出力されました。

ArgumentArrayParsingStrategy

@Argumentが配列の場合、コマンド引数が@Optionと組み合わされた場合、どのようにパースするかを指定できます。その動作を表すのがArgumentArrayParsingStrategyです。
ArgumentArrayParsingStrategyは2つ種類があります。

  • remaining: コマンド引数としてダッシュのプレフィックスを無視し、それ以外を引数とします。@Argumentのデフォルトパース値に設定されています。

例えば次のように@Option@Argumentのプロパティがあるとします。

@Option(default: 2,
        help: "the number of sounds")
var counter: Int

@Argument(
    parsing: .remaining,
    help: "the names of sounds animal")
var names: [String]

soundsコマンドを実行するさい、OptionのcounterとArgumentのnamesはどんな位置で入力しても同じように認識されます。

$ sounds --counter=3 tama eclair # counterが最初
> [counter: 3, names: [tama, eclair]
$ sounds tama --counter=3 eclair # counterが真ん中
> [counter: 3, names: [tama, eclair]
$ sounds tama eclair --counter=3 # counterが最後
> [counter: 3, names: [tama, eclair]

ただし、プロパティとして定義していないダッシュプレフィックスの引数が入力された場合はエラーが表示されます。

$ sounds tama eclair --counter=3 --other
Error: Unknown option '--other'
Usage: sounds [--verbose] [--cat] [--dog] [--mouse] [--counter <counter>] [<names> ...]
  See 'sounds --help' for more information.
  • unconditionalRemaining: ダッシュプレフィックスの値も引数として扱います。
    remainingとは異なり、unconditionalRemainingはダッシュプレフィックスの引数をArgumentの値として認識します。

さきほどの例のnamesプロパティをunconditionalRemainingでパースするように変更します。

@Option(default: 2,
        help: "the number of sounds")
var counter: Int

@Argument(
    parsing: .unconditionalRemaining,
    help: "the names of sounds animal")
var names: [String]

soundsコマンドを実行すると--counterの値もnamesの値として認識されます。

$ sounds tama eclair --counter=3 --other
> [counter: 3, names: [tama, eclair, --counter=3, --other]]

--counter=3がnamesの値として認識されていたり、Optionとして定義していない--otherもArgumentとして認識されています。
コマンドを使うユーザーにとって期待する動作ではないので、特に理由がなければremainingを使いましょう。

まとめ

ArgumentParserをつかってSwiftでコマンドラインツールを作る方法を解説しました。
今回の記事ではプロジェクト作成から、@Flag, @Option, @Argumentの使い方、エラーハンドリングやArgumentArrayParsingStrategyによるパース方法まで触れました。
ArgumentParserはコマンド引数のハンドリングを簡単にするツールです。
ぜひSwiftでコマンドラインツールを作るさいは利用してみてください。

今回の成果物はこちらのGitHubに置いています。
こちらもチェックお願いします。

https://github.com/SatoTakeshiX/swift-argument-parser-example-sounds

さらに詳しく

公式GitHubに詳しいドキュメントがあります。
もっとArgumentParserについて調べたいときはこちらを参考にしてください。
今回省いたサブコマンドなどの解説が書かれています。

swift-argument-parser/Documentation at master · apple/swift-argument-parser

参考

画像

Free stock photos from www.rupixen.comによるPixabayからの画像

宣伝

SwiftUIでアプリを作り方を解説した「1人でアプリを作る人を支えるSwiftUI開発レシピ」がBOOTHで発売中です。
SwiftUIでアプリを作りたい方、ぜひチェックしてください!

https://personal-factory.booth.pm/items/1920812