Xcode
Swift
SwiftPM
swift3

Swift Package Manager (SwiftPM) で作るコマンドラインツール

More than 1 year has passed since last update.

Swift 3でSwift本体も安定してきて、Swift Package Manager (SwiftPM) もライブラリ配布・コマンドラインツール作成などには充分使える状態だと思っています。

ただ、SwiftPM を使ってコマンドラインツールを作るまとまった説明があまり出回っていなくて、その意味で少し敷居が高いかな?と思ったので一通りの手順を書いてみます。

コマンドラインツールのお題

説明のためのサンプルとして、MonoGeneratorを作りました。

$ MonoGenerator おはようございます
→ おはようございます( ´・‿・`)
$ MonoGenerator おはようございます --suffix (`・ω・´)
→ おはようございます(`・ω・´)
$ MonoGenerator おはようございます --suffix (`・ω・´) --length 10
→ おはようございます(
  • 入力された文字列の最後に「( ´・‿・`)」を付けて出力
  • --suffixオプションを付けると、末尾に付ける文字列を変更可能
  • --lengthオプションを付けると、その文字数以下になるようにカット
    • Twitterの140文字以下に調整、などの用途
$ MonoGenerator とても便利ですね
→ とても便利ですね( ´・‿・`)

SwiftPMでのコマンドラインツール作成手順

Swiftパッケージディレクトリ作成

今回の場合、MonoGeneratorという名前なので、その名前のディレクトリを作って、そのルートで以下を実行します。

$ swift package init --type executable

--typeは他に以下を指定でき、フレームワーク(ライブラリ)を作る場合は、libraryが良いでしょう。

  • empty
  • library
  • system-module

今回はexecutableを指定したので、以下のようにSources/main.swift(エントリーポイントとなるファイル)とPackage.swiftが生成されます。

MonoGenerator/
  Package.swift
  Sources/
    main.swift

main.swiftの中身はこうなっています。

main.swift
print("Hello, world!")

以下のコマンドを実行すると、Hello, world!と出力されます👏

$ swift build
$ .build/debug/MonoGenerator

ちなみに、リリースビルドしたい場合は、$ swift build -c releaseです。
参考: swift-package-manager/Reference.md at master · apple/swift-package-manager

実装ファイルを追加

main.swiftにベタに書いても良いですが、別ファイルに書きつつそのテストも書いてみます。
Sources配下にGenerator.swiftという実装クラスと、Tests配下にそれに対応するテストクラスのGeneratorTests.swiftを作成します。
SwiftPMは規約ベースになっていて、テストファイルはTests配下に、Tests.swiftで終わる名前で配置する必要があります。

MonoGenerator/
  Package.swift
  Sources/
    main.swift
    Generator.swift 🆕
  Tests/
    GeneratorTests.swift 🆕

あるいは、--type libraryで生成すると、これらのファイルが自動生成されるので、その後main.swiftを手で追加、という方が楽かもしれません。

Xcodeプロジェクトを生成

ここから軽くコーディングするわけですが、やはり慣れているXcodeが補完なども効いて良いです。
(他のテキストエディタでもプラグインなど工夫して同じくらい快適にコーディング出来るように整えられればそれでも良いと思っています。)

以下のコマンドを実行すると、ディレクトリ構成などを解釈して、良い感じのXcodeプロジェクトファイルを生成してくれます。

$ swift package generate-xcodeproj

Screen Shot 2016-11-17 at 10.28.55.png

swift package init実行時に自動生成された.gitignoreは以下のようになっていて、デフォルトではXcodeプロジェクトファイルはバージョン管理外です。

.DS_Store
/.build
/Packages
/*.xcodeproj

swift-evolution/0082-swiftpm-package-edit.md at master · apple/swift-evolution

あくまでフォルダ構成やファイルがソースが揃っていれば良く、Xcodeプロジェクトファイルは必要に応じて手元で生成すれば良い、という扱いなのだと思います。

ロジックを記述

冒頭に書いたコマンドラインツールの要件を満たせるようなメソッドと、そのテストを記述します。
(このソース自体はこの記事の本筋では無いので、細かい微妙な点はご容赦ください。)

Generator.swift
import Foundation

class Generator {
    let value: String
    init(value: String) {
        self.value = value
    }

    func generate(suffix: String = "( ´・‿・`)", maxLength: Int? = nil) -> String {
        let r = value + suffix
        guard let maxLength = maxLength else {
            return r
        }
        // こうも書けるけどprefixの方がベター
//        return r[r.startIndex..<r.index(r.startIndex, offsetBy: min(maxLength, r.characters.count))]
        return String(r.characters.prefix(maxLength))
    }
}
GeneraterTests.swift
import XCTest
@testable import MonoGenerator

class GeneratorTests: XCTestCase {
    private var target: Generator!

    override func setUp() {
        super.setUp()
        target = Generator(value: "value")
    }

    func testGenerate() {
        XCTAssertEqual(target.generate(), "value( ´・‿・`)")
    }

    func testGenerate_suffix() {
        XCTAssertEqual(target.generate(suffix: "( ´・‿・`)🐶🍎📱"), "value( ´・‿・`)🐶🍎📱")
    }

    func testGenerate_maxCount() {
        XCTAssertEqual(target.generate(maxLength: 6), "value(")
        XCTAssertEqual(target.generate(maxLength: 100), "value( ´・‿・`)")
    }

}

テストを実行

Xcodeでcommand + Uで実行出来ると快適なのですが、実行ファイル形式(main.swiftを含むもの)に対しては、現状ではXcode上でテスト実行出来なさそうです。

まず、以下のコンパイルエラーが発生してしまいます。

GeneratorTests.swift:10:18: Module 'MonoGenerator' was not compiled for testing

プロジェクト設定で、Enable TestabilityYESにすると、これは解決しますが、その後次のコンパイルエラーが発生してしまいます。

ld: symbol(s) not found for architecture x86_64
clang: error: linker command failed with exit code 1 (use -v to see invocation)

色々がんばればもしかしたら解決するかもしれませんが、そもそも、Xcodeプロジェクトはswift package generate-xcodeprojでカジュアルに再生成するもので、それに対してあとから設定変えてもまた吹き飛んでしまったりして良くないと思っています。

ただ、ターミナルから以下のコマンドを実行してテストすることは問題無く出来ますので、それで我慢しています。

$ swift test

あるいは、ライブラリ(--type library)の場合は問題無くXcodeでテスト実行出来るので、ロジックはライブラリとして別リポジトリに用意(SwiftPMは1リポジトリ・1ライブラリ構成)して、コマンドラインツール側はそれを呼び出すだけの薄っぺらい構成にする、というのも良いと思います。

【追記】Sources配下で別ディレクトリに分けるという、以下に記載の方法が良さそうです

Building a command line tool using the Swift Package Manager

簡単なコマンドラインツールを自前実装

まずは簡単に、オプションなど無しで、以下の要件だけ満たすシンプルな作りで組んでみます。

入力された文字列の最後に「( ´・‿・`)」を付けて出力

main.swift
private func main(arguments: [String]) {
    let arguments = arguments.dropFirst()
    guard let input = arguments.first else {
        print("inputの引数を入力してください😡")
        return
    }
    let generator = Generator(value: input)
    print(generator.generate())
}

main(arguments: CommandLine.arguments)

簡単ですね( ´・‿・`)
もうこれで、以下の動作をするコマンドラインツールが出来ました🎉

$ MonoGenerator おはようございます
→ おはようございます( ´・‿・`)

次に、各種オプションを追加していくわけですが、入力をパースする処理ってけっこう面倒なので、このあたりからライブラリ利用すると良い気がしてきます :thinking:

外部ライブラリの導入

というわけで、コマンドラインツール用のライブラリであるkylef/Commander: Compose beautiful command line interfaces in Swiftを導入していきます。

以下のように、dependenciesの項目を追加して、CommanderのGit URLであるhttps://github.com:kylef/Commander.gitを指定します。

Package.swift
import PackageDescription

let package = Package(
    name: "MonoGenerator",
    dependencies: [
        .Package(url: "https://github.com:kylef/Commander.git",
                 majorVersion: 0),
        ]
)

さらに、swift buildをすると依存パッケージが取り込まれつつリビルドが走ります。
(単純に依存パッケージ更新したいだけであればswift package updateでも良いです。)

Packages配下にCommanderとさらにそれが依存しているSpectreが追加されます。

MonoGenerator/
  Package.swift
  Sources/
    main.swift
    Generator.swift
  Packages/
    Commander-0.5.0/ 🆕
    Spectre-0.7.2/ 🆕
  Tests/
    GeneratorTests.swift

このままではXcodeからは認識されないので、swift package generate-xcodeprojで再生成すると、Commanderをimportして利用可能になります。

Commanderの利用

main.swiftを次のように、Commanderを利用して、さらにオプション引数対応させました。
詳しい使い方は、CommanderのREADMEを参照してください。

main.swift
import Commander

let main = command(Argument<String>("input"),
                   Option("suffix", "( ´・‿・`)", flag: "s"),
                   Option("length", -1, flag: "l")) { input, suffix, length in
                    let generator = Generator(value: input)
                    print(generator.generate(suffix: suffix, maxLength: length < 0 ? nil : length))
}

main.run()

swift buildすると、以下を実行出来るようになりました :tada:

$ MonoGenerator おはようございます
→ おはようございます( ´・‿・`)
$ MonoGenerator おはようございます --suffix (`・ω・´)
→ おはようございます(`・ω・´)
$ MonoGenerator おはようございます --suffix (`・ω・´) --length 10
→ おはようございます(

他のコマンドラインライブラリ

初めは愛用しているCarthage配下のライブラリということもあり、Carthage/Commandant: Type-safe command line argument handlingを使おうと思いました。
ただ、以下のように微妙に思う点があって、使いやすいと感じたCommanderに変えました。

  • READMEがメンテナンスされてないため、使い方を把握するために色々ソースを追う必要があった
  • 使い方がけっこう難しい

使い方がけっこう難しい

関数型ぽい技巧的な作りになっていて、コマンドラインツール作る概ね簡単な要件に対して、学習コストが高く感じました。
技巧的な作りになっているメリットが把握出来なかったのですが、それが分かれば再評価するかもしれません(README・ドキュメント欲しい…)。

READMEのサンプルコードのスクショ:
Screen Shot 2016-11-17 at 11.14.47.png

また、今回使わなかったですが、対話式にする場合、oarrabi/Swiftline: Swiftline is a set of tools to help you create command line applications.が便利そうです。

Ubuntuで実行

単にMacで動くコマンドラインツールであれば、普通Xcodeで以下のように新規プロジェクト作る方が慣れていて簡単で、SwiftPM使ったメリットが現状薄いです。

Screen Shot 2016-11-17 at 11.18.39.png

というわけで、Ubuntuでも動かしてみましょう。

🍂Swiftレター #7🍂でも触れましたが、Ubuntuの14・15系のサポートが切れたようなので、16.04で試してみました。

  1. Download Ubuntu Desktop | Download | UbuntuからUbuntu 16.04.1 LTSをダウンロード
  2. 仮想マシンなどでUbuntuを立ち上げる
    • 僕はParallesを使いました
  3. Swift.org - Download SwiftからUbuntu 16.04版のSwiftをダウンロードして適当なパスに配置
  4. Swift.org - Getting StartedのLinuxの項目に従う
    • $ sudo apt-get install clang
    • $ export PATH=/path/to/Swift/usr/bin:"${PATH}"
  5. SwiftPMで利用するので、Gitをインストール
    • $ sudo apt install git
    • ssh周りも適当に設定してGitHubリポジトリをクローン可能にしておく
  6. 今回作ったMonoGeneratorをクローン・ビルド
    • $ git clone https://github.com:mono0926/MonoGenerator.git
    • $ swift build

そうして、Macと同じく無事にコマンド実行出来るようになりました🎉

Linux上でのテスト実行対応

Linuxでテストを拾えるように、Tests配下にLinuxMain.swiftを配置する必要があります。

LinuxMain.swift
import XCTest
@testable import MonoGeneratorTests

XCTMain([
     testCase(GeneratorTests.allTests),
])

合わせて、GeneratorTests.swiftにも、実行対象のテストを羅列しておきます(ちょっと面倒ですね)。

GeneratorTests.swift
import XCTest
@testable import MonoGenerator

class GeneratorTests: XCTestCase {
    static var allTests : [(String, (GeneratorTests) -> () throws -> Void)] {
        return [
            ("testGenerate", testGenerate),
            ("testGenerate_suffix", testGenerate_suffix),
            ("testGenerate_maxCount", testGenerate_maxCount),
        ]
    }
    // 以下略

これで普通はいけるはずですが、今回うまく出来ず…。
僕の書き方のどこかが悪いことを疑っていますが、Ubuntu上で$ swift testを実行すると、次のエラーが出てしまいました(´・︵・`)
まだあまり調べて無いのですが、分かったら更新します。

【追記】
現状の制約のようです: [SR-1503] Can't test module under linux that has main.swift in it - Swift

あるいは、ライブラリ(--type library)の場合は問題無くXcodeでテスト実行出来るので、ロジックはライブラリとして別リポジトリに用意(SwiftPMは1リポジトリ・1ライブラリ構成)して、コマンドラインツール側はそれを呼び出すだけの薄っぺらい構成にする、というのも良いと思います。

やはり、この構成が良さそうです 🤔

mono@ubuntu:~/Desktop/temp/MonoGenerator$ swift test
Cloning https://github.com:kylef/Commander.git
Enter passphrase for key '/home/mono/.ssh/id_rsa': 
Enter passphrase for key '/home/mono/.ssh/id_rsa': 
HEAD is now at 03ae17a Release 0.5.0
Resolved version: 0.5.0
Cloning https://github.com/kylef/Spectre
HEAD is now at e46b75c chore: Release 0.7.2
Resolved version: 0.7.2
Compile Swift Module 'Spectre' (8 sources)
Compile Swift Module 'Commander' (9 sources)
Compile Swift Module 'MonoGenerator' (2 sources)
Compile Swift Module 'MonoGeneratorTests' (1 sources)
Linking ./.build/debug/MonoGenerator
Linking ./.build/debug/MonoGeneratorPackageTests.xctest
/usr/bin/ld.gold: error: /home/mono/Desktop/temp/MonoGenerator/.build/debug/MonoGenerator.build/main.swift.o: multiple definition of 'main'
/usr/bin/ld.gold: /tmp/LinuxMain-6b834d.o: previous definition here
clang: error: linker command failed with exit code 1 (use -v to see invocation)
<unknown>:0: error: link command failed with exit code 1 (use -v to see invocation)
<unknown>:0: error: build had 1 command failures

というわけで、多少躓いた箇所もありながら、充分実用的・快適にSwiftPMでコマンドラインツール作れる状態であると思っています。
コマンドラインツールは、Go・Python・Rubyなど適材適所で使い分けていくのが良いと思ってはいますが、Swiftで良い要件なら慣れていてかつ一番好きな言語でもあるので、Swiftでガシガシ書いていきたいなと思っています( ´・‿・`)

参考リンク