🛠️

Multi-module 環境に swift-format を Command Plugin と Build Plugin で導入する

2023/12/03に公開

swift-format とは

apple/swift-format は、Apple が OSS で公開してる Swift コードのソースファイルを整形するためのツールです。このツールは、コードの書式をSwiftのスタイルガイドラインに準拠するように自動的に調整します。これにより、コードの一貫性が保たれ、読みやすくなります。また、スタイルの設定はカスタマイズ可能で、プロジェクトやチームの特定のニーズに合わせることができます。

サンプルプロジェクト

本稿の内容を実装したサンプルです。すでに1つ lint 違反がある状態でコミットしてあるので、そのままビルドやコマンドを実行すると swift-format の結果が確認できます。

https://github.com/yusuga/AppleSwiftFormatPluginExample

Command Plugin の実行結果

$ make lint
🛠️ lint
Building for debugging...
Build complete! (1.14s)
Sources/Model/ExampleModel.swift:12:1: warning: [Indentation] unindent by 2 spaces
error: swift-format invocation failed: NSTaskTerminationReason(rawValue: 1):1
make: *** [lint] Error 1

Build Plugin の実行結果

Command Plugin で導入する

Command Plugin は、Xcode の新しい機能の一つで、カスタムコマンドやツールを Xcode のワークフローに統合することができます。これにより、開発者は Xcode 内で直接カスタムスクリプトやツールを実行することが可能になり、開発プロセスをより効率的かつカスタマイズ可能にします。

例えば、開発者は Command Plugin を使用して、コードのフォーマット、文書の生成、依存関係の管理などのタスクを自動化することができます。これらのプラグインは Swift で記述され、Xcode のメニューやコマンドラインで実行することができます。

swift-format は Command Plugin をサポートしているため簡単に導入可能です。

1. Package.swift の dependencies に package を追加

let package = Package(
  // ...
  dependencies: [
    .package(url: "git@github.com:apple/swift-format.git", from: "509.0.0"),
  ]
  // ...
}

2. 実行可能な Command Plugin を swift package plugin --list で確認

dependencies の package に追加して実行可能になった Command Plugin は swift package plugin --list で確認できます。

$ swift package plugin --list
‘format-source-code’ (plugin ‘Format Source Code’ in package ‘swift-format’)
‘generate-manual’ (plugin ‘GenerateManual’ in package ‘swift-argument-parser’)
‘lint-source-code’ (plugin ‘Lint Source Code’ in package ‘swift-format’)

3. swift-format lint を実行する

Command Plugin は swift package plugin 経由で実行できます。swift-format lintlint-source-code という Command Plugin で実装されているため、 swift package plugin lint-source-code で実行します。

$ swift package plugin lint-source-code
Building for debugging...
Build complete! (1.23s)
Sources/...

4. swift-format dump-configuration を実行して .swift-format ファイルを生成

swift-format の設定は .swift-format ファイルを参照します。swift-format dump-configuration でデフォルトの設定を出力できます。Swift plugin 経由で swift-format を導入した場合は swift package plugin 経由では swift-format を実行できないのですが、前述の swift package plugin lint-source-code を実行した時点で .build ディレクトリに swift-format の実行可能ファイルも生成されているためそれを使用して出力します。

swift-format の実行ファイルが生成された場所は swift build --show-bin-path でディレクトリが特定できます。

$ swift build --show-bin-path
/Users/yusuga/Developer/AppDirectory/.build/arm64-apple-macosx/debug

swift build --show-bin-path を組み合わせて swift-format dump-configuration を実行します。

