iOS

SwiftGenとSwiftLintをSPMのプラグインで実行する

SwiftGenとSwiftLintをSPMのプラグインで実行する

この記事は、「愛好会 開発合宿in沖縄に参加してきた(成果編) SPMベースのプロジェクトを作る」の記事の解説記事の1つです。

愛好会 開発合宿in沖縄に参加してきた(成果編) SPMベースのプロジェクトを作る愛好会の開発合宿in沖縄に参加してきたので、その成果をまとめました! SwiftのSPMベースのプロジェクトのフィジビリ検証をしました!...

この記事では、SwiftGenとSwiftLintをSPMのプラグインで入れる方法をまとめました。

実装環境

  • MacBook Pro 14インチ(M1 Pro)
  • Xcode14.1

SPMのプラグインとは?

SPMのプラグインは、①ビルドツールプラグインと②コマンドプラグインの2種類があります。
今回メインで使用するのは、①のビルドツールプラグインで、これを使用することで、SPMのパッケージのビルド中にスクリプトを実行することができます。
この機能を使うことで、今までBuildPhaseで実行していたような処理をSPMでも同様に実行できます。

SwiftGenSwiftLintは公式でプラグインのサポートしているので、複雑な設定なしに、SPMでSwiftGenやSwiftLintを実行できるようになりました!
SwiftGenのプラグインは、SwiftGenPluginという別のリポジトリで管理されています。

現状のビルドツールプラグインで使いにくいなと感じるのは、プラグインの仕様で、開発者が任意のパラメーターをプラグインの処理に渡したり、カスタマイズができないことです。(これはSwiftLintのREADMEにも記載があります)
例えばSwiftLintやSwiftGenで使う設定ファイルのパスを渡すことも難しいので、プラグインが指定したディレクトリの指定ファイル名の設定ファイルしか適用することができません。
またgitのdiffがあるファイルのみSwiftLintを適用するといったことを直接行うことはできません。

Package.swiftにプラグインの情報を追加する

実際にPackage内で使用できるように設定してみましょう。
今回はSPMProjectのリポジトリをサンプルとして紹介しながら、記述していきます。

なおこのサンプルは以下のような構成になっています。

まずはPackage.swiftにプラグインの情報を記載します。

(省略)
    dependencies: [
        .package(url: "https://github.com/u5-03/SwiftExtensions", from: "1.0.0"),
        .package(url: "https://github.com/realm/SwiftLint", branch: "main"), // ①
        .package(url: "https://github.com/SwiftGen/SwiftGenPlugin", from: "6.6.0") // ②
    ],
    targets: [
        .target(
            name: "BasePackage",
            dependencies: [
                "FunctionA",
                "FunctionB",
                "FunctionC"
            ],
            plugins: [
                .plugin(name: "SwiftLintPlugin", package: "SwiftLint"), // ③
                .plugin(name: "SwiftGenPlugin", package: "SwiftGenPlugin") // ④
            ]
        ),
        .target(
            name: "FunctionA",
            dependencies: [
                "CommonPackage"
            ],
            plugins: [
                .plugin(name: "SwiftLintPlugin", package: "SwiftLint"),
                .plugin(name: "SwiftGenPlugin", package: "SwiftGenPlugin")
            ]
        ),
        .target(
            name: "FunctionB",
            dependencies: [
                "CommonPackage"
            ],
            plugins: [
                .plugin(name: "SwiftLintPlugin", package: "SwiftLint"),
                .plugin(name: "SwiftGenPlugin", package: "SwiftGenPlugin")
            ]
        ),
        .target(
            name: "FunctionC",
            dependencies: [
                "CommonPackage"
            ],
            plugins: [
                .plugin(name: "SwiftLintPlugin", package: "SwiftLint"),
                .plugin(name: "SwiftGenPlugin", package: "SwiftGenPlugin")
            ]
        ),
        .target(
            name: "CommonPackage",
            dependencies: [
                .product(name: "SwiftExtensions", package: "SwiftExtensions")
            ],
            plugins: [
                .plugin(name: "SwiftLintPlugin", package: "SwiftLint"),
                .plugin(name: "SwiftGenPlugin", package: "SwiftGenPlugin")
            ]
        )
    ]
)

上記の①・②でそれぞれSwiftLint, SwiftGenのプラグインのリポジトリのURLをdependencyとして登録しています。
次に③・④でそれをTargetpluginとして登録しています。
これはTargetごとに設定する必要があるので、このサンプルPackageの場合は5つのTarget全てに同様の記述をします。
これでPackage.swiftの設定は完了です!

なおSwiftLintについては、ブランチではなく、バージョンを指定すると、以下のようなエラーが出ました。
これはまだ原因がわかっていません。

設定ファイル等の実装をする

SwiftLint

SwiftLintの場合は、.swiftlint.ymlファイルを実行ディレクトリに置くだけで完了です。
ちなみに設定ファイルを置いていない場合、置き場を間違えた場合、以下のようなエラーが出て、プラグインが実行できません。
エラーのメッセージの意味はよくわかっていないです(SwiftLintのソースコードを軽く追いましたが、わからなかった。。)

