はじめに

Hello, Swift ラバーなみなさん。これからラバーになるみなさん。

iOS 開発が専門であるはずなのに、iOS 関連の記事は誠に久しぶりでございます。

長らく iOS 開発での formatter や linter はSwiftFormatSwiftLintがデファクトスタンダードと言っていいでしょう。

ほんと、全く知らなかったのですが、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 の設定とは別の仕組みで実行される


導入方法

全体の流れ

  1. プロジェクト内に plugin 用の Local Package を作成する
  2. Build Tool Plugin を実装する
  3. Xcode project の Build Phases で plugin を追加する
  4. .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]
            )
        ]
    }
    // ...
}

設計上のポイントは以下のとおりです。

  1. BuildToolPluginXcodeBuildToolPlugin の両方に準拠する

    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
     ```
    
  2. prebuildCommand ではなく buildCommand を使う

    prebuildCommand は個別の output file ではなく output directory を宣言する仕組みです。今回の lint は入力ファイルと stamp file を事前に決められるため、buildCommand で output file(stamp file)を宣言し、Xcode のビルド依存関係に載せる構成にしています。

  3. .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 への適用

  1. Xcode で Plugins/FormatPlugin を Local Package として追加する
  2. 対象 target の Build Phases を開く
  3. Run Build Tool Plug-ins に FormatBuildTool を追加する
  4. .swift-format.example をプロジェクトルートにコピーして .swift-format にリネームする
  5. 初回ビルド時に 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 の進化とともにルールが拡充されていくことを期待しています。

それでは良きエンジニアライフを!!



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる