Mirrativ Tech Blog

株式会社ミラティブの開発者(バックエンド,iOS,Android,Unity,機械学習,インフラ, etc.)によるブログです

SwiftFormatを導入してコード記法を統一化

ミラティブでiOS開発をしている福山(@fokotate)です。 今回はSwiftFormatをMirrativのiOSプロジェクト(約1500のSwiftファイル)へ導入したときのことを話します。

導入にあたって

私は当初、SwiftFormatについてよく知らなかったため導入にはあまり乗り気ではありませんでした。 しかし調べてみると、実行タイミングによってはチームにとってほぼストレスなくソースコードを綺麗に保てることがわかってきました。

コミット実行時にSwiftFormatがコードを変更してコミットを中断、その変更を取り入れて再度コミットするといった一手間だけです。

導入したい気持ちが高まってきたものの、いきなり新しいツールを持ち込むのはチームから反発も受けそうだったので、Slack上で様子をみたり、ドラフトPRを書いたり、勉強会を開いて徐々に受け入れられる状況を作りました (実際は皆やさしいので心理的安全性⭕️)

ちなみにSwiftファイルのフォーマット機能を提供するものはSwiftFormatの他にも SwiftLint やAppleの提供している swift-format があります。Appleのswift-formatはルールが少なくドキュメントも乏しいので不採用にしました。SwiftLintは既に警告表示の目的で使用しており、SwiftFormatのフォーマット機能と組み合わせることで足りない部分を補い合えると考えています。

SwiftFormatとは

SwiftFormatがなんなのかわからない人向けに少し極端な例を載せます。

SwiftFormat例
左のコードは動作こそしますが、重複したimport、不要なセミコロン、崩れたインデントやスペースがあります。右のコードはSwiftFormatをかけたものです。 SwiftFormat v0.49.11時点では82のルールとそれらに対応するオプションがあり、カスタマイズ可能です。

導入概要

SwiftFormatの導入には様々な方法がありますが、Mirrativ iOSでは次の構成にしました。

  • SwiftFormatコマンド入手方法: Swift Package Manager
  • SwiftFormat実行タイミング: コミット時

ファイル配置

プロジェクトルート
├── BuildTools # swiftformatコマンド用 Swift Package Manager
│   ├── Package.swift
│   └── ...
├── GitHooks # .git/hooksの代わりにgitに読み込ませるフォルダ
│   └── pre-commit # gitコミットの前に実行されるスクリプト
├── .swiftformat # 全体をカバーするSwiftFormat設定ファイル
└── ...

実行環境

  • Xcode 13.4
  • Swift 5.6
  • SwiftFormat 0.49.11

Swift Package ManagerでSwiftFormatを導入

SwiftFormatはHomeBrewを使ってもインストール可能ですが、バージョンの指定ができないのでSwift Package Managerでの導入を選びました。

まずは既存のプロジェクトにPackage追加します。 File > New > Package...

Packageを追加

次のような配置にします。

Package配置を設定

BuildTools内のPackage.swiftを次のように編集すると、Xcodeが自動でSwiftFormatをインストールしてくれます。

// swift-tools-version: 5.6
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "BuildTools",
    platforms: [.macOS(.v10_11)],
    dependencies: [
        .package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.49.11")
    ],
    targets: [
        .target(
            name: "BuildTools",
            dependencies: []
        ),
        .testTarget(
            name: "BuildToolsTests",
            dependencies: ["BuildTools"]
        )
    ]
)

これでSwiftFormatのインストールは完了しました。

もし手動で実行する場合はプロジェクトのルートディレクトリで次のようにできます。

swift run --package-path BuildTools swiftformat .   # 現在のディレクトリを対象
swift run --package-path BuildTools swiftformat sample.swift # 特定のファイルを対象

コミット前実行スクリプト

ビルド時にSwiftFormatを走らせることも可能ですが、ファイルに変更が生じた場合にXcodeのundo履歴がふっとび⌘zが効かなくなるので避けました。

gitコミット時にステージングされた変更のみにSwiftFormatがかかるようにし、コミットの合否を決めるよう工夫すれば、開発者にストレスがかかりません。

それを実現するpre-commitというコマンドがありますが今回は使わず、同様のことをシェルスクリプトで実現しました (シェルスクリプトは苦手なのでご指摘歓迎です!)

自前のスクリプトの方がgitで管理しやすく、ログの出力などもコントロール可能で、Swift Package ManagerでインストールしたSwiftFormatとの相性が良いと考えています。

手順

  1. 以下のGitHooks/pre-commitファイルを作成
  2. 実行権を与える $ chmod +x GitHooks/pre-commit
  3. gitの設定をする $ git config --local core.hooksPath GitHooks

GitHooks/pre-commit

#!/usr/bin/env sh

# SwiftFormat
command="swift run --package-path BuildTools swiftformat"

color_red () {
  ESC=$(printf '\033')
  printf "$ESC[31m$1$ESC[m"
}

command -v $command 1>/dev/null 2>&1
if [ $? -gt 0 ]; then
  echo $(color_red "😓 swiftformat コマンドが見つかりません。")
  exit 1
fi

should_fail=false
staged_swift_files=`git diff --diff-filter=d --staged --name-only | grep -e '\(.*\).swift$'`
if [ -z "$staged_swift_files" ]; then
  # no swift file found
  exit 0
fi

