Conditional context for macros
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.
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
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
:guard. These three contexts mean, respectively, the following
:guardmeans the macro gets expanded within a function guard context, as in
def foo(s) when is_struct(s, URI)
:matchmeans it gets expanded inside a match, as in
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.
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.
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