1
votes

I am working on macro which would take a function and add some additional functionality. Eg.:

This:

  defstate this_works(a, b) do
    a + b + 1
  end

Should be converted to this:

  def this_works(a, b) do
    IO.puts("LOGGING whatever")
    a + b + 1
  end

This is what I have so far. Try running this piece of code in iex:

  defmodule MyMacro do
    defmacro defstate(ast, do: block) do
      {fn_atom, _} = Macro.decompose_call(ast)

      quote do
        def unquote(fn_atom)(var!(a), var!(b)) do
          IO.puts("LOGGING")
          unquote(block)
        end
      end
    end
  end

  defmodule Test1 do
    import MyMacro

    defstate this_works(a, b) do
      a + b + 1
    end
  end

  Test.this_works(1, 2)

This works as expected.

Now, this module does not compile:

  defmodule Test2 do
    import MyMacro

    defstate this_fails(a, b) 
      when 1 < 2 
      when 2 < 3 
      when 3 < 4 do
      a + b + 1
    end
  end

The only change is that I added a guard and macro is unable to deal with that.

How can I improve MyMacro.defstate to make it work with a function with any number of guards?

2

2 Answers

4
votes

If you inspect fn_atom with the defstate this_fails(a, b) when 1 < 2, you'll see that it's :when instead of :this_fails. This is because of how when expressions are represented in the Elixir AST:

iex(1)> quote do
...(1)>   def foo, do: 1
...(1)> end
{:def, [context: Elixir, import: Kernel],
 [{:foo, [context: Elixir], Elixir}, [do: 1]]}
iex(2)> quote do
...(2)>   def foo when 1 < 2, do: 1
...(2)> end
{:def, [context: Elixir, import: Kernel],
 [{:when, [context: Elixir],
   [{:foo, [], Elixir}, {:<, [context: Elixir, import: Kernel], [1, 2]}]},
  [do: 1]]}

You can fix this using some pattern matching:

defmodule MyMacro do
  defmacro defstate(ast, do: block) do
    f = case ast do
      {:when, _, [{f, _, _} | _]} -> f
      {f, _, _} -> f
    end

    quote do
      def unquote(ast) do
        IO.puts("LOGGING #{unquote(f)}")
        unquote(block)
      end
    end
  end
end

defmodule Test do
  import MyMacro

  defstate this_works(a, b) do
    a + b + 1
  end

  defstate this_works_too(a, b) when a < 2 do
    a + b + 1
  end
end

defmodule A do
  def main do
    IO.inspect Test.this_works(1, 2)
    IO.inspect Test.this_works_too(1, 2)
    IO.inspect Test.this_works_too(3, 2)
  end
end

A.main

Output:

LOGGING this_works
4
LOGGING this_works_too
4
** (FunctionClauseError) no function clause matching in Test.this_works_too/2

    The following arguments were given to Test.this_works_too/2:

        # 1
        3

        # 2
        2

    a.exs:24: Test.this_works_too/2
    a.exs:33: A.main/0
    (elixir) lib/code.ex:376: Code.require_file/2

(I also changed the unquote after def to make sure the when clause is preserved.)

0
votes

The call to defstate is expanded at compile time to the things in the quote block from your defmacro. As such, guard expressions will not be applied to the macro call directly, because at compile time, the function you're defining inside is not called.

So you have to grab the :when tuple yourself and add the guards yourself:

defmodule MyMacro do
  defmacro defstate({:when, _, [ast, guards]}, do: block) do
    {fn_atom, _} = Macro.decompose_call(ast)

    quote do
      def unquote(fn_atom)(var!(a), var!(b)) when unquote(guards) do
        IO.puts("LOGGING")
        unquote(block)
      end
    end
  end
end

Note how I match for a {:when, _, [ast, guards]} tuple now.

When you call a macro with a guard, it will put the original ast inside the first item of the arguments list, and the guard expression inside the second item.

Note that you'll still have to define a catch-all macro definition below this one in case you want to use your macro without guard clauses.