[Elixir]Macroで簡単なテストフレームワークを作ってみる

テストフレームワークと書きながら、そこは余り多く書いていませんが…

Elixirは、

Elixir is small because it doesn’t have ti include all common features.

とのこと。なので、他に必要な拡張が欲しければ、自分で作ってねという言語らしい。基本は小さく、という立ち方ですね。

bind_quoted

通常、 quote で囲まれたところのうち、変数をそのまま使いたい場合は unquote します。ただ、都度 unquote するのは少し面倒だし、場合によっては意図しない動作になります。その問題を解決するために、 bind_quote があります。

例えば、以下のASTが bind_quote の有無でどう変わるのかを覗いてみます。

defmodule Debugger do
  defmacro log(expression) do
    if Application.get_env(:debugger, :log_level) == :debug do
      quote bind_quoted: [expression: expression] do # or quote do
        IO.puts "====="
        IO.puts expression # or IO.puts unquote expression
        IO.puts "====="
      end
      |> IO.inspect
    else
      expression
    end
  end
end
  • unquoteのみ
{:__block__, [],
 [{{:., [], [{:__aliases__, [alias: false], [:IO]}, :puts]}, [], ["====="]},
  {{:., [], [{:__aliases__, [alias: false], [:IO]}, :puts]}, [],
   [{{:., [line: 24], [{:re, [line: 24], nil}]}, [line: 24], []}]},
  {{:., [], [{:__aliases__, [alias: false], [:IO]}, :puts]}, [], ["====="]}]}
  • bindされたとき
{:__block__, [],
 [{:=, [],
   [{:expression, [], Debugger},
    {{:., [line: 24], [{:re, [line: 24], nil}]}, [line: 24], []}]},
  {:__block__, [],
   [{{:., [], [{:__aliases__, [alias: false], [:IO]}, :puts]}, [], ["====="]},
    {{:., [], [{:__aliases__, [alias: false], [:IO]}, :puts]}, [],
     [{:expression, [], Debugger}]},
    {{:., [], [{:__aliases__, [alias: false], [:IO]}, :puts]}, [], ["====="]}]}]}

bind_quoteのときは、最初に処理が関連付けられて、それを以降のquoteでaliasのようにして呼び出している、という処理をするようですね。unquoteの有無で意味が大きく異なる処理もあるので、これは良さそうです。

このbind_quoteが有効な時は、unquoteはその範囲内では無効に設定されます。

なお、このほかにも

https://github.com/elixir-lang/elixir/blob/f7183440716d705bfaabdc54c216d87cebacd9a7/lib/elixir/src/elixir_exp.erl#L146

にある、

ValidOpts = [context, location, line, file, unquote, bind_quoted],

がquoteのときにしていできる、特別なoptionのようです。

テストフレームワーク

簡単なテストフレームワークを書きました。以下リポジトリに突っ込んでいます。
内容自体は簡単で、

  • assert の実装
  • test で始まるメソッドをテストケースとして認識、実施する

というものです。

https://github.com/KazuCocoa/my_mini_ex_test_assertion/tree/master

サンプルを見ながらの実装ですが、これはそんなに難しくないです。

Compile-Time code generation

コンパイル時にコードを生成、メソッドなんかで定義されるmacroの話です。

unquoteが、quoteの外で使われている場合があります。

defmodule Fragment do
  for {name, val} <- [one: 1, two: 2, three: 3] do
    def unquote(name)(), do: unquote(val)
  end
end

これにより、以下のように動的な関数を定義することができる。

Fragment.one/0
Fragment.two/0
Fragment.three/0

ここで、Elixirは、外部ファイルに依存している箇所を明示することができる。

@external_resource mimes_path = Path.join([__DIR__, "mimes.txt"])

これにより、Elixirは mimes_path が変更されていれば、コンパイル時に再度コンパイルを走らせることができます。

少し脱線

PlugのMIME typeの処理箇所、この quote 外の unquote を使っているのですね。

https://github.com/elixir-lang/plug/blob/0118337b990aa2109a7b9152ea1e244a37c7dd07/lib/plug/mime.ex#L95

これは、

https://github.com/elixir-lang/plug/blob/master/lib/plug/mime.types

のリストからmimeのtypeとextensionsを得て、atomにしていくというコンパイル時のビルド処理。

なるほど。。。
思ったよりも力技でした。。。

Compile-time code generationの例


