Swift製CLIツールをSwiftPMで管理するベストプラクティス

## はじめに

私はこれまでSwift製CLIツール（SwiftLint、XcodeGenなど）の管理をMintで行っていました。
SwiftPMで管理できることを知り、実際にMintからSwiftPMに乗り換えたことによる自分なりのベストプラクティスを紹介します。

SwiftPMで管理する方法についてはSwiftFormatのREADMEに記載されているので、先に読んでいただけると私の説明がわかりやすいです。

https://github.com/nicklockwood/SwiftFormat#using-swift-package-manager

## 環境

- OS：macOS Big Sur 11.4
- Xcode：12.4 (12D4e)
- Swift：5.3.2
- swift-tools：5.3

## ベストプラクティス

ベストプラクティスをCLIツールの導入時と実行時に分け、順に紹介します。

### 導入

CLIツール導入時のベストプラクティスです。

#### CLIツールをまとめるフォルダを作る

プロジェクトのルートフォルダにたくさんフォルダを作ると煩雑になるため、CLIツールをまとめるフォルダを作ります。

私は `Tools` という名前で作りました。

```shell-session
$ tree -L 1
.
├── Tools
```

1つのパッケージで足りる場合は不要です。

#### できる限り1つのパッケージにまとめる

すべてのCLIツールを1つのパッケージにまとめたいのですが、以下の理由により難しいです。

- 依存関係の競合
- ターゲット名の衝突

まとめられなかったCLIツール同士でパッケージをまとめるのもいいですが、パッケージ名を考えたりするのが手間なので、私は独立させています。

私は以下の3パッケージとなりました。

```:パッケージ1
XcodeGen
SwiftLint
SpellChecker
LicensePlist
R.swift
xcbeautify
```

```:パッケージ2
IBLinter
```

```:パッケージ3
Mockolo
```

私は __CLIツールはそれぞれ独立して動くべき__ だと考えているのですが、CLIツールごとにパッケージを作るとファイル数が増えてしまうため、依存ライブラリを共有することにしました。

#### パッケージ名にプリフィックスを付ける

パッケージ名がCLIツールと被るとビルドエラーになるため、プリフィックスを付けます。

私はアプリ名をプリフィックスとして付けました。
メインのパッケージを `{App name}Tools` 、独立したパッケージを `{App name}{CLI tools name}` としました。

```swift:Tools/UhooiPicBookTools/Package.swift
// swift-tools-version:5.3
import PackageDescription

let package = Package(
    name: "UhooiPicBookTools", // !!!: パッケージ名にプリフィックスを付ける
    // ...
)
```

3つのパッケージを先ほど作成した `Tools` フォルダに格納します。

```shell-session
$ tree -L 2
.
├── Tools
│   ├── UhooiPicBookIBLinter
│   ├── UhooiPicBookMockolo
│   └── UhooiPicBookTools
```

#### ターゲット名にもプリフィックスを付ける

ターゲット名もCLIツールと被るとビルドエラーになるため、プリフィックスを付けます。

私はパッケージ名と同名にしています。

```swift:Tools/UhooiPicBookTools/Package.swift
// swift-tools-version:5.3
import PackageDescription

let package = Package(
    name: "UhooiPicBookTools",
    // ...
    targets: [.target(name: "UhooiPicBookTools", path: "")] // !!!: ターゲット名とパッケージ名を同じにする
)
```

ターゲットの配列を空にしても動作するようなので、そうすればターゲット名の被りを気にする必要がなくなります。

```diff_yaml:Package.swift
-     targets: [.target(name: "UhooiPicBookTools", path: "")]
+     targets: []
```

ただ `Package.swift` をXcodeで開いたときに「My Mac」が選択肢になくてビルドできなかったため、私はターゲットを明記しています。
![スクリーンショット 2021-06-06 18.01.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/138245/007d5543-0361-c4ce-be5e-3d0e489bf824.png)

#### CLIツールのバージョンを固定する

CLIツールのバージョンを指定して使いたい、かつ他の開発メンバーと同じバージョンを使いたいため、 `from:` でなく `.exact()` で指定してバージョンを固定します。

```swift:Tools/UhooiPicBookTools/Package.swift
// swift-tools-version:5.3
import PackageDescription

let package = Package(
    name: "UhooiPicBookTools",
    // ...
    // CLIツールのバージョンを `.exact()` を使って固定する
    dependencies: [
        .package(url: "https://github.com/yonaskolb/XcodeGen", .exact("2.23.0")),
        .package(url: "https://github.com/realm/SwiftLint", .exact("0.43.1")),
        .package(url: "https://github.com/fromkk/SpellChecker", .exact("0.1.0")),
        .package(url: "https://github.com/mono0926/LicensePlist", .exact("3.0.7")),
        .package(url: "https://github.com/mac-cain13/R.swift", .exact("5.4.0")),
        .package(url: "https://github.com/thii/xcbeautify", .exact("0.9.1"))
    ],
    // ...
)
```

