Hi,
I’m experimenting with macros to create a framework for defining a sort of tests.
I’m trying to figure out how to create a graph of function calls between these macros at compile-time but I’m unsure how to do it.
I have a macro defined as follows.
defmodule MyMacro do
defmacro test(name, do: body) do
quote do
def unquote(name) do
unquote(name)
end
end
end
end
defmodule MyModule do
require MyMacro
import MyMacro
test first() do
:first
end
test second() do
x = first()
y = third()
end
test third() do
:third
end
What I would like to know, at compile-time, is which test
definitions invoke which other test
definitions.
So, the result would be that I can inject some module attributes that allow me to determine which test
will call which test
. In the example above this should be something like: invokes({MyModule, :second}) ==
[{MyModule, :first}, {MyModule, :third}]`.
Attempt using postwalk
What I have tried so far is using Macro.postwalk
to traverse the AST in defmacro test
, but in that context you only have access to the __CALLER__
env, and the AST of that particular test
definition. With this information I can only determine which functions from other modules are imported and called, and which qualified functions are called. The issue I have here is that I don’t know when an unqualified invocation is actually another function in the module, or something else.
For example, the AST of the second()
definition above looks as follows.
{:__block__, [],
[
{:=, [line: 10, column: 7],
[{:x, [line: 10, column: 5], nil}, {:first, [line: 10, column: 9], []}]},
{:=, [line: 11, column: 7],
[{:y, [line: 11, column: 5], nil}, {:third, [line: 11, column: 9], []}]}
]}
__CALLER__
during macro expansion does not contain the other definitions in the module, so I cannot determine whether they are defined in this module or not.
Ideas
Another idea I had was to use the __after_compile__
callback to then do manual analysis on the module. In the __after_compile__
body you have access the an env that contains which module this invocation is about. Since the module is already compiled, I also have access to __info__(:functions)
for that module.
Using this information, I can do a Macro.postwalk
on the entire module definition and determine the calls inside each test
.
The limitation here is that it’s no longer possible to inject code/module attributes in the module itself. So I don’t really know where to put the result of the analysis at this point.
defmodule MyMacro do
defmacro __using__(_options) do
quote do
import unquote(__MODULE__)
# module attribute that holds all the examples
Module.register_attribute(__MODULE__, :test, accumulate: true)
@after_compile unquote(__MODULE__)
end
end
def __after_compile__(env, _bytecode) do
module =
env.module.__info__(:functions)
|> IO.inspect(label: "functions in #{inspect(env.module)}")
# analyze the compiled module to build a dependency graph
analyze_module_here_using_the_env(env.module)
IO.inspect(env)
end
defmacro test(name, do: body) do
quote do
# @test {unquote(name), unquote(body)}
def unquote(name) do
unquote(name)
end
end
end
end
So in brief, how could I solve this in an elegant way? I’d like to not do these calculations at runtime, but have the information ready at compile-time.