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.
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 indef foo(s) when is_struct(s, URI)
:match
means it gets expanded inside a match, as indef 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!