[Swift] SwiftGen をカスタムテンプレートで拡張する


# はじめに

`SwiftGen`を使っていて、欲しい機能がなかったので、自分で拡張することにしました。
（情報もあんまりなく英語のものが多かったので、ニッチだけどまとめておこうかなと思ったり）

# SwiftGen に関して

> SwiftGen is a tool to automatically generate Swift code for resources of your projects (like images, localised strings, etc), to make them type-safe to use.

（引用：[SwiftGen](https://github.com/SwiftGen/SwiftGen)）

リソースファイル（画像、ローカライズなどのファイル）を扱う際に、`String` がハードコードになってしまうので、それを自動で保管してくれる Swift コードを生成してくれる便利なツールです。


## 前提

コード生成される仕組みは以下のようになっています。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/b4f9c651-78d7-c16c-776c-a0078592d9e4.png)
（引用：[SwiftGen/Documentation/](https://github.com/SwiftGen/SwiftGen/tree/stable/Documentation)）

[Stencil](https://github.com/stencilproject/Stencil) というテンプレート構文を書いて、吐き出したいコードにしていきます。

https://github.com/stencilproject/Stencil

実際は [StencilSwiftKit](https://github.com/SwiftGen/StencilSwiftKit) でラップされているので、こちらの書き方に準拠していくことになります。

https://github.com/SwiftGen/StencilSwiftKit


# 実装

今回は、`json`を読み込ませる想定で話を進めます。

## 1. 現状

`SwiftGen`で`json`を扱う際、中の値にアクセスするのは容易になります。

例えば、以下のようなデータがあった場合は

```response.json
{
  "id": 123456789,
  "name": "john"
}
```

このようなコードが生成されます。

```.swift
internal enum JSONFiles {
  internal enum Response {
    private static let _document = JSONDocument(path: "response.json") // ※
    internal static let id: Int = _document["id"]
    internal static let name: String = _document["name"]
  }
}
```
※ `JSONDocument`は`SwiftGen`が内部で作成した`json`を扱うためのもの


```.swift
print(JSONFiles.Response.id) // 123456789
print(JSONFiles.Response.name) // john
```

上記の通り簡単に取り出すことができます。

## 2. 課題

チームではテストで`json`を使用しており読み込んでいるのですが、`json`の読み込みは **Stringのハードコード** になっています。

```.swift
Bundle(for: /* any class */ ).path(forResource: "response", ofType: "json")
```

これは現状の`SwiftGen`ではファイル名の取得ができないため（内部的にはできているが、それが参照できない仕様になっているため）、拡張して対応することにしました。

## 3. SwiftGen のカスタムテンプレート作成

以下にデフォルトのテンプレートファイルがあるので、こちらを参考にして（コピーしてきて）書くと良いです。

https://github.com/SwiftGen/SwiftGen/tree/stable/Sources/SwiftGenCLI/templates

先に生成される完成系を見てからの方が想像しやすいので載せておきます。


```.swift
internal enum JSONFiles {
  internal enum Response {
    static let path = "response.json"
  }
   .
   .
```

```.swift
print(JSONFiles.Response.path) // "response.json"
```

これを生成するための`stencil`を書いていきます。

そのためには、`StencilSwiftKit`の書き方である`macro`を使っていきます。
`macro`は値を動的に入れることができます。（→ 詳しくは[公式リファレンス](https://github.com/SwiftGen/StencilSwiftKit/blob/stable/Documentation/tag-macro.md)から）

```.stencil
{% macro documentBlock file document %}
  static let path = "{% call transformPath file.path %}"
{% endmacro %}

{% macro transformPath path %}{% filter removeNewlines %}
  {% if param.preservePath %}
    {{path}}
  {% else %}
    {{path|basename}}
  {% endif %}
{% endfilter %}{% endmacro %}
```

このコードから`json`のファイル名を取得できます。
（テンプレートファイルにあるものとほぼ同じです。）

さらに、`json`の数だけ取得したいので、そのコードを書いていきます。

```.stencil
{{accessModifier}} enum {{param.enumName|default:"JSONFiles"}} {
  {% if files.count > 1 or param.forceFileNameEnum %}
  {% for file in files %}
  {{accessModifier}} enum {{file.name|swiftIdentifier:"pretty"|escapeReservedKeywords}} {
    {% filter indent:2 %}{% call fileBlock file %}{% endfilter %}
  }
  {% endfor %}
  {% else %}
  {% call fileBlock files.first %}
  {% endif %}
}
```

（こちらもテンプレートファイルにあるものとほぼ同じです。）

以下、軽く解説すると

- `accessModifier`：
デフォルトでは`internal`がつくが、`swiftgen.yml`から設定で変更できる。

- `{{param.enumName|default:"JSONFiles"}}`：
`swiftgen.yml`から`enum`名を設定でき、指定がないと`JSONFiles`になる。

- `{{file.name|swiftIdentifier:"pretty"|escapeReservedKeywords}}`：
`json`名が内部の`enum`名に設定される。後ろにあるのはオプション。

- `{% filter indent:2 %}{% call fileBlock file %}{% endfilter %}`：
`macro`で作成した`path`が生成される部分。

作成した`stencil`ファイルはプロジェクトの任意の場所に設置しましょう。

## 4. swiftgen.yml の設定


テストでしか必要ないため、主に`〇〇Tests`を見るようにしていますが、個々に設定したいようにしてください。

```swiftgen.yml
 json:
   inputs: 〇〇Tests/.../.../ # 読み込み先
   filter: .+\.json$ # `json`のみをフィルター
   outputs:
     - templatePath: 〇〇Tests/.../.../custom-temp.stencil # 用意したカスタムテンプレート
       output: 〇〇Tests/.../.../xxx.swift # 書き出し先
```

あとは、コマンドを実行してコードを生成するだけです。

このように`stencil`を書くことで、オリジナルの生成コードを作成することができ拡張できます。

# おまけ

拡張は上記までの手順で問題ありません。
ここからは`json`の拡張をもう少し行ったので記載しておきます。

## 5. json を簡単に扱えるようにする

上記の実装の場合、名前を取得すると`response.json`という形式のため、拡張子`.json`がついてきます。

実際にデータを呼び出す場合は、

```.swift
Bundle(for: /* any class */ ).path(forResource: "response", ofType: "json")
```

このように拡張子が不要なので、この部分はswiftで加工していきます。
また、生成された全ての`enum`で使えるように、共通の`protocol`を作り、それを`stencil`側で付与します。

### 共通のプロトコル

共通の処理ができる`protocol`は以下になります。

```.swift
protocol JSONEntitable {
  static var path: String { get }
  static var fileName: String { get }
  static var data: Any? { get }
}

extension JSONEntitable {

  /// `.json`を取り除く
  static var fileName: String {
    path.components(separatedBy: ".json").joined()
  }

  /// jsonからデータを取り出す
  static var data: Any? {
    guard let path = Bundle(for: JSONBundle.self).path(forResource: fileName, ofType: "json") else {
        return nil
    }
    guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else {
        return nil
    }
    return try? JSONSerialization.jsonObject(with: data, options: [.mutableContainers])
  }
}

/// `Bundle(for: )`で使用するためだけのクラス
private final class JSONBundle {}
```

これを`stencil`の`macro`を使って、動的に差し込めるようにします。

```.stencil
{% macro fileNameEnumBlock enumName %}{{enumName}}: JSONEntitable{% endmacro %}
```

そして`enum`に付与していきます。

```.stencil
{{accessModifier}} enum {{param.enumName|default:"JSONFiles"}} {
  {% if files.count > 1 or param.forceFileNameEnum %}
  {% for file in files %}
  {{accessModifier}} enum {% call fileNameEnumBlock file.name|swiftIdentifier:"pretty"|escapeReservedKeywords %} {
    {% filter indent:2 %}{% call fileBlock file %}{% endfilter %}
  }
  {% endfor %}
  {% else %}
  {% call fileBlock files.first %}
  {% endif %}
}
```

こうすることで、生成されるコードには共通のプロトコル(`JSONEntitable`)が付与されます。

```.swift
internal enum JSONFiles {
  internal enum Response: JSONEntitable {
    static let path = "response.json"
  }
   .
   .
```

実際に使用する際は

```.swift
print(JSONFiles.Response.path)
// "response.json"

print(JSONFiles.Response.fileName)
// "response"

print(JSONFiles.Response.data!)
// {
//   "id": 123456789,
//   "name": "john"
// }
```

となり、かなり使いやすくなりました！

# 終わりに

コード全般を以下に掲載しておきます！

https://gist.github.com/crane-hiromu/9423c18c319f1331a9aa459d3dcaa16d


他でも言及されていましたが、テンプレートのカスタマイズは属人化すると管理が大変なので、要領用法を正しく守って行いましょう！