[Elixir] mix xref trace を真似して compilation tracer を使ってみる

[株式会社ACCESS Advent Calendar 2021](https://qiita.com/advent-calendar/2021/access) 22日目の記事だよー！

## はじめに

- 最近リリースされた [Elixir 1.13.0 では `mix xref` の新しいサブコマンドに `trace` が追加されました。](https://elixir-lang.org/blog/2021/12/03/elixir-v1-13-0-released/#mix-xref)
    - Advent Calendar の担当日を待っているうちに [v1.13.1](https://github.com/elixir-lang/elixir/releases/tag/v1.13.1) がリリースされました。
- [実装](https://github.com/elixir-lang/elixir/pull/11184) を見てみたところ、Elixir 1.10 で [追加](https://github.com/elixir-lang/elixir/pull/9247) された [compilation tracers](https://hexdocs.pm/elixir/1.13.0/Code.html#module-compilation-tracers) という機能を使っているようです。
- Elixir には macro があるので、ファイルを見ただけではどの関数を呼び出しているか分からないこともあるのですが、compilation tracers を使うと比較的簡単に分かりそうに思ったので調べてみました。

## 環境

```console
$ iex
Erlang/OTP 22 [erts-10.7.2.15] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1]

Interactive Elixir (1.13.0) - press Ctrl+C to exit (type h() ENTER for help)
```

## Tracer module を作ってみる

```elixir
defmodule CompilationTracer do
  def run(file, apps) do
    set =
      for app <- apps,
          modules = Application.spec(app, :modules),
          module <- modules,
          into: MapSet.new(),
          do: module

    old = Code.compiler_options(ignore_module_conflict: true, tracers: [__MODULE__])
    ets = :ets.new(__MODULE__, [:named_table, :duplicate_bag, :public])
    :ets.insert(ets, [{:config, set, trace_label(nil)}])

    try do
      Code.compile_file(file)
    else
      _ ->
        :ets.delete(ets, :modules)
        print_traces(Enum.sort(:ets.lookup_element(__MODULE__, :entry, 2)))
    after
      :ets.delete(ets)
      Code.compiler_options(old)
    end
  end

  # 以下に下記のコードを丸ごとコピーする
  # https://github.com/elixir-lang/elixir/blob/v1.13.0/lib/mix/lib/mix/tasks/xref.ex#L509-L585
```

ほとんど `mix xref trace` のコードから持ってきています。
元のコードと違う点は以下です。

- 検出対象の application を設定できるようにしています。
    - `apps` で指定した application 内の関数や macro の呼び出しが検出されます。
    - `mix xref trace` では自分の Mix project で管理している application が対象になります。
        - `--include-siblings` オプションにより、同じ umbrella projects 内の application を対象にすることもできます。
- 使わないオプションに関連するコードを削除してあります。

## Tracer module を使ってみる

### テスト用の Mix project

Macro を多用している [`gettext`](https://hex.pm/packages/gettext) を使った Mix project を準備します。
`$ mix new gettext_example` して、`gettext` を deps に入れて、以下 2 つのファイルを書きます。

```elixir
# lib/gettext_example/gettext.ex
defmodule GettextExample.Gettext do
  use Gettext, otp_app: :gettext_example
end
```

```elixir
# lib/gettext_example.ex
defmodule GettextExample do
  import GettextExample.Gettext

  def hello do
    gettext("Here is one string to translate")
  end
end
```

### Compilation tracing してみる

少しは面白い結果になるように、`gettext` のバージョンを `v0.17.1` と `v0.18.2` の 2 通り使って実行してみます。

```console
# gettext = v0.17.1 の場合
iex(2)> Mix.Project.config()[:elixirc_paths] |> Mix.Utils.extract_files([:ex]) |> Enum.each(&CompilationTracer.run(&1, [:gettext]))
lib/gettext_example.ex:9: call Gettext.dgettext/4 (runtime)
lib/gettext_example/gettext.ex:1: alias Gettext.Backend (compile)
lib/gettext_example/gettext.ex:1: require Gettext.Backend (export)
lib/gettext_example/gettext.ex:1: call Gettext.Compiler.__before_compile__/1 (compile)
lib/gettext_example/gettext.ex:1: call Gettext.Compiler.append_extracted_comment/1 (runtime)
lib/gettext_example/gettext.ex:1: call Gettext.Compiler.expand_to_binary/4 (runtime)
lib/gettext_example/gettext.ex:1: call Gettext.Compiler.expand_to_binary/4 (runtime)
lib/gettext_example/gettext.ex:1: call Gettext.Compiler.expand_to_binary/4 (runtime)
lib/gettext_example/gettext.ex:1: call Gettext.Compiler.expand_to_binary/4 (runtime)
lib/gettext_example/gettext.ex:1: call Gettext.Compiler.expand_to_binary/4 (runtime)
lib/gettext_example/gettext.ex:1: call Gettext.Compiler.expand_to_binary/4 (runtime)
lib/gettext_example/gettext.ex:1: call Gettext.Compiler.get_and_flush_extracted_comments/0 (runtime)
lib/gettext_example/gettext.ex:1: call Gettext.Compiler.get_and_flush_extracted_comments/0 (runtime)
lib/gettext_example/gettext.ex:1: call Gettext.Extractor.extract/5 (runtime)
lib/gettext_example/gettext.ex:1: call Gettext.Extractor.extract/5 (runtime)
lib/gettext_example/gettext.ex:1: call Gettext.Extractor.extracting?/0 (compile)
lib/gettext_example/gettext.ex:1: call Gettext.Extractor.extracting?/0 (compile)
lib/gettext_example/gettext.ex:1: call Gettext.Extractor.extracting?/0 (runtime)
lib/gettext_example/gettext.ex:1: call Gettext.Extractor.extracting?/0 (runtime)
lib/gettext_example/gettext.ex:1: call Gettext.ExtractorAgent.add_backend/1 (compile)
lib/gettext_example/gettext.ex:1: call Gettext.ExtractorAgent.add_backend/1 (compile)
lib/gettext_example/gettext.ex:2: require Gettext (export)
lib/gettext_example/gettext.ex:2: require Gettext.Interpolation (export)
lib/gettext_example/gettext.ex:2: require Gettext.Interpolation (export)
lib/gettext_example/gettext.ex:2: call Gettext.__using__/1 (compile)
lib/gettext_example/gettext.ex:2: call Gettext.Compiler.warn_if_domain_contains_slashes/1 (runtime)
lib/gettext_example/gettext.ex:2: call Gettext.Compiler.warn_if_domain_contains_slashes/1 (runtime)
lib/gettext_example/gettext.ex:2: call Gettext.Interpolation.interpolate/2 (runtime)
lib/gettext_example/gettext.ex:2: call Gettext.Interpolation.interpolate/2 (runtime)
lib/gettext_example/gettext.ex:2: import Gettext.Interpolation.interpolate/2 (runtime)
lib/gettext_example/gettext.ex:2: import Gettext.Interpolation.interpolate/2 (runtime)
lib/gettext_example/gettext.ex:2: call Gettext.Interpolation.to_interpolatable/1 (runtime)
lib/gettext_example/gettext.ex:2: call Gettext.Interpolation.to_interpolatable/1 (runtime)
lib/gettext_example/gettext.ex:2: import Gettext.Interpolation.to_interpolatable/1 (runtime)
lib/gettext_example/gettext.ex:2: import Gettext.Interpolation.to_interpolatable/1 (runtime)
```

```console
# gettext = v0.18.2 の場合
iex(2)> Mix.Project.config()[:elixirc_paths] |> Mix.Utils.extract_files([:ex]) |> Enum.each(&CompilationTracer.run(&1, [:gettext]))
lib/gettext_example.ex:9: call Gettext.dpgettext/5 (runtime)
lib/gettext_example/gettext.ex:1: alias Gettext.Backend (compile)
lib/gettext_example/gettext.ex:1: require Gettext.Backend (export)
lib/gettext_example/gettext.ex:1: call Gettext.Compiler.__before_compile__/1 (compile)
lib/gettext_example/gettext.ex:1: call Gettext.Compiler.append_extracted_comment/1 (runtime)
lib/gettext_example/gettext.ex:1: call Gettext.Compiler.expand_to_binary/4 (runtime)
lib/gettext_example/gettext.ex:1: call Gettext.Compiler.expand_to_binary/4 (runtime)
lib/gettext_example/gettext.ex:1: call Gettext.Compiler.expand_to_binary/4 (runtime)
lib/gettext_example/gettext.ex:1: call Gettext.Compiler.expand_to_binary/4 (runtime)
lib/gettext_example/gettext.ex:1: call Gettext.Compiler.expand_to_binary/4 (runtime)
lib/gettext_example/gettext.ex:1: call Gettext.Compiler.expand_to_binary/4 (runtime)
lib/gettext_example/gettext.ex:1: call Gettext.Compiler.expand_to_binary/4 (runtime)
lib/gettext_example/gettext.ex:1: call Gettext.Compiler.expand_to_binary/4 (runtime)
lib/gettext_example/gettext.ex:1: call Gettext.Compiler.get_and_flush_extracted_comments/0 (runtime)
lib/gettext_example/gettext.ex:1: call Gettext.Compiler.get_and_flush_extracted_comments/0 (runtime)
lib/gettext_example/gettext.ex:1: call Gettext.Extractor.extract/6 (runtime)
lib/gettext_example/gettext.ex:1: call Gettext.Extractor.extract/6 (runtime)
lib/gettext_example/gettext.ex:1: call Gettext.Extractor.extracting?/0 (compile)
lib/gettext_example/gettext.ex:1: call Gettext.Extractor.extracting?/0 (compile)
lib/gettext_example/gettext.ex:1: call Gettext.Extractor.extracting?/0 (runtime)
lib/gettext_example/gettext.ex:1: call Gettext.Extractor.extracting?/0 (runtime)
lib/gettext_example/gettext.ex:1: call Gettext.ExtractorAgent.add_backend/1 (compile)
lib/gettext_example/gettext.ex:1: call Gettext.ExtractorAgent.add_backend/1 (compile)
lib/gettext_example/gettext.ex:2: require Gettext (export)
lib/gettext_example/gettext.ex:2: require Gettext.Interpolation (export)
lib/gettext_example/gettext.ex:2: require Gettext.Interpolation (export)
lib/gettext_example/gettext.ex:2: call Gettext.__using__/1 (compile)
lib/gettext_example/gettext.ex:2: call Gettext.Compiler.warn_if_domain_contains_slashes/1 (runtime)
lib/gettext_example/gettext.ex:2: call Gettext.Compiler.warn_if_domain_contains_slashes/1 (runtime)
lib/gettext_example/gettext.ex:2: call Gettext.Interpolation.interpolate/2 (runtime)
lib/gettext_example/gettext.ex:2: call Gettext.Interpolation.interpolate/2 (runtime)
lib/gettext_example/gettext.ex:2: import Gettext.Interpolation.interpolate/2 (runtime)
lib/gettext_example/gettext.ex:2: import Gettext.Interpolation.interpolate/2 (runtime)
lib/gettext_example/gettext.ex:2: call Gettext.Interpolation.to_interpolatable/1 (runtime)
lib/gettext_example/gettext.ex:2: call Gettext.Interpolation.to_interpolatable/1 (runtime)
lib/gettext_example/gettext.ex:2: import Gettext.Interpolation.to_interpolatable/1 (runtime)
lib/gettext_example/gettext.ex:2: import Gettext.Interpolation.to_interpolatable/1 (runtime)
```

`gettext` のバージョンを更新したことにより、呼び出している関数や macro が変わったことが分かります。

```diff
1c1
< lib/gettext_example.ex:9: call Gettext.dgettext/4 (runtime)
---
> lib/gettext_example.ex:9: call Gettext.dpgettext/5 (runtime)
11a12,13
> lib/gettext_example/gettext.ex:1: call Gettext.Compiler.expand_to_binary/4 (runtime)
> lib/gettext_example/gettext.ex:1: call Gettext.Compiler.expand_to_binary/4 (runtime)
14,15c16,17
< lib/gettext_example/gettext.ex:1: call Gettext.Extractor.extract/5 (runtime)
< lib/gettext_example/gettext.ex:1: call Gettext.Extractor.extract/5 (runtime)
---
> lib/gettext_example/gettext.ex:1: call Gettext.Extractor.extract/6 (runtime)
> lib/gettext_example/gettext.ex:1: call Gettext.Extractor.extract/6 (runtime)
```

### できたこと

ライブラリの macro を使っている場合、自分のコードは全く変えていないのに、呼び出している関数や macro が変わっているケースがあります。
もちろんコードを追えばどのような変化があったのか分かるのですが、compilation tracers を利用することによって、苦労せずに見やすい形でその変化を確認することができました。

（実際にこのような変化を確認したいケース自体があまりありませんが。。。）

## （おまけ）ある application の関数と macro の一覧を取得する

```elixir
mfa_list =
  for m <- Application.spec(:gettext, :modules),
      {f, a} <- m.module_info()[:exports],
      do: {m, f, a}
```

`gettext` の `v0.17.1` と `v0.18.2` で diff を取ってみると、以下のようになりました。

```diff
5d4
< {Gettext, :dngettext, 5}
6a6,8
> {Gettext, :dpgettext, 4}
> {Gettext, :dpngettext, 6}
> {Gettext, :dpngettext, 7}
13a16,18
> {Gettext, :pgettext, 3}
> {Gettext, :pgettext, 4}
> {Gettext, :pngettext, 6}
19a25
> {Gettext, :dpgettext, 5}
31,34d36
< {Gettext.Compiler, :append_extracted_comment, 1}
< {Gettext.Compiler, :expand_to_binary, 4}
< {Gettext.Compiler, :get_and_flush_extracted_comments, 0}
< {Gettext.Compiler, :warn_if_domain_contains_slashes, 1}
36a39,42
> {Gettext.Compiler, :warn_if_domain_contains_slashes, 1}
> {Gettext.Compiler, :append_extracted_comment, 1}
> {Gettext.Compiler, :get_and_flush_extracted_comments, 0}
> {Gettext.Compiler, :expand_to_binary, 4}
40d45
< {Gettext.Error, :exception, 1}
43a49
> {Gettext.Error, :exception, 1}
47,48d52
< {Gettext.Extractor, :extract, 5}
< {Gettext.Extractor, :extracting?, 0}
53a58,59
> {Gettext.Extractor, :extract, 6}
> {Gettext.Extractor, :extracting?, 0}
75,77d80
< {Gettext.Interpolation, :interpolate, 2}
< {Gettext.Interpolation, :keys, 1}
< {Gettext.Interpolation, :to_interpolatable, 1}
79a83,85
> {Gettext.Interpolation, :keys, 1}
> {Gettext.Interpolation, :interpolate, 2}
> {Gettext.Interpolation, :to_interpolatable, 1}
96d101
< {Gettext.PO, :dump, 2}
98,99d102
< {Gettext.PO, :parse_file!, 1}
< {Gettext.PO, :parse_string, 1}
102a106,108
> {Gettext.PO, :parse_string, 1}
> {Gettext.PO, :dump, 2}
> {Gettext.PO, :parse_file!, 1}
115d120
< {Gettext.PO.SyntaxError, :exception, 1}
118a124
> {Gettext.PO.SyntaxError, :exception, 1}
120d125
< {Gettext.PO.Tokenizer, :tokenize, 1}
122a128
> {Gettext.PO.Tokenizer, :tokenize, 1}
129,132d134
< {Gettext.PO.Translations, :autogenerated?, 1}
< {Gettext.PO.Translations, :find, 2}
< {Gettext.PO.Translations, :mark_as_fuzzy, 1}
< {Gettext.PO.Translations, :protected?, 2}
135a138,141
> {Gettext.PO.Translations, :mark_as_fuzzy, 1}
> {Gettext.PO.Translations, :autogenerated?, 1}
> {Gettext.PO.Translations, :protected?, 2}
> {Gettext.PO.Translations, :find, 2}
137a144
> {Gettext.Plural, :nplurals, 1}
142d148
< {Gettext.Plural, :nplurals, 1}
146d151
< {Gettext.Plural.UnknownLocaleError, :exception, 1}
149a155,162
> {Gettext.Plural.UnknownLocaleError, :exception, 1}
> {Gettext.PluralFormError, :__info__, 1}
> {Gettext.PluralFormError, :__struct__, 0}
> {Gettext.PluralFormError, :__struct__, 1}
> {Gettext.PluralFormError, :exception, 1}
> {Gettext.PluralFormError, :message, 1}
> {Gettext.PluralFormError, :module_info, 0}
> {Gettext.PluralFormError, :module_info, 1}
```

例えば、バージョンアップで `Gettext.Extractor.extract/5` が無くなっているので、`v0.17.1` で compile した `gettext_example` を `v0.18.2` の環境で使おうとしても無理ということが分かります。

## おわりに

Compilation tracers は色々な使い方ができそうですね！

23 日の担当は @Hiroshi_Tsukamoto さんです。
投稿楽しみにしています。