Macrofunology 2: wrapping bodies, dissecting arglists

Any def contains a head (the function's name and argument list) and a body (what comes after the do). This post shows how to build a macro that takes the same structure but uses a part of the argument list to wrap code around the body. That is, the macro defchain will turn this:

defchain assert_fields(map, pairs) do <body> end

... into this:

def assert_fields(map, pairs) do code_around(<body>) end

Note: handling function guards (when is_list(arg) and the like) is covered in the next post.

The series: module structure, this post, guard expressions, atoms and function names

I'm fond of test assertions that return their first argument because they can be used in pipelines:

  VM.ServiceGap.accept_form(params, @institution)
  |> ok_content
  |> assert_valid
  |> assert_changes(id: 1,
                    in_service_datestring: @iso_date_1,
                    out_of_service_datestring: @iso_date_2,
                    reason: "reason")

Fond enough, in fact, that I've created two Hex packages with a selection of them (general-purpose and Ecto-specific).

For my own convenience, I wrote a macro, defchain, to use when creating such assertions. It does the work of remembering to return the first argument. Consider the following definition of an assertion that checks whether a map contains certain expected values for a set of keys:

  @doc ~S"""
      iex> map = %{a: 3, b: 4, c: 1}
      iex> map |> assert_fields(a: 3, b: 4) # |> assert...
      %{a: 3, b: 4, c: 1} 

  defchain assert_fields(map, pairs) do
    for {key, expected} <- pairs do 
      assert Map.get(map, key) == expected
link to code

The expansion of that defchain expression will be this:

def(assert_fields(map, pairs)) do
  _called_for_side_effect =                      <<<<<<
    for({key, expected} <- pairs) do
      assert(Map.get(map, key) == expected)
  map                                            <<<<<<

All defchain does is wrap a little code around the original body. (The only change that really matters is the insertion of a new return value on the next-to-last line. The binding of _called_for_side_effect isn't required; it's just documentation.)

Let's now look at how defchain is defined. Here is its argument list, which mimics the syntax of a def:

  defmacro defchain(head, do: body) do

Digression alert!

Note that do is a keyword argument, just like any other keyword argument to any function. The previous code could also be written like this:

  defmacro defchain(head, opts) do
    body = Keyword.fetch!(opts, :do)

One thing that's special about do is that the Elixir parser understands how to turn a do ... end block into a key/value pair. If I wanted to use wrapping instead of do, I could. But the parser wouldn't understand wrapping ... end, so I'd have to use an explicit keyword notation in the definition of assert_fields:

  defchainX assert_fields(map, pairs), wrapping:
    (for {key, expected} <- pairs do 
       assert Map.get(map, key) == expected
link to code

Ick. I have at times found such a thing useful, though.

We won't need to look inside assert_fields' body, so its structure is uninteresting, but we do have to find the name of the first argument (map) in order to have the generated function use it for a return value. That means looking at the structure of defchain's head argument:

  defmacro defchain(head, do: body) do
    IO.inspect head

Here's the result for assert_fields:

{:assert_fields, [line: 45],
 [{:map, [line: 45], nil}, {:pairs, [line: 45], nil}]}

That's the standard representation for Elixir parsed text (also called "quoted text", "abstract syntax trees", or "ASTs"). Except for some exceptions, it's a three-tuple with these parts:

  1. An atom that represents a string of non-whitespace characters in the source text. For example, what I typed as assert_fields (with no leading colon) was represented by :assert_fields. If I'd typed code like a < b, that would be parsed into a tuple whose first element would be the atom :<.
  2. Miscellaneous data about the source text ("metadata") such as the line number.
  3. A description – typically a list of nested three-tuples – of subordinate parsed text. For example, in our assert_fields case, the tuple for the function name contains the tuples for the arguments map and pairs. The :< tuple for a < b would contain three-element tuples for a and b.

    A non-list third element, like nil, means there was no subordinate text.

Later in this series, we'll see some gotchas involving the exceptions to this representation. In this post, we don't need to care.

Given the above representation of assert_fields(map, pairs), our macro code can find the name of the first argument like this:

  defmacro defchain(head, do: body) do
    {_name, _metadata, [return_this | _args]} = head

... and here's how we use that information to add it into the expansion of defchain assert_fields...:

    quote do
      def unquote(head) do
        _called_for_side_effect = unquote(body)
        unquote(return_this)               <<<<<<<<<<<<<<<
link to code

Easy-peasy, right? But there's a complication, described in the next post.

Next: guard expressions