LoginSignup
6
5

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-03-14

はじめに

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

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

前提

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


(引用:SwiftGen/Documentation/

Stencil というテンプレート構文を書いて、吐き出したいコードにしていきます。

実際は StencilSwiftKit でラップされているので、こちらの書き方に準拠していくことになります。

実装

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

1. 現状

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

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

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"]
  }
}

JSONDocumentSwiftGenが内部で作成した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 のカスタムテンプレート作成

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

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

.swift
internal enum JSONFiles {
  internal enum Response {
    static let path = "response.json"
  }
   .
   .
.swift
print(JSONFiles.Response.path) // "response.json"

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

そのためには、StencilSwiftKitの書き方であるmacroを使っていきます。
macroは値を動的に入れることができます。(→ 詳しくは公式リファレンスから)

.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 {}

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

.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"
// }

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

終わりに

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

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

6
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
5