はじめに
Hello, Swift ラバーなみなさん。これからラバーになるみなさん。
iOS 開発が専門であるはずなのに、iOS 関連の記事は誠に久しぶりでございます。
長らく iOS 開発での formatter や linter はSwiftFormatとSwiftLintがデファクトスタンダードと言っていいでしょう。
ほんと、全く知らなかったのですが、Xcode 16 以降、Swift ツールチェーンの一部として Apple 謹製のswift-formatが同梱されるようになっていました。
本記事では、サードパーティツールを一切インストールせず、Xcode Build Tool Plugin として swift-format の lint をビルドに組み込む方法を紹介します。
目次
結論
SwiftFormat や SwiftLint に比べるとルール数は少なく、カスタマイズ性も低いです。また、format on save 的なことも標準機能としてはできません。 しかし、何もインストールせずに使えるという点が最大のメリットです。(SwiftFormat や SwiftLint の導入では、めちゃくちゃハマった経験がある)
チーム全員が同じ Xcode を使っていれば、formatter のバージョンは自然にそろいます。Swift ツールチェーン同梱であるため、今後の Swift バージョンアップへの追従も期待しやすいです。ルールの拡充に期待しつつ、まずは導入してみる価値があります。
動作環境
| 項目 | バージョン |
|---|---|
| Xcode | 26.2 (Build 17C52) |
| Swift | 6.2.3 |
| swift-format | 6.2.3 |
swift-format のバージョンは Swift ツールチェーンと同期しており、xcrun swift-format --version で確認できます。
なぜやりたかったか
サードパーティツールの管理コストが高い
SwiftFormat や SwiftLint を使う場合、導入やバージョン固定など、ツール自体の管理コストが発生します。プロジェクトの本質ではない部分に手間がかかるのは避けたいところです。
Swift ツールチェーン同梱であること
Apple が配布する Swift ツールチェーンに含まれる formatter である以上、Swift のアップデートへの追従は期待できます。サードパーティツールでは Swift の新構文対応が遅れることがありますが、そのリスクを抑えられる可能性があります(期待を込めて)。
チーム導入のハードルが低い
Xcode に同梱されているため、新しいメンバーが git clone してビルドするだけで lint を動かせます。追加のインストール手順が不要であることは、オンボーディングコストの削減につながります。
User Script Sandboxing の回避
Xcode 15 以降は ENABLE_USER_SCRIPT_SANDBOXING=YES が推奨設定として扱われ、新規プロジェクトや適用後は有効になっていることがあります。Run Script Phase で swift-format lint を呼ぶ方式では、入力ファイルや出力ファイルを適切に宣言していないと、サンドボックスにより lint が失敗することがあります。Build Tool Plugin を使えば、Run Script Phase の User Script Sandboxing 設定に左右されにくい形で lint を実行できます。
> NOTE:
>
> – ENABLE_USER_SCRIPT_SANDBOXING=YES は、Run Script Phase のファイルシステムアクセスなどを制限する設定
> – セキュリティとビルドの正確性向上が目的だが、入力/出力の宣言が不足している Run Script では、swift-format が参照する設定ファイルや対象ソースへのアクセスが制限され、lint が失敗することがある
> – Build Tool Plugin も SwiftPM plugin としてのサンドボックス内で動くが、Run Script Phase の設定とは別の仕組みで実行される
導入方法
全体の流れ
- プロジェクト内に plugin 用の Local Package を作成する
- Build Tool Plugin を実装する
- Xcode project の Build Phases で plugin を追加する
.swift-format設定ファイルをプロジェクトルートに配置する
本記事では紹介していませんが、もちろんリモートパッケージで管理してもよいです。
ディレクトリ構成
Plugins/FormatPlugin/
├── .swift-format.example
├── Package.swift
└── Plugins/
└── FormatBuildTool/
├── FormatBuildTool.swift
└── swift-format-lint.sh
.swift-format.example
ルール設定を記述する JSON ファイルのコピー元を作ります。
> NOTE:
>
> – .swift-format のデフォルト設定を取得する
> – swift-format dump-configuration を実行すると、デフォルト設定を JSON 形式で書き出せる
> – 1 から導入するならこれを実行して .swift-format ファイルを作成するのがよい
>
> bash > xcrun swift-format dump-configuration > .swift-format >
デフォルトからカスタマイズしたい箇所だけ変更すればよいです。version フィールドは現時点では 1 固定です。
Package.swift
plugin target のみを公開するシンプルな manifest です。外部 dependency は持ちません。
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "FormatPlugin",
// plugin 自体はホスト Mac 上で実行されるため macOS のみを明示
// アプリ用 library product も同じ package に含める場合は、必要な platforms を別途検討する
platforms: [.macOS(.v14)],
products: [
// plugin として公開する(library や executable ではない)
.plugin(name: "FormatBuildTool", targets: ["FormatBuildTool"])
],
targets: [
.plugin(
name: "FormatBuildTool",
// buildTool: ビルド時に Xcode から呼び出される plugin
capability: .buildTool()
)
]
)
FormatBuildTool.swift
plugin 本体の実装です。
import PackagePlugin
import Foundation
// XcodeProjectPlugin は .xcodeproj への適用に必要
// Swift Package target だけなら不要だが、両対応のため条件付き import する
#if canImport(XcodeProjectPlugin)
import XcodeProjectPlugin
#endif
@main
struct FormatBuildTool: BuildToolPlugin {
// Swift Package target 向けのエントリポイント
func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] {
makeBuildCommands(
projectDirectoryURL: context.package.directoryURL,
inputFiles: sourceInputFiles(for: target),
pluginWorkDirectoryURL: context.pluginWorkDirectoryURL,
targetName: target.name
)
}
private func makeBuildCommands(
projectDirectoryURL: URL,
inputFiles: [URL],
pluginWorkDirectoryURL: URL,
targetName: String
) -> [Command] {
// lint 対象ファイルがなければコマンドを返さない
guard !inputFiles.isEmpty else {
return []
}
// プロジェクトルートから親方向に .swift-format を探す
let configFile = findConfigFile(startingAt: projectDirectoryURL)
// ビルド成功を示す stamp ファイル(output file として Xcode に宣言する)
let outputFile = pluginWorkDirectoryURL.appendingPathComponent(
"\(sanitizedFileName(from: targetName))-swift-format-lint.stamp",
isDirectory: false
)
// plugin と同じディレクトリに配置した lint 実行スクリプト
let helperScript = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.appendingPathComponent("swift-format-lint.sh", isDirectory: false)
return [
.buildCommand(
displayName: "Swift Format Linting",
executable: URL(fileURLWithPath: "/bin/sh"),
// 引数: スクリプト, 設定ファイル, stamp ファイル, lint 対象ソース...
arguments: [helperScript.path, configFile.path, outputFile.path]
+ inputFiles.map(\.path),
environment: [:],
inputFiles: [helperScript, configFile] + inputFiles,
// output file を宣言することで Xcode がビルド依存関係を正しく解決する
outputFiles: [outputFile]
)
]
}
// ...
}
設計上のポイントは以下のとおりです。
-
BuildToolPluginとXcodeBuildToolPluginの両方に準拠するSwift Package target 向けの
BuildToolPluginと、.xcodeproj向けのXcodeBuildToolPluginの両方を実装しています。XcodeBuildToolPluginがないと、Xcode project に plugin を適用した際にPlugin doesn't support Xcode projectsで失敗します。```swift #if canImport(XcodeProjectPlugin) extension FormatBuildTool: XcodeBuildToolPlugin { func createBuildCommands(context: XcodePluginContext, target: XcodeTarget) throws -> [Command] { makeBuildCommands( projectDirectoryURL: context.xcodeProject.directoryURL, inputFiles: target.inputFiles .filter { $0.type == .source } .map(\.url), pluginWorkDirectoryURL: context.pluginWorkDirectoryURL, targetName: target.displayName ) } } #endif ``` -
prebuildCommandではなくbuildCommandを使うprebuildCommandは個別の output file ではなく output directory を宣言する仕組みです。今回の lint は入力ファイルと stamp file を事前に決められるため、buildCommandで output file(stamp file)を宣言し、Xcode のビルド依存関係に載せる構成にしています。 -
.swift-formatを親ディレクトリまで探索するXcode plugin の
projectDirectoryURLは.xcodeprojの位置と完全には一致しないことがあります。そのため開始ディレクトリから親方向にさかのぼって.swift-formatを探します。
private func findConfigFile(startingAt directoryURL: URL) -> URL {
let fileManager = FileManager.default
var currentDirectoryURL = directoryURL.standardizedFileURL
while true {
let candidate = currentDirectoryURL.appendingPathComponent(".swift-format", isDirectory: false)
if fileManager.fileExists(atPath: candidate.path) {
return candidate
}
let parentDirectoryURL = currentDirectoryURL.deletingLastPathComponent()
if parentDirectoryURL.path == currentDirectoryURL.path {
return candidate
}
currentDirectoryURL = parentDirectoryURL
}
}
全文は以下です。
import PackagePlugin
import Foundation
#if canImport(XcodeProjectPlugin)
import XcodeProjectPlugin
#endif
@main
struct FormatBuildTool: BuildToolPlugin {
func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] {
makeBuildCommands(
projectDirectoryURL: context.package.directoryURL,
inputFiles: sourceInputFiles(for: target),
pluginWorkDirectoryURL: context.pluginWorkDirectoryURL,
targetName: target.name
)
}
private func makeBuildCommands(
projectDirectoryURL: URL,
inputFiles: [URL],
pluginWorkDirectoryURL: URL,
targetName: String
) -> [Command] {
guard !inputFiles.isEmpty else {
return []
}
let configFile = findConfigFile(startingAt: projectDirectoryURL)
let outputFile = pluginWorkDirectoryURL.appendingPathComponent(
"\(sanitizedFileName(from: targetName))-swift-format-lint.stamp",
isDirectory: false
)
let helperScript = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.appendingPathComponent("swift-format-lint.sh", isDirectory: false)
return [
.buildCommand(
displayName: "Swift Format Linting",
executable: URL(fileURLWithPath: "/bin/sh"),
arguments: [helperScript.path, configFile.path, outputFile.path]
+ inputFiles.map(\.path),
environment: [:],
inputFiles: [helperScript, configFile] + inputFiles,
outputFiles: [outputFile]
)
]
}
private func sourceInputFiles(for target: Target) -> [URL] {
guard let sourceModuleTarget = target as? SourceModuleTarget else {
return []
}
return sourceModuleTarget.sourceFiles
.filter { $0.type == .source }
.map(\.url)
}
private func findConfigFile(startingAt directoryURL: URL) -> URL {
let fileManager = FileManager.default
var currentDirectoryURL = directoryURL.standardizedFileURL
while true {
let candidate = currentDirectoryURL.appendingPathComponent(".swift-format", isDirectory: false)
if fileManager.fileExists(atPath: candidate.path) {
return candidate
}
let parentDirectoryURL = currentDirectoryURL.deletingLastPathComponent()
if parentDirectoryURL.path == currentDirectoryURL.path {
return candidate
}
currentDirectoryURL = parentDirectoryURL
}
}
private func sanitizedFileName(from name: String) -> String {
name.unicodeScalars
.map { CharacterSet.alphanumerics.contains($0) ? String($0) : "_" }
.joined()
}
}
#if canImport(XcodeProjectPlugin)
extension FormatBuildTool: XcodeBuildToolPlugin {
func createBuildCommands(context: XcodePluginContext, target: XcodeTarget) throws -> [Command] {
makeBuildCommands(
projectDirectoryURL: context.xcodeProject.directoryURL,
inputFiles: target.inputFiles
.filter { $0.type == .source }
.map(\.url),
pluginWorkDirectoryURL: context.pluginWorkDirectoryURL,
targetName: target.displayName
)
}
}
#endif
swift-format-lint.sh
実際の lint 実行を担うシェルスクリプトです。Xcode が生成する script phase の中で長い引数列を直接埋め込むと引数展開が壊れやすいため、処理を分離しています。
#!/bin/sh
set -eu
config="$1"
stamp="$2"
shift 2
/usr/bin/xcrun swift-format lint --configuration "$config" "$@"
/usr/bin/touch "$stamp"
lint コマンド自体が成功した場合のみ stamp file を touch し、Xcode のビルド依存関係を満たします。
swift-format lint は通常、フォーマット違反を warning として出力し、終了コードは 0 のままです。フォーマット違反でビルドを失敗させたい場合は、--strict を追加します。
/usr/bin/xcrun swift-format lint --strict --configuration "$config" "$@"
構文エラーや設定ファイルの問題などで lint コマンドが失敗した場合は、set -eu により即座に終了し、ビルドエラーとして報告されます。
Xcode project への適用
- Xcode で
Plugins/FormatPluginを Local Package として追加する - 対象 target の Build Phases を開く
- Run Build Tool Plug-ins に
FormatBuildToolを追加する .swift-format.exampleをプロジェクトルートにコピーして.swift-formatにリネームする- 初回ビルド時に plugin の信頼確認が出た場合は許可する
設定パラメータ(抜粋)
.swift-format は JSON 形式の設定ファイルです。以下に主要なパラメータを抜粋して紹介します。
基本設定
{
"indentation": {
"spaces": 4
},
"lineLength": 100,
"maximumBlankLines": 2,
"respectsExistingLineBreaks": true,
"multiElementCollectionTrailingCommas": true
}
| パラメータ | 説明 | 補足 |
|---|---|---|
indentation |
インデント幅(spaces または tabs で指定) | |
lineLength |
1 行の最大文字数 | |
maximumBlankLines |
連続する空行の上限 | |
respectsExistingLineBreaks |
既存の改行を尊重するかどうか | false にすると既存の手動改行が崩れやすいので true がいい |
multiElementCollectionTrailingCommas |
複数要素のコレクションリテラルに末尾カンマを付けるか |
ルール設定
rules オブジェクトで個々の lint ルールの有効/無効を切り替えます。
デフォルトで無効になっているルールの例:
| ルール | 無効になっている理由の推測 | 補足 |
|---|---|---|
NeverForceUnwrap |
安全な場面での forced unwrapping まで警告するのは過剰 | |
UseEarlyExits |
guard の強制が読みやすさを損なう場面がある | |
AllPublicDeclarationsHaveDocumentation |
public API すべてにドキュメントを書くのは過剰になる場合がある | モジュール分けとかされているならありかも? |
OmitExplicitReturns |
return の省略を強制すると可読性が下がるケースがある | 個人的には return を付けたい |
診断レベル
swift-format の設定ファイルには、現状 lint 違反を warning / error に切り替える設定項目はありません。
swift-format lint の通常の指摘は warning として表示されます。ビルドエラーとして扱いたい場合は、lint 実行時に --strict を付けます。
自分がデフォルトから変更したパラメータ
xcrun swift-format dump-configuration で出力されるデフォルト値から変更している箇所は以下の 4 点です。
| パラメータ | 意味 | デフォルト | プロジェクト設定 | 変更理由 |
|---|---|---|---|---|
indentation.spaces |
インデントのスペース数 | 2 |
4 |
Swift は通常 4 だと思うんだが、なぜかデフォルト設定だと 2 になっている |
indentConditionalCompilationBlocks |
#if / #endif ブロック内のコードをインデントするか |
true |
false |
#if ブロック内のインデントが深くなるのを防ぐ。swift-format 以前の Xcode(⌃I)のフォーマットでは #if ブロック内のコードは 0 列目に移動していた。false にすると、周りのインデント位置との関係を維持できる |
maximumBlankLines |
連続して許容する空行の最大数 | 1 |
2 |
個人的に複数行空行をいれたい |
rules.UseTripleSlashForDocumentationComments |
/** */ コメントを /// に変換するか |
true |
false |
自分的に /** */ と /// で意味合いが違うので |
動かし方
Xcode でのビルド
通常どおり Xcode でビルドするだけで、Build Phases に追加した FormatBuildTool が自動的に lint を実行します。lint 違反は Xcode の警告として表示されます。--strict を付けた場合はエラーとして表示されます。
フォーマットの実行
対象ファイルを開いた状態で Control + Shift + I を押すと、swift-format による自動整形が実行されます。この割り当ては Xcode の Format File with 'swift-format' に対応しており、各メンバーのローカル設定で有効にする必要があります(自分の環境ではデフォルトで設定されていた)。
xcodebuild での CLI 確認
CI やローカルで CLI から確認したい場合は以下のコマンドを使います。
xcodebuild \
-project .xcodeproj \
-scheme Production \
-sdk iphonesimulator \
-destination 'generic/platform=iOS Simulator' \
build
ビルドログの中に Swift Format Linting というフェーズが表示されていれば、plugin が正しく動作しています。
SwiftFormat / SwiftLint との比較
| 観点 | swift-format(Xcode 同梱) | SwiftFormat(nicklockwood) | SwiftLint |
|---|---|---|---|
| インストール | 不要(Xcode 同梱) | brew install / SPM | brew install / SPM |
| ルール数 | 43(Swift 6.2.3 時点) | 50 以上 | 200 以上 |
| カスタマイズ性 | JSON で有効/無効の切り替え | 多数のオプション設定 | YAML で詳細設定 |
| カスタムルール | CLI 設定では不可 | CLI 設定では不可 | 正規表現で追加可能 |
| フォーマット機能 | あり | あり | autocorrect で一部対応 |
| Xcode 統合 | Build Tool Plugin / Control + Shift + I | Run Script Phase / Xcode Extension | Run Script Phase / Build Tool Plugin |
| CI 対応 | xcrun で呼び出し可能 | 別途インストールまたは SPM 管理が必要 | 別途インストールまたは SPM 管理が必要 |
| User Script Sandboxing | Run Script Phase の設定には左右されにくい | Run Script では入力/出力宣言または設定調整が必要になることがある | Run Script では入力/出力宣言または設定調整が必要になることがある |
| Swift 新構文への追従 | ツールチェーン同梱のため早い可能性が高い | コミュニティ依存 | コミュニティ依存 |
swift-format はルール数では劣りますが、メリットも多いです。
- 何もインストールしなくてよい
- Run Script Phase の User Script Sandboxing 設定を避けやすい
- Xcode に含まれる Swift ツールチェーンのバージョンに追従する
まとめ
Xcode 同梱の swift-format を Build Tool Plugin として導入する方法を紹介しました。
パラメータはデフォルト設定のまま使い始めると、既存コードに対して大量の警告が出ます。最初に .swift-format.example をベースに、プロジェクトの既存スタイルに合わせてルールの有効/無効を調整するのがいいかもしれません。
また、ちゃんとブランチを切って、一気に format をかけてしまうのもありです!
SwiftFormat や SwiftLint に比べるとルール数は少ないですが、何もインストールせずに使えるのが最大の強みです。チーム全員が同じ Xcode を使っていれば formatter のバージョンは自動的にそろいます!
Swift ツールチェーン同梱のツールである以上、Swift の進化とともにルールが拡充されていくことを期待しています。
それでは良きエンジニアライフを!!
