How do I recursively call a macro?

So this is my first attempt at creating a macro, so I’m sure I’m missing something dumb, but I couldn’t find anything via searching.

I have a function that can return a list with different type of values depending on what was passed in. However, for my purposes I don’t care what order the values exist in the list, I just need to test that it has all the values that I expect it to have. 99% of the time ordering won’t matter and it can cause my tests to become brittle for little gain.

So I decided to make a macro with the idea that it can iterate over a list performing the specified match against each element. If at least one matches then we are good but if none match I want to raise an error.

So far I have come up with the following code:

defmodule ListAssertions do
  defmacro contains([], match_expression) do
    string_representation = Macro.to_string(match_expression) 

    quote do
      raise("No entries in the list matched the expression: #{unquote(string_representation)}")
    end
  end

  defmacro contains([head | tail], match_expression) do
    quote do
      try do
        unquote(head) = unquote(match_expression) # Don't continue if we found a match
      rescue
        MatchError -> contains(tail, match_expression)
      end
    end
  end
end

This works fine when an empty list is provided (e.g. ListAssertions.contains([], 1)) but this fails when a non-empty list is provided (e.g. ListAssertions.contains([1,2], 1)) with the following error:

warning: variable tail is unused
list_assertions.exs:10

** (CompileError) list_assertions.exs:25: undefined function contains/2
(stdlib) lists.erl:1337: :lists.foreach/2
list_assertions.exs:21: (file)
(elixir) lib/code.ex:363: Code.require_file/2

I’m assuming this is because it’s expanding into the literal call contains() function call, which can’t be resolved at final compile time, since after expansion there is no function called contains.

However, I’ve been unable to figure out the right way to resolve this. Anyone have any insight into how to write this?

3 Likes

You cannot call contains because then you are assuming contains will be imported into the user context. You should use the fully qualified name as well as make sure you call unquote(tail):

ListAssertions.contains(tail, match_expression)

Finally, there are two other issues with your macro. First, match_expression is unquoted multiple times, which means that it will be executed multiple times. For example, if you do contains([:error, :error, :error], IO.inspect(:ok)), you should see :ok printed 3 times, which is unexpected. Finally, there is no reason to use try/rescue. Instead, you can use case and match on the head.

3 Likes

Thanks for the response. Good call about using case instead of try, not sure why I thought about using try.

But fully qualifying it didn’t seem to work, at least like so:

defmodule ListAssertions do
  defmacro contains([], match_expression) do
    string_representation = Macro.to_string(match_expression) 

    quote do
      raise("No entries in the list matched the expression: #{unquote(string_representation)}")
    end
  end

  defmacro contains([head | tail], match_expression) do
    quote do
      case unquote(head) do
        unquote(match_expression) -> :ok
        _ -> ListAssertions.contains(tail, match_expression)
      end
    end
  end
end

I now get:

iex(1)> require ListAssertions
ListAssertions
iex(2)> ListAssertions.contains([1], 1)
** (FunctionClauseError) no function clause matching in ListAssertions.contains/2
expanding macro: ListAssertions.contains/2
iex:2: (file)
expanding macro: ListAssertions.contains/2
iex:2: (file)

The macro expands to:

iex(2)> Macro.expand(quote(do: ListAssertions.contains([1], 1)), __ENV__ )
{:case, [],
 [1,
  [do: [{:->, [], [[1], :ok]},
    {:->, [],
     [[{:_, [], ListAssertions}],
      {{:., [],
        [{:__aliases__, [alias: false, counter: -576460752303423486],
          [:ListAssertions]}, :contains]}, [],
       [{:tail, [counter: -576460752303423486], ListAssertions},
        {:match_expression, [counter: -576460752303423486],
         ListAssertions}]}]}]]]}
2 Likes

Ok so it looks like the issue was that I needed to unquote tail and match_expession,

My current code is

defmodule ListAssertions do
  defmacro contains([], match_expression) do
    string_representation = Macro.to_string(match_expression) 

    quote do
      raise("No entries in the list matched the expression: #{unquote(string_representation)}")
    end
  end

  defmacro contains([head | tail], match_expression) do
    quote do         
      case unquote(head) do
        unquote(match_expression) -> :ok
        _ -> ListAssertions.contains(unquote(tail), unquote(match_expression))
      end
    end
  end
end

Thanks for the help., this makes perfect sense now.

2 Likes

You are still forgetting to unquote(tail). This means the macro is seeing a
variable at compile time which won’t match your list clauses.

2 Likes

Sorry for spamming my own question.

Actually this does not work, and it makes perfect sense why.