$ $(swift build --show-bin-path)/swift-format dump-configuration
{
  "fileScopedDeclarationPrivacy" : {
    "accessLevel" : "private"
  },
  ...

swift-format dump-configuration をリダイレクトして .swift-format ファイルとして書き出します。swift-format の設定は .swift-format を参照して決定されます。

$ $(swift build --show-bin-path)/swift-format dump-configuration > .swift-format

5. swift-format format を実行

Swift Plugin 経由で swift-format formatswift package plugin format-source-code で実行できます。

$ swift package plugin format-source-code
Plugin ‘Format Source Code’ wants permission to write to the package directory.
Stated reason: “This command formats the Swift source files”.
Allow this plugin to write to the package directory? (yes/no) yes
Building for debugging...
Build complete! (1.31s)
Formatted the source code.

実行途中で Allow this plugin to write to the package directory? (yes/no) と plugin がファイルへの書き込み変更を行なっていいかを確認されます。これは swift package plugin--allow-writing-to-package-directory を追加することで省略できます。

$ swift package plugin --allow-writing-to-package-directory format-source-code
Building for debugging...
Build complete! (1.31s)
Formatted the source code.

Build Plugin で導入する

Build Plugin は、Xcode の機能の一つで、Xcode のビルドプロセスにカスタムのビルドステップや操作を組み込むことができるプラグインです。これにより、開発者はビルドプロセスを拡張し、特定のタスクを自動化することが可能になります。

例えば、Build Plugin を使用して、ビルド時にコードの生成、リソースの最適化、静的解析ツールの実行などのカスタム処理を行うことができます。これらのプラグインは Swift で書かれ、Xcodeのビルドプロセスにシームレスに統合されます。ビルドプラグインは、ビルド時に特定のフェーズで実行され、ビルド環境やプロジェクトの設定に基づいて動作をカスタマイズすることができます。

ただし、swift-format は 509.0.0 の時点では Build Plugin をサポートしていません。そこで自前で Build Plugin をサポートして、ビルド時に swift-format lint を実行して Xcode 上で Warning を出してみます。

1. SwiftFormatLintPlugin を実装する

次のように Plugins/SwiftFormatLintPlugin/plugin.swift を実装します。createBuildCommands(context:target:) 内で swift-format lint が実行されるように実装します。

import Foundation
import PackagePlugin

private let pluginName = "swift-format"
private let targetFileExtension = "swift"

@main
struct SwiftFormatLintPlugin: BuildToolPlugin {

  func createBuildCommands(context: PluginContext, target: Target) throws -> [Command] {
    guard let sourceTarget = target as? SourceModuleTarget else {
      return []
    }

    return createBuildCommands(
      inputFiles: sourceTarget
        .sourceFiles(withSuffix: targetFileExtension)
        .map(\.path),
      tool: try context.tool(named: pluginName)
    )
  }
}

#if canImport(XcodeProjectPlugin)
import XcodeProjectPlugin

extension SwiftFormatLintPlugin: XcodeBuildToolPlugin {

  func createBuildCommands(context: XcodePluginContext, target: XcodeTarget) throws -> [Command] {
    return createBuildCommands(
      inputFiles: target.inputFiles
        .filter { $0.type == .source && $0.path.extension == targetFileExtension }
        .map(\.path),
      tool: try context.tool(named: pluginName)
    )
  }
}
#endif

private extension SwiftFormatLintPlugin {

  func createBuildCommands(
    inputFiles: [Path],
    tool: PluginContext.Tool
  ) -> [Command] {
    [
      .buildCommand(
        displayName: pluginName,
        executable: tool.path,
        arguments: [
          "lint",
          "--parallel",
          "--recursive",
        ] + inputFiles.map(\.string)
      )
    ]
  }
}

2. artifactbundle を生成する

Build Plugin には binaryTarget を指定する必要があります(ですよね?)。binaryTarget には XCFramework または artifactbundle(またはそれらの zip)を指定可能です。今回は artifactbundle を生成して指定します。

artifactbundle は次の構成を持つディレクトリです。

swift-format.artifactbundle
├── info.json
└── swift-format-509.0.0-macos
  └── bin
    └── swift-format

info.json は次の構造です。

{
  "schemaVersion": "1.0",
  "artifacts": {
    "swift-format": {
      "version": "509.0.0",
      "type": "executable",
      "variants": [
        {
          "path": "swift-format-509.0.0-macos/bin/swift-format",
          "supportedTriples": [
            "x86_64-apple-macosx", 
            "arm64-apple-macosx"
          ]
        }
      ]
    }
  }
}

swift-format-509.0.0-macos/bin/swift-format には $(swift build --show-bin-path)/swift-format に生成されている swift-format の実行ファイルを置きます。

3. Package.swift に Build Plugin を追加

Package.swift の targets に .plugin.binaryTarget を追加します。binaryTarget の path には前項で生成した swift-format.artifactbundle のローカルパスを指定します。

let package = Package(
  // ...
  targets: [
    .plugin(
      name: "SwiftFormatLintPlugin",
      capability: .buildTool(),
      dependencies: [
        "SwiftFormatBinary"
      ]
    ),
    .binaryTarget(
      name: "SwiftFormatBinary",
      path: "./artifactbundle/swift-format.artifactbundle"
    ),
  // ...

あとは Build Plugin を実行したい target に plugins を指定するだけでビルド時に swift-format lint が実行されます。

let package = Package(
  // ...
  targets: [
    // ...
    .target(
      name: "Model",
      plugins: [
        "SwiftFormatLintPlugin",
      ]
    ),
    // ...
}

4. artifactbundle の生成をスクリプトで行う

artifactbundle の生成を手作業でやるのは非常に煩雑なため次の手順をスクリプトで実行します。

  1. swift run swift-format -v で swift-format の実行ファイルを生成する
  2. artifactbundle 内に swift-format の実行ファイルをコピーする

ただし、ここで問題となるのが 1 の swift run swift-format -v の時点で binaryTarget で指定している swift-format.artifactbundle が必要になってしまう点です。swift-format の実行ファイルを生成するために swift-format の実行ファイルが必要という、卵が先か、ニワトリが先かという状況になっています。

そこでそれを回避するためにまずはダミーの swift-format.artifactbundle を生成して、初回の swift run swift-format -v 中に実行されている swift build を成功させたあとに正規の swift-format.artifactbundle を生成します。

完成した make コマンドは次の通りです。

MAKEFILE_DIR := $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST))))
ARTIFACT_BUNDLE_PATH := $(MAKEFILE_DIR)/artifactbundle
SWIFT_FORMAT_ARTIFACT_BUNDLE_PATH := $(ARTIFACT_BUNDLE_PATH)/swift-format.artifactbundle
SWIFT_BUILD := swift build --package-path $(MAKEFILE_DIR)
SWIFT_RUN := swift run --package-path $(MAKEFILE_DIR)