これで実行すると、以下のようなアラートが出ますが、これはそのままTrust & Enable Allを選択します。

これで以下のように、Lintの警告が表示されれば、動作確認は完了です!

SPMの実行ディレクトリはどこ?

最初にプラグインを実行する時に、実行するディレクトリはどこかな?と疑問に思ったので、調べてみました!
Xcodeのビルドログで、プラグインを実行している箇所を探して、↓の右側の赤く囲ったボタンをクリックします

クリック後に表示される詳細情報をみると、cd Packageのディレクトリに移動していることがわかるので、swiftlint.ymlなどの設定ファイルのデフォルトの置き場はこのディレクトリになります。

ちなみに上記の画像には実は生成されたShellファイルのパスも書かれています。

/Users/yugo.sugiyama/Library/Developer/Xcode/DerivedData/SPMProject-asvabhmgdwvtmuevvdigpmgsnkwj/Build/Intermediates.noindex/LocalPackages.build/Debug-iphoneos/LocalPackages_BasePackage.build/Script-11304790157053709582.shの箇所がそれになります。
これを開いてみると、以下のようなことが書いてあります。

#!/bin/bash
/usr/bin/sandbox-exec -p "(version 1)
(deny default)
(import \"system.sb\")
(allow file-read*)
(allow process*)
(allow file-write*
    (subpath \"/private/tmp\")
    (subpath \"/private/var/folders/bz/cpl5mvf90g11jn08qzzz41mm0000gn/T\")
)
(deny file-write*
    (subpath \"/Users/yugo.sugiyama/Dev/Swift/Sample/SPMProject/LocalPackages\")
)
(allow file-write*
    (subpath \"/Users/yugo.sugiyama/Library/Developer/Xcode/DerivedData/SPMProject-asvabhmgdwvtmuevvdigpmgsnkwj/SourcePackages/plugins/localpackages.output/BasePackage/SwiftLintPlugin\")
)
" "/${BUILD_DIR}/${CONFIGURATION}/swiftlint" lint --cache-path /Users/yugo.sugiyama/Library/Developer/Xcode/DerivedData/SPMProject-asvabhmgdwvtmuevvdigpmgsnkwj/SourcePackages/plugins/localpackages.output/BasePackage/SwiftLintPlugin --config /Users/yugo.sugiyama/Dev/Swift/Sample/SPMProject/LocalPackages/.swiftlint.yml /Users/yugo.sugiyama/Dev/Swift/Sample/SPMProject/LocalPackages/Sources/BasePackage/BasePackage.swift /Users/yugo.sugiyama/Dev/Swift/Sample/SPMProject/LocalPackages/Sources/BasePackage/BaseView.swift /Users/yugo.sugiyama/Dev/Swift/Sample/SPMProject/LocalPackages/Sources/BasePackage/Generated/ImageAssets.swift

このコードは、SwiftLintのSwiftLintPlugin.swiftで生成された処理が書かれています。
これを見ると、lintするファイルのパスを個別に渡して、lintを適用していることがわかります。

SwiftGen

SwiftGenの場合は、設定ファイルとしてswiftgen.ymlを使用します。
今回SwiftGenで生成するコードのリソース等は、以下の通りです

  1. 画像
  2. Localizeのファイル

このうち、②・③はCommonPackageで共通管理し、画像については使用するTargetごとに個別管理します。
最初は画像も共通管理しようとしていましたが、xibStoryboardの場合、GUIIからでは別のModule(Bundle)の画像を直接表示することはできないのと、WWDCの動画や有識者の方から個別管理が推奨とのお言葉をいただいたので、この方針にしました!(アドバイスありがとうございます!)

そのため、今回は以下の2つのswiftgen.ymlを用意しました。

  1. 各Targetで画像のアセット情報をもとに、画像のリソースアクセス用のコードを生成するもの(コード)
  2. CommonPackageで色とLocalizeファイルに関するコードを生成するもの(コード)

①については、すべてのTargetで共通化しているため、input_dirなどのパスについては、$(TARGET_NAME)の環境変数を使用しています。

input_dir: Sources/${TARGET_NAME}/Resources
output_dir: Sources/${TARGET_NAME}/Generated

xcassets:
  - inputs:
      - ImageAssets.xcassets
    outputs:
      - templateName: swift5
        output: ImageAssets.swift
        params:
          forceProvidesNamespaces: true
          publicAccess: false
          enumName: ImageAssets

SwiftGenのプラグインでは、(1)実行ディレクトリと(2)各Targetのソースコードのディレクトにあるswiftgen.ymlを見つけて、コード生成を実行します。
したがって、共通で実行するものは(1)のディレクトリに、個別に適用したいものは(2)のディレクトリに配置しましょう。
ちなみに①の設定については、画像のリソースがないTargetであっても、input_dirのアセットファイルがないと、エラーになってしまうので、今回は空のアセットファイルを配置することで回避しています。
これでPackageをビルドすることで、コードを自動生成することができます。

ちなみにSwiftGenについては、さきほど記載したコマンドプラグインの機能も提供されており、コマンドラインから実行することもできます(参考)
以下のように、実行ディレクトリで実行することで、コード生成ができます。
このコマンドの場合、--configのオプションを使うことで、任意の設定ファイルを指定することができます。

$ swift package --allow-writing-to-package-directory generate-code-for-resources

注意点は、上記の①の共通で利用しているswiftgen.ymlファイルは、ビルド中にしか利用できない$(TARGET_NAME)の環境変数が記述されているので、コマンドとしては実行できません

カスタムのスクリプトを実装する

先ほど記載したように、現状のSPMのプラグインの仕様・制約により、SwiftLintなどのビルドツールプラグインには、パラメーターなどを渡すことができないので、diffのあるファイルだけLintを適用するといったことは現状できません。

そこでなんとかその処理をカスタムできないかなと試行錯誤しました
方法としては、SwiftLintのSwiftLintPlugin.swiftに記載している内容を自分で書くことで実現できました(コード)

    targets: [
        // Use
        .binaryTarget( // ①
            name: "SwiftLintBinary",
            url: "https://github.com/realm/SwiftLint/releases/download/0.50.1/SwiftLintBinary-macos.artifactbundle.zip",
            checksum: "487c57b5a39b80d64a20a2d052312c3f5ff1a4ea28e3cf5556e43c5b9a184c0c"
        ),
        .plugin( // ②
            name: "SwiftLint",
            capability: .buildTool(),
            dependencies: ["SwiftLintBinary"]
        ),
        .target(
            name: "BasePackage",
            dependencies: [
                "FunctionA",
                "FunctionB",
                "FunctionC"
            ],
            plugins: [
                "SwiftLint", // ③
                .plugin(name: "SwiftGenPlugin", package: "SwiftGenPlugin")
            ]
        ),

まずは①でLocalPackageTargetbinaryTargetとして、SwiftLintのバイナリを設定します。
そして②でそのバイナリを.pluginを使用してビルドツールプラグインとして、設定します。
プラグインの名前は今回SwiftLintとしていますが、この名前はのちに記載するプラグイン設定ファイルのディレクトリ名と一致している必要があります。
最後に③で②のプラグインをTargetに追加します。
ちなみにSPMではLocalの設定を書く場合は、dependenciesも含めて、ダブルクォーテーションで囲んでそのまま記載するようです(あまり今まで気にしていなかった。。)

次に実際のプラグインで実行する処理の内容を設定をします。(コード)

import PackagePlugin

@main
struct SwiftLintPlugin: BuildToolPlugin {
    func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] {
        // If want to run lint when specific target, use code below.
//        if target.name != "CommonPackage" { return [] }
        return [
            .buildCommand(
                displayName: "Linting \(target.name)",
                // `targeKt.directory.string` is the directory SwiftLint.swift is located.
                // Set custom swiftlint script path
                executable: Path("\(target.directory.string)/../../../Scripts/swiftlint.sh"),
                arguments: [
                    // Set `swiftlint` command path
                    try context.tool(named: "swiftlint").path,
                    // Set `.swiftlint.yml` file directory path
                    "\(target.directory.string)/../.."
                ],
                environment: [:]
            )
        ]
    }
}

上記が今回試しに作ったカスタムのスクリプトです。
注意点は先ほど書いた通りディレクトリの名前で、必ずPlugins/{Package.swiftで定義した名前}にしなければいけません
ファイル名やstruct名は任意で問題ないです。

このスクリプトは、Pathを使用してルートディレクトリに配置したScripts/swiftlint.shのファイルを実行し、その際にパラメータとして、swiftlintのコマンドのパスと.swiftlint.ymlのファイルのパスを渡しています。
またこの処理の中ではTarget名も取得できるので、特定のTargetでは実行しないといった条件分岐を書くことも可能です。

この方法を使うことで、swiftlint.shファイルでは、以下のように今までようにSwiftLintの処理を書くことができます。

$ $1 lint --no-cache --format --config $2/.swiftlint.yml

ただこの方法は最初に紹介した公式のプラグインを使用するよりも手間なので、可能ならカスタムしないで済むようにしたいところです。。

注意点

時々実装後に思った通りの挙動をしない場合があります。
その場合は、例のごとく、クリーンする、DerivedDataを消すなどしてキャッシュを消しましょう!
自分の場合は、比較的いつもよりクリーンすることが多かったです
しかしある程度小さくPackageを小さくしていれば、クリーンしてもビルド時間がそこまで長くなることはないと思います!

まとめ

今回はSPMのプロジェクトでSPMのプラグインを使って、SwiftGenやSwiftLintを実行する方法をまとめました。
今後はSPMを使って開発することも多いでしょうし、そこで今まで通り、SwiftGenやSwiftLintなどのツールを使えるようになったのは、開発体験の向上にも大きく貢献すると感じました。

参考資料

  1. Meet Swift Package plugins
  2. Swift Package Managerのプラグイン機能
  3. Swift Package ManagerでBuildToolPluginを利用する
  4. Swift Package ManagerのBuild Tools
  5. Swift packages: Resources and localization
+1

COMMENT

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

CAPTCHA