while read file; do
  # stagingされていない変更のパッチを作成
  unstaged_patch=$(git diff "$file")
  if [ ! -z "$unstaged_patch" ]; then
    # stagingされていない変更があれば削除
    git restore $file
  fi
  # stagingの変更部分のみSwiftFormatでチェック (変更は起こらない)
  echo "👁 SwiftFormat: 確認中... $file"
  $command --lint $file
  if [ $? -eq 0 ]; then
    # no error
    if [ ! -z "$unstaged_patch" ]; then
      # パッチがあれば適用して戻す
      echo "$unstaged_patch" | git apply --whitespace=nowarn
    fi
    printf "\n"
    continue
  fi
  
  should_fail=true
  if [ ! -z "$unstaged_patch" ]; then
    # パッチがあれば適用して戻す
    echo "$unstaged_patch" | git apply --whitespace=nowarn
  fi
  # SwiftFormatを適用 (変更が起こる)
  echo "🪬 SwiftFormat: 適用中... $file"
  $command $file
  printf "\n"
done <<< "$staged_swift_files"
# ↑doneの後ろから読み込ませる方法でなければ、should_fail変数の変更が反映されない

if "$should_fail" ; then
  echo $(color_red "⛔️ SwiftFormatによってコードに変更が生じました。")
  echo "変更を取り入れるなどの対応をお願いします。"
  echo "🔗 wikiはこちら - https://..."
  exit 1
fi

ステージングの変更がSwiftFormatに合わず、コミットが弾かれた時のログ。(ログの出し方はもっと工夫が必要かもしれません...)

コミットが弾かれた時のログ

SwiftFormat設定ファイル

プロジェクトのルートディレクトリにSwiftFormat設定ファイル .swiftformat を置いています。 .swiftformatにはルールを全て書き込み有効/無効を選んでいます。

SwiftFormatルール一覧

ルールの選別にあたっては、既存のプロジェクトへの影響度を一つひとつ検証した後、チームの勉強会で各ルールを見ていき決定しました。

.swiftformat

# Rule List - https://github.com/nicklockwood/SwiftFormat/blob/master/Rules.md

--swiftversion 5.6

# file options 除外するディレクトリやファイル

--exclude Pods
--exclude Carthage
--exclude BuildTools

# format options

--commas inline # trailingCommas 有効時 複数行の配列や辞書などで、最後のカンマを無効化
--ranges no-space # spaceAroundOperators 有効時 例: "0...9" を "0 ... 9" としない

# Default Rules (enabled by default) デフォルトで有効なルール
# コメントアウト部分が有効

#--disable andOperator
#--disable anyObjectProtocol
#--disable assertionFailures
#--disable blankLinesAroundMark
#--disable blankLinesAtEndOfScope
#--disable blankLinesAtStartOfScope
#--disable blankLinesBetweenScopes
#--disable braces
#--disable consecutiveBlankLines
#--disable consecutiveSpaces
#--disable duplicateImports
#--disable elseOnSameLine
#--disable emptyBraces
--disable enumNamespaces
--disable extensionAccessControl
#--disable fileHeader
#--disable hoistPatternLet
#--disable indent
#--disable initCoderUnavailable
#--disable leadingDelimiters
#--disable linebreakAtEndOfFile
#--disable linebreaks
#--disable modifierOrder
#--disable numberFormatting
--disable preferKeyPath
#--disable redundantBackticks
#--disable redundantBreak
#--disable redundantClosure
--disable redundantExtensionACL
#--disable redundantFileprivate
#--disable redundantGet
#--disable redundantInit
#--disable redundantLet
#--disable redundantLetError
#--disable redundantNilInit
#--disable redundantObjc
--disable redundantParens
#--disable redundantPattern
#--disable redundantRawValues
#--disable redundantReturn
--disable redundantSelf
#--disable redundantType
#--disable redundantVoidReturnType
#--disable semicolons
--disable sortDeclarations
#--disable sortedImports
#--disable spaceAroundBraces
#--disable spaceAroundBrackets
#--disable spaceAroundComments
#--disable spaceAroundGenerics
#--disable spaceAroundOperators
#--disable spaceAroundParens
#--disable spaceInsideBraces
#--disable spaceInsideBrackets
#--disable spaceInsideComments
#--disable spaceInsideGenerics
#--disable spaceInsideParens
--disable strongOutlets
#--disable strongifiedSelf
--disable todos
#--disable trailingClosures
#--disable trailingCommas
#--disable trailingSpace
#--disable typeSugar
--disable unusedArguments
#--disable void
--disable wrap
#--disable wrapArguments
#--disable wrapAttributes
--disable wrapMultilineStatementBraces
#--disable yodaConditions

# Opt-in Rules (disabled by default) デフォルトで無効なルール
# コメントアウト部分は無効

#--enable acronyms
#--enable blankLinesBetweenImports
#--enable blockComments
#--enable isEmpty
#--enable markTypes
#--enable organizeDeclarations
#--enable preferDouble
#--enable sortedSwitchCases
#--enable wrapConditionalBodies
#--enable wrapEnumCases
#--enable wrapSwitchCases

まとめ

以上の方法によるSwiftFormat導入で、他のメンバーは $ git config --local core.hooksPath GitHooks を一度実行するだけでOKです。

コミット実行時にSwiftFormatがコードを変更してコミットを中断、その変更を取り入れて再度コミットするといった一手間だけでソースコードを綺麗に保てるのは魅力的だと感じました。

導入も簡単なので一度検討してみる価値はあるかと思います。

サンプルプロジェクト

SwiftFormatの実行環境を再現したサンプルのプロジェクトをこちらに置いておきます。

github.com

We are hiring!

ミラティブでは一緒に開発してくれるiOS開発者を募集しています!少しでも興味を持っていただいた方はお話を聞いていただくだけでも結構ですので、気軽にご連絡ください。

www.mirrativ.co.jp

エンジニア向け会社紹介資料

speakerdeck.com