ちなみに通常のCLIツールやライブラリの開発で `.exact()` を使うのは推奨されていません。
複数のパッケージが特定のパッケージに依存している場合、依存関係が競合する可能性が高いためです。
参考：https://github.com/apple/swift-package-manager/blob/ce50cb0de101c2d9a5742aaf70efc7c21e8f249b/Documentation/PackageDescription.md#methods-4

今回はCLIツールなので [Swift Argument Parser](https://github.com/apple/swift-argument-parser) に依存しているものが多く、競合する可能性が高いです。
パッケージを分けたりバージョンを下げて使ったりして回避しましょう。

### 実行

CLIツール実行時のベストプラクティスです。

#### リリースビルドしたバイナリを使う

`swift run -c release swiftlint` のように `swift run` コマンドでもCLIツールを実行できますが、以下のデメリットがあります。

- `swift run` とCLIツールでオプションが衝突すると予期せぬ動作になることがある
- 2回目以降の実行でもオーバーヘッドがある

そのため `swift build -c release swiftlint` のようにリリースビルドし、バイナリを呼び出して実行するのがオススメです。

```bash
# ビルド
swift build -c release --package-path ./Tools/UhooiPicBookTools --product swiftlint

# 実行
./Tools/UhooiPicBookTools/.build/release/swiftlint --fix --format
```

私は `make build-cli-tools` ですべてのCLIツールをリリースビルドできるようにしています。

```make:Makefile
PRODUCT_NAME := UhooiPicBook
CLI_TOOLS_PACKAGE_PATH := Tools/${PRODUCT_NAME}Tools
CLI_TOOLS_PATH := ${CLI_TOOLS_PACKAGE_PATH}/.build/release

.PHONY: build-cli-tools
build-cli-tools: # Build CLI tools managed by SwiftPM
	$(MAKE) build-cli-tool CLI_TOOL_NAME=swiftlint
	swift build -c release --package-path Tools/UhooiPicBookIBLinter --product iblinter
	$(MAKE) build-cli-tool CLI_TOOL_NAME=SpellChecker
	swift build -c release --package-path Tools/UhooiPicBookMockolo --product mockolo
	$(MAKE) build-cli-tool CLI_TOOL_NAME=license-plist
	$(MAKE) build-cli-tool CLI_TOOL_NAME=xcbeautify

.PHONY: build-cli-tool
build-cli-tool:
	swift build -c release --package-path ${CLI_TOOLS_PACKAGE_PATH} --product ${CLI_TOOL_NAME}
```

#### CIでキャッシュを取得する

パッケージごとに `.swiftpm` と `.build` フォルダが生成されますが、 `.build` フォルダをキャッシュすることでCIの実行時間を短縮できます。

GitHub Actionsを使っている場合は公式ドキュメントの例がほぼそのまま使えます。

https://github.com/actions/cache/blob/main/examples.md#swift---swift-package-manager

```yaml:.github/workflows/main.yml
jobs:
  build:
    runs-on: macOS-latest
    steps:
    # ...

    # SwiftPMで管理しているCLIツールのキャッシュ
    - name: Cache CLI tools managed by SwiftPM
      uses: actions/cache@v2
      with:
        path: Tools/UhooiPicBookTools/.build
        key: ${{ runner.os }}-spm-${{ hashFiles('**/Tools/UhooiPicBookTools/Package.resolved') }}
        restore-keys: |
          ${{ runner.os }}-spm-

    - name: Cache IBLinter managed by SwiftPM
      uses: actions/cache@v2
      with:
        path: Tools/UhooiPicBookIBLinter/.build
        key: ${{ runner.os }}-spm-${{ hashFiles('**/Tools/UhooiPicBookIBLinter/Package.resolved') }}
        restore-keys: |
          ${{ runner.os }}-spm-

    - name: Cache Mockolo managed by SwiftPM
      uses: actions/cache@v2
      with:
        path: Tools/UhooiPicBookMockolo/.build
        key: ${{ runner.os }}-spm-${{ hashFiles('**/Tools/UhooiPicBookMockolo/Package.resolved') }}
        restore-keys: |
          ${{ runner.os }}-spm-

    # ...
```

## 全体図

最後にフォルダ構成と各パッケージの `Package.swift` を載せます。

```shell-session
$ tree -L 3
.
├── Tools
│   ├── UhooiPicBookIBLinter
│   │   ├── Empty.swift
│   │   ├── Package.resolved
│   │   └── Package.swift
│   ├── UhooiPicBookMockolo
│   │   ├── Empty.swift
│   │   ├── Package.resolved
│   │   └── Package.swift
│   └── UhooiPicBookTools
│       ├── Empty.swift
│       ├── Package.resolved
│       └── Package.swift
```

```swift:Tools/UhooiPicBookTools/Package.swift
// swift-tools-version:5.3
import PackageDescription

let package = Package(
    name: "UhooiPicBookTools",
    platforms: [
        .macOS(.v11)
    ],
    dependencies: [
        .package(url: "https://github.com/yonaskolb/XcodeGen", .exact("2.23.0")),
        .package(url: "https://github.com/realm/SwiftLint", .exact("0.43.1")),
        // .package(url: "https://github.com/IBDecodable/IBLinter", .exact("0.4.25")),
        .package(url: "https://github.com/fromkk/SpellChecker", .exact("0.1.0")),
        // .package(url: "https://github.com/uber/mockolo", .exact("1.4.0")),
        .package(url: "https://github.com/mono0926/LicensePlist", .exact("3.0.7")),
        .package(url: "https://github.com/mac-cain13/R.swift", .exact("5.4.0")),
        .package(url: "https://github.com/thii/xcbeautify", .exact("0.9.1"))
    ],
    targets: [.target(name: "UhooiPicBookTools", path: "")]
)
```

```swift:Tools/UhooiPicBookIBLinter/Package.swift
// swift-tools-version:5.3
import PackageDescription

let package = Package(
    name: "UhooiPicBookIBLinter",
    platforms: [
        .macOS(.v11)
    ],
    dependencies: [
        // TODO: The target name will no longer conflict with XcodeGen in the next release,
        // and then we'll put the package together.
        .package(url: "https://github.com/IBDecodable/IBLinter", .exact("0.4.25")),
    ],
    targets: [.target(name: "UhooiPicBookIBLinter", path: "")]
)
```

```swift:Tools/UhooiPicBookMockolo/Package.swift
// swift-tools-version:5.3
import PackageDescription

let package = Package(
    name: "UhooiPicBookMockolo",
    platforms: [
        .macOS(.v11)
    ],
    dependencies: [
        .package(url: "https://github.com/uhooi/mockolo", .exact("1.3.4")) // TODO: Replace uhooi with uber
    ],
    targets: [.target(name: "UhooiPicBookMockolo", path: "")]
)
```

## おわりに

これでSwiftPMを使ってSwift製CLIツールを管理できます！

私がOSSで開発している個人アプリのPRが参考になると思うので、リンクを貼ります。

https://github.com/uhooi/UhooiPicBook/pull/199

ここに行き着くまでにたくさんつまづき、複数のCLIツールにPRを出したりしたので、別記事で紹介したいと思っています。

## おまけ: SwiftPM vs Mint

本記事ではSwift製CLIツールの管理をMintからSwiftPMに置き換えましたが、果たしてどちらがいいのでしょうか。

私が置き換えて感じたことは、 __Mintを使うのがかんたんで無難__ ということです。
理由は大きく2つです。

- CLIツール同士の依存関係を気にしなくていい
  - SwiftPMは依存関係の解決が大変だった
- Mintで管理したほうが実行時に速い
  - おそらくCLIツールのインストール時にビルドしてバイナリを保持しているため  
  SwiftPMでも保持しているはずだが、2回目以降の実行でもMintより時間がかかる

ただサードパーティ製のツールに依存しなくなるのはメリットなので、個人開発では導入に踏み切りました。
業務ではMintを使い続けています。

## 参考リンク

- [mintやめてSwiftPMにしました](https://www.notion.so/mint-SwiftPM-3cc20d47c7374216b4a1b1af51ff04fc)
- [CA.swift#14 - Speaker Deck](https://speakerdeck.com/ryitto/ca-dot-swift-number-14)
- [swift-package-manager/PackageDescription.md at main · apple/swift-package-manager](https://github.com/apple/swift-package-manager/blob/main/Documentation/PackageDescription.md)
- [swift-evolution/0162-package-manager-custom-target-layouts.md at master · apple/swift-evolution](https://github.com/apple/swift-evolution/blob/master/proposals/0162-package-manager-custom-target-layouts.md)
- [Add Cleaning SwiftPM by uhooi · Pull Request #205 · uhooi/UhooiPicBook](https://github.com/uhooi/UhooiPicBook/pull/205)
- https://twitter.com/kateinoigakukun/status/1397927091174219779?s=20
- https://twitter.com/treastrain/status/1397971421473607680?s=20