Conditional context for macros
· 3 min read
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
-
:guardmeans the macro gets expanded within a function guard context, as indef foo(s) when is_struct(s, URI) -
:matchmeans it gets expanded inside a match, as indef foo(is_struct(s)) -
nilmeans 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!