Interface Builder用のLinterを作ってみた

## Interface Builderの問題点

iOSアプリを開発する限り付き合わなければいけないInterface Builder。
視覚的にViewの構造を確認できる便利な代物ですが、人類が扱うには少々厳しいポイントがいくつかあります。その1つが「指定できるオプションの多さ」です。
現在、Interface Builderのツールタブは6つに別れておりオプションを一望することはできません。そのため、指定するオプションを間違えていたとしても気づきにくい仕様になっています。


例えば、プロジェクト全体で`Relative to margin`の使用を禁止で統一しているとします。[^1] しかし、プログラマーはInterface Builder上でなかなか気がつくことができません。コードレビューで見つかる可能性もありますが、レビュアーの負担はできるだけ減らしたいです。


## IBLinter

<img width="366" alt="スクリーンショット 2017-12-15 14.43.34.png" src="https://qiita-image-store.s3.amazonaws.com/0/73976/c066d1dd-6306-b2fa-808d-27bcfaa249c4.png">

https://github.com/kateinoigakukun/IBLinter

そこで、[Interface Builder用のLinter](https://github.com/kateinoigakukun/IBLinter) を作りました。

SwiftLintと同様に`Build Phase`にコマンドを仕込んで警告を出したり、ビルドエラーで落とすことができます。

## インストール

Homebrewで入れられます。

```sh
$ brew install kateinoigakukun/homebrew-tap/iblinter
```

## 使い方

<img width="578" alt="スクリーンショット 2017-12-15 21.56.45.png" src="https://qiita-image-store.s3.amazonaws.com/0/73976/a8454582-cf65-f4da-8455-16e68ab981d6.png">

`Build Phase` で + New Run Script Phase して以下のスクリプトを仕込みます。

```sh
if which iblinter >/dev/null; then
  iblinter lint
fi
```

これによりビルドする度にLintが走り、問題があれば警告を出すようになります。

## 設定

`.iblinter.yml` という設定ファイルをプロジェクトのルートディレクトリに配置すると色々と設定できます。



```.iblinter.yml
enabled_rules: #有効にするルールid
  - relative_to_margin
  - misplaced

disabled_rules: #無効化するルールid
  - custom_class_name
  - enable_autolayout

excluded: #Lintから除外するパス
  - Carthage
  - Pods
```

以下は現在定義してあるルールです。

| Rule id              | 説明                                                                    |
|:---------------------|:-------------------------------------------------------------------------------|
| `custom_class_name`  | ViewControllerのCustom Classをファイル名と一致させる |
| `relative_to_margin` | `Relative to margin` を禁止する [^1]|
| `misplaced`          | ViewがAutolayoutの制約通りに配置されていない時にビルドエラーにする|
| `enable_autolayout`  | `useAutolayout`をオンでないときにビルドエラーにする |


## IBLinterの実装

Interface Builderで作成したStoryboardやxibファイルの実態は単なるXMLなので、コマンド内部で行っているのは

- XMLをパース
- パースしたオブジェクトをバリデート
- Xcodeの警告として表示

のみです。XPathや正規表現でバリデートする方がパフォーマンスは良さそうですがメンテナンス性に欠けるため、Swiftオブジェクトにマッピングしています。

以下のコードは`relative to margin`を禁止するルールのコードです。一度マッピングして型を付けているのでコード補完によって気持ちよくルールが書けます。

```swift
public struct RelativeToMarginRule: Rule {

    public static let identifier: String = "relative_to_margin"

    public init() {}

    public func validate(storyboard: StoryboardFile) -> [Violation] {
        let scenes = storyboard.document.scenes
        let viewControllers = scenes?.flatMap { $0.viewController }
        return viewControllers?.flatMap { $0.rootView }
            .flatMap { validate(for: $0, file: storyboard) } ?? []
    }

    public func validate(xib: XibFile) -> [Violation] {
        return xib.document.views?.flatMap { validate(for: $0, file: xib) } ?? []
    }

    private func validate(for view: ViewProtocol, file: InterfaceBuilderFile) -> [Violation] {
        guard let constraints = view.constraints else {
            return view.subviews?.flatMap { validate(for: $0, file: file) } ?? []
        }
        let relativeToMarginKeys: [InterfaceBuilderNode.View.Constraint.LayoutAttribute] = [
            .leadingMargin, .trailingMargin, .topMargin, .bottomMargin
        ]
        let attributes = constraints.flatMap { [$0.firstAttribute, $0.secondAttribute] }.flatMap { $0 }
        let violations: [Violation] = attributes.filter { relativeToMarginKeys.contains($0) }
            .map { at in
                let message = " \(at) is deprecated in \(view.customClass ?? view.elementClass)"
                return Violation.init(interfaceBuilderFile: file, message: message, level: .warning)
        }
        if let subviews = view.subviews {
            return subviews.flatMap { validate(for: $0, file: file) } + violations
        } else {
            return violations
        }
    }
}
```

## まとめ

Interface Builderと上手く付き合っていく方法の1つを紹介してみました。今後も色々と模索して、幸せな世界を手に入れたいですね。

まだまだバリデートのルールが少ないのでPR下さい。
https://github.com/kateinoigakukun/IBLinter



[^1]: Autolayoutの制約に`Relative to margin`を付けると8pt(or 16pt)のマージンが余計に入り込むため。