Conditional context for macros

· 3 min read

elixir hacking

Elixir 1.11 brought to us (amongst other very exciting features) is_struct/2 guard. It, by the way, might have been used as an example of more straightforward not backward compatible alternative in my yesterdayโ€™s writing , but I avoided it for a reason that will become clear below.

Gazing for the first time into the documentation examples, I was confused. Here it is

iex> is_struct(URI.parse("/"), URI)
#โ‡’ true

Wait, what? There is no way the remote call URI.parse("/") would have been allowed in guards, but the documentation states it will.

Well, the documentation is cheating on us. Of course, remote call cannot be used in guards, but is_struct/2 without remote calls can. How is that? Letโ€™s find out.

Conditional Context

There is no doubt, the compiler allows is_struct/2 to be more permissive when itโ€™s used as a bare macro, in a raw code, outside of guards. How does she know what AST to inject depending on the context? Easy. The macro tells her.

There is a __CALLER__ special form, providing the current calling environment. It can be used only inside defmacro and defmacrop ( is available , as an exception raised on any attempt to call it from outside macros, says.) It holds a whole bunch of interesting stuff within it, including, but not limited to context and context_modules .

The purpose of latter weโ€™ll see a bit later, now letโ€™s focus on the former.

It has a type Macro.Env.context() which means it can be an atom, either nil , or :match or :guard . These three contexts mean, respectively, the following

  • :guard means the macro gets expanded within a function guard context, as in def foo(s) when is_struct(s, URI)
  • :match means it gets expanded inside a match, as in def foo(is_struct(s))
  • nil means elsewhere

All three cases are handled differently in the case of is_struct/2 macro, and from the source code linked above, one can see that itโ€™s not allowed in match clauses.

For what it worth, in the case of :guard , it gets expanded into is_map(s) and s.__struct__ == S which I directly used yesterday to simplify things.

Context Module

Another glitch I met literally today, is I have created a module, exporting a fancy functionality via __using__/1 special form. I have it thoroughly tested, and all, and it worked perfectly. Then I decided to add use MyFancy to my .iex.exs file to steroidize my elixir shell.

It blowed up with a very cryptic message.

One might see the nearly same message by calling use GenServer from the shell.

But all I wanted was to inject some fancy stuff into my current context! Apparently, the AST to inject, besides setting a ton of environment, contained some stuff that makes sense within the module context only. One cannot call def/2 from outside of the module, or set the module attribute (this is what the cryptic error message above was actually referring to,) just out of the thin air.

Aforementioned __CALLER__ has context_modules field to help us. According to the documentation , this field contains the list of modules defined in the current context. One example expresses it better than the thousand of words:

IO.inspect(__ENV__.context_modules, label: "top level")
defmodule M do
  IO.inspect(__ENV__.context_modules, label: "inside M module")
  defmodule N do
    IO.inspect(__ENV__.context_modules, label: "inside nested N module")
  end
end

#โ‡’ top level: []
#โ‡’ inside M module: [M]
#โ‡’ inside nested N module: [M.N, M]

So, I simply split the AST to inject into two parts, one that made sence only within a module context, and another one, that I might have injected anywhere. And then I simply conditionally concatenated them.

defmacro __using__(opts \\ []) do
  modulewise =
    case __CALLER__.context_modules do
      [] -> []
      [_some | _] -> build_modulewise_ast()
    end

  modulewise ++ build_module_agnostic_ast()
end

Two functions above, starting with build_ are out of scope here, they simply build the AST.

That was it. Now I am able to call use MyFancy from .iex.exs .


Happy context-depending!