Expand macro in Macro.prewalk/2

I would like to “expand” every macros in a Macro.prewalk/2 using the __CALLER__ context.


Basically, I’m trying to create a macro that create a new module with a function and a guard inside.

Here is a simplified example of what I’m trying to achieve:

defmodule DSL do
  defmacro deffunction({:when, _, [name, guards]}) do
    quote do
      defmodule CustomModule do
        def unquote(name)(var!(arg1)) when unquote(guards) do
          IO.inspect(:ok)
        end
      end
    end
  end
end

defmodule MyApp do
  import DSL, only: [deffunction: 1]

  defguard is_true(value) when value == "1"

  deffunction :my_function when is_true(arg1)
end

Sadly, this piece of code doesn’t compile and I have the following error:

** (CompileError) test.exs:36: cannot find or invoke local is_true/1 inside guards. Only macros can be invoked in a guards and they must be defined before their invocation. Called as: is_true(arg1)

It doesn’t work because CustomModule is generated like this:

defmodule CustomModule do
  def my_function(arg1) when is_true(arg1) do
    IO.inspect(:ok)
  end
end

Instead, I’d like to expand the is_true/1 macro to output something like this

defmodule CustomModule do
  def my_function(arg1) when arg1 == "1" do
    IO.inspect(:ok)
  end
end

I tried Macro.expand(guards, __CALLER__) inside my deffunction/1 macro but it doesn’t work and generate the same AST:

{:is_true, [line: 41], [{:arg1, [line: 41], nil}]}

A simple option would be to move both the def and the defguard inside an explicit defmodule CustomModule, but it’s hard to say how bad that would be for the actual un-anonymized situation.

A sign that something’s going to be tricky in macro-land is if it’s not possible without macros, and the specific structure you’re asking for isn’t possible:

defmodule WatOuter do
  defguard is_wat(x) when x == :wat
  defmodule Huh do
    def wat(x) when is_wat(x) do
      :ok
    end
  end
end
# result:
** (CompileError) iex:4: cannot find or invoke local is_wat/1 inside guards. Only macros can be invoked in a guards and they must be defined before their invocation. Called as: is_wat(x)
    (elixir 1.14.0) src/elixir_expand.erl:618: :elixir_expand.expand_arg/3
    (elixir 1.14.0) src/elixir_expand.erl:556: :elixir_expand.expand_list/5
    (elixir 1.14.0) src/elixir_expand.erl:466: :elixir_expand.expand/3
    (elixir 1.14.0) src/elixir_clauses.erl:70: :elixir_clauses.guard/3
    (elixir 1.14.0) src/elixir_clauses.erl:36: :elixir_clauses.def/3
    (elixir 1.14.0) src/elixir_def.erl:197: :elixir_def."-store_definition/10-lc$^0/1-0-"/3
    iex:1: (file)

Another alternative: trying to import WatOuter in Huh fails since WatOuter is still being compiled.

The closest I could get was this:

defmodule WatOuter do
  defmodule Guards do
    defguard is_wat(x) when x == :wat
  end

  defmodule Huh do
    import Guards

    def wat(x) when is_wat(x) do
      :ok
    end
  end
end

You could obscure some of this with a little bit more DSL and end up with:

defmodule MyApp do
  import DSL, only: [deffunction: 1, guards: 1]

  guards do
    defguard is_true(value) when value == "1"
  end

  deffunction :my_function when is_true(arg1)
end

but again, I don’t have much of an aesthetic sense for how this affects the vibe of the un-anonymized versionl.

2 Likes

Unfortunately I can’t, as it’s the purpose of my macro to generate this module.


After a few tests, I noticed that I could expand macros from another module but not the guards. So I took a look at the source code of defguard and noticed that it uses an env = %{env | context: :guard} to define the context as a guard.

Testing it out, it seems to work!

defmodule DSL do
  defmacro deffunction({:when, _, [name, guards]}) do
    env = %{__CALLER__ | context: :guard}
    expanded_guards = Macro.prewalk(guards, &Macro.expand(&1, env))

    quote do
      defmodule CustomModule do
        def unquote(name)(var!(arg1)) when unquote(expanded_guards) do
          IO.puts("Hello from inside the function!")
        end
      end
    end
    |> tap(&(&1 |> Macro.to_string() |> Kernel.<>("\n") |> IO.puts()))
  end
end

defmodule MyApp.Macros do
  defguard is_true(value) when value == "1"
end

defmodule MyApp do
  import DSL, only: [deffunction: 1]
  import MyApp.Macros

  deffunction :my_function when is_true(arg1)
end

MyApp.CustomModule.my_function("1")

It outputs:

defmodule CustomModule do
  def my_function(var!(arg1)) when :erlang.==(arg1, "1") do
    IO.puts("Hello from inside the function!")
  end
end

Hello from inside the function!

Here the call to is_true/1 is correctly replaced by :erlang.==(arg1, "1")

1 Like