erlang like record manipulator

rabbitmqのクライアントamqp_clientをelixirで使おうと思ったのだが、erlangのrecordを使い倒していて困った。elixirにもレコードはあるし、erlangレコードをコンバートする事も出来るのだが、-includeを追いかけてくれないとかのelixirのRecord.extract_fromがイマイチだったり、レコードタグ(タプルの最初の要素のアトム)の値をたよりにOK/NGを判定するamqp_clientの仕様が、レコードタグをモジュール名にしてしまうelixirレコードの仕様と合わなかったりしたので、Ermモジュールという名前でマクロライブラリを自作することにした。
Amqpモジュールを例にすると、こんな感じで使う。

defmodule Amqp do
  use Erm
  use Amqp.Uri
  Erm.addpath("dist/rabbit_common*/ebin")
  :io.format("code ~p~n", [:code.lib_dir(:rabbit_common)])
  Erm.defrecords_from_hrl("deps/**/amqp_client*/include/amqp_client.hrl")

  def start() do
    {:ok, re} = Amqp.Uri.parse("amqp://user:passwd@amqp-server-host.example.com")
    :io.format("Refields: ~p~n",
                 [Erm.record_info(:fields, :amqp_params_network)])
    :io.format("Re: ~p~n", [re])
    {:ok, con} = Amqp.Connection.start(re)
    {:ok, chan} = Amqp.Connection.open_channel(con)
    ex = Erm.record(:"exchange.declare", [exchange: "my_exchange",
                   type: "topic"])
    Erm.recordl(:"exchange.declare_ok") = Amqp.Channel.call(chan, ex)
    q = Erm.record(:"queue.declare", [queue: "my_queue"])

    Erm.recordl(:"queue.declare_ok") = Amqp.Channel.call(chan, q)
    binding = Erm.record(:"queue.bind", [queue: "my_queue",
                                         exchange: "my_exchange",
                                         routing_key: "key"])
    Erm.recordl(:"queue.bind_ok") = Amqp.Channel.call(chan, binding)

    payload = "foobar"
    publish = Erm.record(:"basic.publish", [exchange: "my_exchange",
                                            routing_key: "key"])
    p = Erm.record(:"P_basic", [delivery_mode: 2])
    p = Erm.record(:"P_basic", p, [delivery_mode: 2])
    Amqp.Channel.cast(chan, publish, Erm.record(:amqp_msg, [props: p,
                                                            payload: payload]))

    :timer.sleep(10000)
    get = Erm.record(:"basic.get", [queue: "my_queue", no_ack: true])
    {Erm.recordl(:"basic.get_ok"), content} = Amqp.Channel.call(chan, get)
    Erm.recordl(:amqp_msg, [payload: payload2]) = content
    :io.format("~p ~p", [payload, payload2])
    binding = Erm.record(:"queue.unbind", [queue: "my_queue",
                                          exchange: "my_exchange",
                                         routing_key: "key"])

    Erm.recordl(:"queue.unbind_ok") = Amqp.Channel.call(chan, binding)
    delete = Erm.record(:"exchange.delete", [exchange: "my_exchange"])
    Erm.recordl(:"exchange.delete_ok") = Amqp.Channel.call(chan, delete)
    delete = Erm.record(:"queue.delete", [queue: "my_queue"])
    Erm.recordl(:"queue.delete_ok") = Amqp.Channel.call(chan, delete)
    :ok = Amqp.Channel.close(chan)
    :ok = Amqp.Connection.close(con)
  end
end

eppかわいいよepp

erlangのコードを解析してelixirのコードにしてそれをコンパイルするとerlangのコードになるのだが、それは置いておいて、erlangのコードをプリプロセスするためのモジュールeppがあるのでそれを使う。

{:ok, ast} = :epp.parse_file(file, pathlist, opt)

opt, pathlistは何に使うのかイマイチ不明だったのでどちらも[]でOK。fileはbinaryではなくlistであることに注意すると、erlangの抽象構文木が得られる。この構文木をスキャンしてレコード定義部分を抽出してコンバート(Enum.filter_map/3)すれば良い。

レコード定義

レコードはタプルである。従ってタプル定義を登録しておき、あとで名前で参照したときに定義からタプルを生成することができれば良い。
定義情報をマクロのモジュールやメソッドとして保存する方法もあるし、defrecordpとかそのために用意されている節もあるのだが、amqp_clientのレコード名は、P_basicとかqueue.bind_okとか香ばしくてメソッド名に使えないため、etsを使う。
レコード定義は、フィールド名とその初期値のリストのetsテーブルへのinsertであり、レコード定義の参照は、etsテーブルのlookupとなる。

include_libの解決

:epp.parse_fileでは-include_libのファイルオープンエラーは {:error, {_n, :epp, {filepath}}}の形でastに表現される。これを見つけたら例外を発生させればよい。

raise File.Error, reason: :enoent, action: "maybe not search path", path: filepath

といった具合。サーチパスは、:code.add_path('path’)で追加。Path.wildcard("path/**/to/include")のようなかたちでまとめて追加しておくと良い。これはコンパイル時に追加しなければならないので関数定義の外側に置いておく。

astはconvすればいい

さてastから{:attribute, _n, :record, {name, fields}}の形を取り出してetsへ登録すればいいのだが、ここでfieldsの内容がきわめて多様であるためそんな簡単には行かない。まぁ木自体は出来ているので、そこを降りていくだけなのでそんなに難しくはないのだが。
fieldsは初期値のない{:record_field, _n, name}か初期値のある{:record_field, _n, name, value}の形をしている。初期値がない場合には:undefinedを初期値と考えればいいので、そのようにすればいいが、valueについては、任意のerlang項があり得るため、コンバートが必要となる。このコンバート関数をconvとしてどういうパターンがあるか考えてみる。

conv!conv!conv!

{:atom, _n, v} アトム v
{:integer, _n, v} 整数 v
{:bin, _n, v} バイナリ v
{:record, _n, name, fields} レコード参照 erec(name, reduce_field(fields))
{:function, func, arity} 関数 [:erlang, func, arity]
{:function, module, func, arity} 関数 [module, func, arity]
{:fun, _n, fp} 無名関数 {:function, [import: Kernel], conv(fp)}
{:cons, _n, car, cdr} コンス(つまりリスト) [ conv(car) | conv(cdr)]
{:call, _n, mf, args} 定義時に関数呼び出す appy(m, f, args)

といったパターンがある。一文字でない変数で表示した変数は非終端記号であり、さらにconvする必要があるが、上の表の通り、再帰的に書くだけなので問題は無い。面白いのは関数呼び出しや関数オブジェクトも変換対象と出来るのでほぼ全てのerlangの構文をコンバートできてしまっている。

右辺値と左辺値

パターンマッチングでも使いたいが、レコード定義には初期値がもれなく入っているため、レコード参照を仮にErm.record(:record_name)とした時、-record(record_name, {field1})の場合、
Erm.record(:record_name) --> {:record_name, :undefined}
と展開されてしまうため、左辺値としてマッチングに使おうとすると、興味の無いフィールドを明示的に'_'として与えなければならなくなる。これでは使いにくいため、左辺値として使うときには、フィールドのデフォルト値を'_'として展開するようにする。

というところをまとめたのが、
https://github.com/k1complete/erm
になる。。。