.PHONY: swift_format_artifactbundle
swift_format_artifactbundle:
	@echo "🛠️ $@"
	@## Workaround:
	@## SwiftFormatBinary が参照している `swift-format.artifactbundle` を生成する前に
	@## `swift build` を動かすために、ダミーの `swift-format.artifactbundle` を生成する。
	$(MAKE) _swift_format_artifactbundle_configuration LIBRARY=swift-format VERSION=0.0.1
	touch $(SWIFT_FORMAT_ARTIFACT_BUNDLE_PATH)/swift-format-0.0.1-macos/bin/swift-format
	
	@## 正しい `swift-format.artifactbundle` を生成する。
	@SWIFT_FORMAT_VERSION=$$($(SWIFT_RUN) swift-format -v); \
	$(MAKE) _swift_format_artifactbundle_configuration LIBRARY=swift-format VERSION=$$SWIFT_FORMAT_VERSION; \
	cp $(shell $(SWIFT_BUILD) --show-bin-path)/swift-format \
	  $(SWIFT_FORMAT_ARTIFACT_BUNDLE_PATH)/swift-format-$$SWIFT_FORMAT_VERSION-macos/bin/swift-format
	@echo "✅ $@"

.PHONY: _swift_format_artifactbundle_configuration
_swift_format_artifactbundle_configuration:
ifndef LIBRARY
	$(error "The argument 'LIBRARY' is missing. e.g. `make _swift_format_artifactbundle_dir LIBRARY=swift-format`")
endif
ifndef VERSION
	$(error "The argument 'VERSION' is missing. e.g. `make _swift_format_artifactbundle_dir VERSION=0.0.1`")
endif
	mkdir -p $(SWIFT_FORMAT_ARTIFACT_BUNDLE_PATH)/$(LIBRARY)-$(VERSION)-macos/bin
	sed -e 's/__VERSION__/$(VERSION)/g' \
	  -e 's/__LIBRARY__/$(LIBRARY)/g' \
	  $(ARTIFACT_BUNDLE_PATH)/info.json.template > $(SWIFT_FORMAT_ARTIFACT_BUNDLE_PATH)/info.json

info.json.template を元に sed で文字列を入れ替えることで意図したライブラリ名とバージョンが指定された info.json を生成しています。このアイデアは SwiftLint の実装を参考にしました。

{
  "schemaVersion": "1.0",
  "artifacts": {
    "__LIBRARY__": {
      "version": "__VERSION__",
      "type": "executable",
      "variants": [
        {
          "path": "__LIBRARY__-__VERSION__-macos/bin/__LIBRARY__",
          "supportedTriples": [
            "x86_64-apple-macosx", 
            "arm64-apple-macosx"
          ]
        }
      ]
    }
  }
}

5. Xcode Cloud 対応する

swift-format の Build Plugin を Xcode Cloud で動かす場合には ci_post_clone.shmake swift_format_artifactbundle を実行するようにします。

#!/bin/sh

# `-C` で makefile が置いてあるディレクトリを指定していますが、
# これはそれぞれの環境で適宜正しいパスに変更してください。
make -C ../.. swift_format_artifactbundle

これで lint 違反があった場合に Xcode Cloud 上の実行結果で Warnings を確認することができます。

6. Xcode Cloud 上のみ lint 違反をビルドエラーにする

Xcode Cloud 上でビルドされているかは ProcessInfo.processInfo.environment["CI_XCODE_CLOUD"] == "TRUE" で判定できます。次のように Xcode Cloud 上でビルドされた場合には --struct オプションをつけることによって lint 違反をしてる場合は Xcode Cloud の Action をエラーにすることができます。

private extension SwiftFormatLintPlugin {

  func createBuildCommands(
    inputFiles: [Path],
    tool: PluginContext.Tool
  ) -> [Command] {
    var arguments = [
      "lint",
      "--parallel",
      "--recursive",
    ]

    if ProcessInfo.processInfo.environment["CI_XCODE_CLOUD"] == "TRUE" {
      arguments.append("--strict")
    }

    return [
      .buildCommand(
        displayName: pluginName,
        executable: tool.path,
        arguments: arguments + inputFiles.map(\.string)
      )
    ]
  }
}

残課題

Multi-module 環境ですと、Multi-module 外のコードに対してどうやって lint や format を適用するかを課題に感じています(--allow-writing-to-directory はありますが)。別の発想で swift-format の実行ファイルはあるので、それを使えばできなくもないのですが、可能であれば Plugin を直接使用したいなと考えています。基本的にはビジネスロジックはすべて Multi-module に実装をしていくのですが、やはり多少は App 側にも書くことになるので悩ましいです。この構成でもう少し運用してみてどうするかを考えていきたいと思います。

参考

artifactbundle について

Build Plugin について

Discussion