defmodule Translator do
defmacro __using__(_options) do
quote do
Module.register_attribute __MODULE__, :locales, accumulate: true, persist: false
import unquote(__MODULE__), only: [locale: 2]
@before_compile unquote(__MODULE__)
end
end
defmacro __before_compile__(env) do
compile(Module.get_attribute(env.module, :locales))
end
defmacro locale(name, mappings) do
quote bind_quoted: [name: name, mappings: mappings] do
@locales {name, mappings}
end
end
def compile(translations) do
translations_ast = for {locale, source} <- translations do
deftranslations(locale, "", source)
end
quote do
def t(locale, path, bindings \\ [])
unquote(translations_ast)
def t(_locale, _path, _bindings), do: {:error, :no_translation}
end
end
defp deftranslations(locales, current_path, translations) do
for {key, val} <- translations do
path = append_path(current_path, key)
if Keyword.keyword?(val) do
deftranslations(locales, path, val)
else
quote do
def t(unquote(locales), unquote(path), bindings) do
unquote(interpolate(val))
end
end
end
end
end
defp interpolate(string) do
IO.inspect string
~r/(?<head>)%{[^}]+}(?<tail>)/
|> Regex.split(string, on: [:head, :tail])
|> IO.inspect
|> Enum.reduce "", fn
<<"%{" <> rest>>, acc ->
key = String.to_atom(String.rstrip(rest, ?}))
quote do
unquote(acc) <> to_string(Dict.fetch!(bindings, unquote(key)))
end
segment, acc -> quote do: (unquote(acc) <> unquote(segment))
end
end
defp append_path("", next), do: to_string(next)
defp append_path(current, next), do: "#{current}.#{next}"
end
defmodule I18n do
use Translator
locale "en",
flash: [
hello: "Hello %{first} %{last}",
bye: "Bye, %{name}"
],
users: [
title: "Users",
]
locale "ja",
flash: [
hello: "こんにちは %{first} %{last}",
bye: "ばいばい, %{name}"
],
users: [
title: "ユーザ",
]
end
defmodule Sample do
import I18n
def run do
IO.inspect I18n.t("en", "flash.hello", first: "Chris", last: "McCord")
IO.inspect I18n.t("ja", "flash.hello", first: "Chris", last: "McCord")
IO.inspect I18n.t("en", "users.title")
IO.inspect I18n.t("ja", "users.title")
end
end

Macro.to_string 、良いですね。ASTを紐解いて表現してくれる。

Compile-time code generation by remote api

このコード生成、例えば、取得したAPIの結果を、動的にメソッドとして定義して実行することも可能です。

HTTPoisonと、Poisonを使って、以下のようにGitHubから取得できる名前をそのままメソッドとして利用もできるようにできます。mix.exsに適当に依存関係を記入して処理を進めると、以下を実施できます。

defmodule Hub do
  HTTPoison.start
  @username "KazuCocoa"

  "https://api.github.com/users/#{@username}/repos"
  |> HTTPoison.get!(["User-Agent": "Elixir"])
  |> Map.get(:body)
  |> IO.inspect
  |> Poison.decode!
  |> Enum.each fn repo ->
    def unquote(String.to_atom(repo["name"]))() do
      unquote(Macro.escape(repo))
    end
  end
end

↑をビルドした後に変換候補を表示させると以下の通り、repository名が関数名になって取得できます。

iex(1)> Hub.
AppReviewViewer/0
android-testing/0
appium/0
atom-light-ui/0
awesome-android-testing/0
device_manager/0
docker-appium/0
droid-monitor/0
ecto/0
elixir-gimei/0
elixir-github-api/0
elixir-tutorial/0
githubSample/0
hello_phoenix/0
...

これを実行すると、以下のようにURLを取得することも可能。

iex(1)> Hub.my_mini_ex_test_assertion
%{"statuses_url" => "https://api.github.com/repos/KazuCocoa/my_mini_ex_test_assertion/statuses/{sha}",
  "git_refs_url" => "https://api.github.com/repos/KazuCocoa/my_mini_ex_test_assertion/git/refs{/sha}",
  "issue_comment_url" => "https://api.github.com/repos/KazuCocoa/my_mini_ex_test_assertion/issues/comments{/number}",
  "watchers" => 0, "mirror_url" => nil,
  "languages_url" => "https://api.github.com/repos/KazuCocoa/my_mini_ex_test_assertion/languages",
  "stargazers_count" => 0, "forks" => 0, "default_branch" => "master",
  "comments_url" => "https://api.github.com/repos/KazuCocoa/my_mini_ex_test_assertion/comments{/number}",
  "commits_url" => "https://api.github.com/repos/KazuCocoa/my_mini_ex_test_assertion/commits{/sha}",
...

つまるとこ、動的に外部APIをそのままメソッドとして利用することができるようになる、ということですね。
ちょっと、これは使い道が大きそうな気がします…


macro、読む分にはだいぶん読める量が増えてきた気がする。

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.