Multi-module 環境に swift-format を Command Plugin と Build Plugin で導入する
swift-format とは
apple/swift-format は、Apple が OSS で公開してる Swift コードのソースファイルを整形するためのツールです。このツールは、コードの書式をSwiftのスタイルガイドラインに準拠するように自動的に調整します。これにより、コードの一貫性が保たれ、読みやすくなります。また、スタイルの設定はカスタマイズ可能で、プロジェクトやチームの特定のニーズに合わせることができます。
サンプルプロジェクト
本稿の内容を実装したサンプルです。すでに1つ lint 違反がある状態でコミットしてあるので、そのままビルドやコマンドを実行すると swift-format の結果が確認できます。
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"),
]
// ...
}
swift package plugin --list
で確認
2. 実行可能な Command Plugin を 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’)
swift-format lint
を実行する
3. Command Plugin は swift package plugin
経由で実行できます。swift-format lint
は lint-source-code
という Command Plugin で実装されているため、 swift package plugin lint-source-code
で実行します。
$ swift package plugin lint-source-code
Building for debugging...
Build complete! (1.23s)
Sources/...
swift-format dump-configuration
を実行して .swift-format
ファイルを生成
4. 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
swift-format format
を実行
5. Swift Plugin 経由で swift-format format
を swift 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 の生成を手作業でやるのは非常に煩雑なため次の手順をスクリプトで実行します。
-
swift run swift-format -v
で swift-format の実行ファイルを生成する - 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.sh
で make 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 について
- Introduction to SPM artifact bundles
- Collecting SwiftPM plugin ideas for the server (but not only) ecosystem
- cybozu/LicenseList / issues/Artifact Bundleをリリースに含めてほしい #4
Build Plugin について
Discussion