This works fine where the list is passed in at compile time (e.g. ListAssertions.contains([1,2], 1)) because it knows how big to expand.

However, the way I intend to use this macro is along the lines of ListAssertions.contains(resulting_list, %MyStruct{a: :b}), to make sure that at least one of the elements in the list that my function nreturns contains an element of MyStruct where the a key is :b regardless of what the other struct fields are.

This is an easy macro in a language with mutability as you can easily output a loop, but I don’t see a way to do this in Elixir due to immutability. I can’t (I don’t think) create a __using__ macro that defines a private function to use and loop through because I would need to pass the match expression as an argument, and my understanding is you can’t do that.

Although now that I am typing this out and rubber ducking I can probably solve it by creating a __using__ macro that defines a private function that takes in a list and a fun that returns true or false if the match succeeds or not, and then go about it that way.

1 Like

Yep an anonymous function did the trick. Here’s the final code if anyone is interested:

defmodule ListAssertions.AssertionError do
  defexception expression: nil,
               message: nil

  def exception(value) do
    msg = "No entries in the list matched the expression: #{value}"
    %__MODULE__{expression: value, message: msg}
  end
end

defmodule ListAssertions do
  defmacro __using__(_options) do
    quote do
      import ListAssertions

      defp assert_list_contains([], _fun, expression_as_string) do
        raise(ListAssertions.AssertionError, expression_as_string)  
      end

      defp assert_list_contains([head | tail], test_fun, expression_as_string) do
        case test_fun.(head) do
          true -> :ok
          false -> assert_list_contains(tail, test_fun, expression_as_string)
        end
      end
    end
  end

  defmacro contains(list, match_expression) do
    string_representation = Macro.to_string(match_expression)

    test_fun = quote do
      fn(item) -> 
        case item do
          unquote(match_expression) -> true
          _ -> false
        end
      end
    end 

    quote do
      assert_list_contains(unquote(list), unquote(test_fun), unquote(string_representation))
    end
  end
end

And verified with:

defmodule Test do
  use ListAssertions

  defstruct a: nil, b: nil

  def run do
    a = [1,%__MODULE__{a: 5, b: 7},3]
    ListAssertions.contains(a, %__MODULE__{b: 7})

    IO.puts("Match success")
  end
end

Test.run
1 Like

Hi. Sorry for bumping an old thread. My question may be very stupid, but can anyone explain me, why this pointless code runs an infinite loop?

defmodule M do
  defmacro m(0) do
    quote do: IO.puts "zero"
  end

  defmacro m(number) do
    quote do
      case unquote(number) do
        1 -> IO.puts "one"
        _ -> M.m(unquote(number) - 1)
      end
    end
  end
end
require M
M.m(2)
1 Like

Because it expands to this:

case 2 do
  1 -> IO.puts "one"
  _ -> M.m(2 - 1)
end

Which expands to

case 2 do
  1 -> IO.puts "one"
  _ -> case 2 - 1 do
    1 -> IO.puts "one"
    _ -> M.m(2 - 1 - 1)
  end
end

Which expands to

case 2 do
  1 -> IO.puts "one"
  _ -> case 2 - 1 do
    1 -> IO.puts "one"
    _ -> case 2 - 1 do
      1 -> IO.puts "one"
      _ -> case 2 - 1 - 1 do
        1 -> IO.puts "one"
        _ -> M.m(2 - 1 - 1 - 1)
    end
  end
end

And so on…


As you can see, recursing over a changing value in a macro is not that good, you need to enforce calculation in the macro. You can use functions from Macro for that. But this will work only for values which are available literally at compile time as an argument to the macro. Not for e.g. M.m(x).

If though, you iterate over the AST and consume it, recursion should work.

1 Like

Thank you for your reply! Yes, I get it. I just was confused that this code of KallDrexx does not run an infinite recursion:

defmodule ListAssertions do
  defmacro contains([], match_expression) do
    string_representation = Macro.to_string(match_expression) 

    quote do
      raise("No entries in the list matched the expression: #{unquote(string_representation)}")
    end
  end

  defmacro contains([head | tail], match_expression) do
    quote do         
      case unquote(head) do
        unquote(match_expression) -> :ok
        _ -> ListAssertions.contains(unquote(tail), unquote(match_expression))
      end
    end
  end
end
1 Like

Because his hits the base case, your’s never hits a 0 because a 0 is never passed to it, if you notice the expansion is 2-1-1, which although equals 0, is not the 0 AST, and you are matching the 0 AST, not the 0 value.

Got it! Thank you

1 Like