For educational purposes (and fun), I am currently rewriting the openiban.com IBAN validation and generation service in Elixir. Because most of the validation is simple pattern matching, I expect Elixir to be a great choice for the task.

Openiban currently uses a MySQL database to look up BIC codes and some additional data from the bank code, that is included inside every IBAN. My goal is to get rid of the database and simply generate all the code that will be used to perform the lookups.

To get started with this endeavour, I’ll come up with a macro that can be used to generate a case expression which matches all valid https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2 country codes.

This post will show my train of thought and the problems I encountered while developing the macro. I have read Chris McCord’s awesome Metaprogramming in Elixir a while ago, but never used any of it in a real project. What I write here might not be the most idiomatic solution, but maybe someone can get unstuck by reading about the problems and intermediate steps that I went through while coding the macro.

It’s been a while since I read the book, so I decided to get started with what little I already knew about my macro: the way it should be called.

def extract_country_code(iban) do
  take_prefix iban, ["AD", "AE", "..."]
end

I also knew the code that the take_prefix/2 macro would have to produce:

def extract_country_code(iban) do
  case iban do
    "AD" <> rest -> {:ok, "AD", rest}
    "AE" <> rest -> {:ok, "AE", rest}
    # ...
    _ -> {:error, "no match", iban}
  end
end

An example invocation of this function should return the following result:

iex> extract_country_code("DE89370400440532013000")
{:ok, "DE", "89370400440532013000"}

This can’t be too hard, right?. Let’s create a module that will house the macro:

defmodule Openiban.Matchers do
  # __using__ is invoked when a module is 'used' by some other module
  defmacro __using__(_opts) do
    quote do
      import unquote(__MODULE__)
    end
  end
end

My next step was to try and generate the AST representation of the conditions that would be put in the body, or block, of my case condition.

defmodule Openiban.Matchers do
  # ...
  defmacro take_prefix(subject, prefixes) do
    conditions_ast = Enum.reduce(conditions, [], fn(c, acc) ->
      [quote do
        unquote(c) <> _ -> {:ok, unquote(c)}
      end | acc]
    end)
  end
end

So let’s see what this generates:

iex> IO.inspect Macro.expand(conditions_ast, __ENV__)
[[{:->, [],
   [[{:<>, [context: Matchers, import: Kernel], ["B", {:_, [], Matchers}]}],
    true]}],
 [{:->, [],
   [[{:<>, [context: Matchers, import: Kernel], ["A", {:_, [], Matchers}]}],
    true]}]]

Looks great. So let’s try to inject that into a case ... do expression:

defmodule Openiban.Matchers do
  # ...
  defmacro take_prefix(subject, prefixes) do
    conditions_ast = Enum.reduce(prefixes, [], fn(c, acc) ->
      [quote do
        unquote(c) <> _ -> {:ok, unquote(c)}
      end | acc]
    end)

    quote do
      case unquote(subject) do
        unquote(conditions_ast)
      end
    end
  end
end

But this doesn’t work as expected:

iex> require Openiban.Matchers
iex> Openiban.Matchers.take_prefix("test", ["t"])
** (CompileError) iex:15: expected -> clauses in case
    (stdlib) lists.erl:1353: :lists.mapfoldl/3
    (stdlib) lists.erl:1353: :lists.mapfoldl/3
             expanding macro: Foo.head_if/2
             iex:15: (file)

So what is the problem? I found it very useful to inspect the generated AST with the Macro.expand and Macro.to_string functions. I know it’s a little hard to see at first, but there is a surplus list around every generated AST node:

iex> IO.inspect Macro.expand(conditions_ast, __ENV__)
[[{:->, [],
   [[{:<>, [context: Matchers, import: Kernel], ["B", {:_, [], Matchers}]}],
    true]}],
 [{:->, [],
   [[{:<>, [context: Matchers, import: Kernel], ["A", {:_, [], Matchers}]}],
    true]}]]

This is how the AST of two actual case branches looks like:

iex> quote do                                  
...> "B" -> true
...> "A" -> true
...> end
[{:->, [], [["B"], true]}, {:->, [], [["A"], true]}]

My first intuition was to simply call List.flatten/1 on the nested list of AST nodes. At a second thought (on the next morning) I discovered that quote always returns a list. So I simply had to concatenate ([] ++ []) the lists instead of prepending ([x | xs]) each case branch to the accumulator.

defmodule Openiban.Matchers do
  # ...
  defmacro take_prefix(subject, prefixes) do
    conditions_ast = Enum.reduce(prefixes, [], fn(c, acc) ->
      case_branch =
        quote do
          unquote(c) <> rest -> {:ok, unquote(c), rest}
        end
      
      case_branch ++ acc
    end) 

    quote do
      case unquote(subject) do
        unquote(conditions_ast)
      end
    end
  end
end

Let’s try again:

iex> require Openiban.Matchers
iex> Openiban.Matchers.take_prefix("test", ["a", "t", "z"])
{:ok, "t", "est"}

Okay, this looks promising. How about the case when no match is possible?

iex> Openiban.Matchers.take_prefix("bar", ["a", "t", "z"])
** (CaseClauseError) no case clause matching: "bar"

The CaseClauseError is raised, because the ‘no match’ case has not been implemented yet. Here is the full implementation of the macro, which fixes this problem.

defmodule Openiban.Matchers do
  # ...
  defmacro take_prefix(subject, prefixes) do
    conditions_ast = Enum.reduce(prefixes, [], fn(c, acc) ->
      case_branch = 
        quote do
          unquote(c) <> rest -> {:ok, unquote(c), rest}
        end 
        
      acc ++ case_branch
    end) 

    # Add default condition
    default_case =
      quote do
        _ -> {:error, "no match", unquote(subject)}
      end
    
    conditions_ast = conditions_ast ++ default_case

    quote do
      case unquote(subject) do
        unquote(conditions_ast)
      end
    end
  end
end
Openiban.Matchers.take_prefix("test", ["a", "t", "z"])

The first version of the macro also used Enum.reverse/1 to correct the order of AST nodes. Otherwise the default condition "_" -> ... would come first inside the list of case expressions, and all other conditions would be ignored. The final version always appends to the list of AST nodes, making the reverse operation unneccessary.

The next part of this series will show how macros can be used to create a simple matcher DSL and deal with non-AST-literal arguments in the take_prefix/2 macro. This will come in handy, because it will allow the macro to use prefixes that have been e.g. read from a file or placed in a module attribute.