Search code examples
macroselixir

Is it possible to define `case` clauses with an elixir macro?


I'd like to define a subset of the cases in my case clause with a macro. Unfortunately, I'm getting a syntax error. Here's what I have:

defmodule Yacht do
  @type category ::
          :ones
          | :twos
          | :threes
          | :fours
          | :fives
          | :sixes

  defmacro count_case(dice, symbol, value) do
    quote do
      unquote(symbol) ->
        unquote(value) * Enum.count(unquote(dice), &(&1 == unquote(value)))
    end
  end

  @doc """
  Calculate the score of 5 dice using the given category's scoring method.
  """
  @spec score(category :: category(), dice :: [integer]) :: integer
  def score(category, dice) do
    case category do
      # these cause syntax errors if uncommented
      # count_case(dice, :ones, 1)
      # count_case(dice, :twos, 2)

      # these work as expected
      :threes ->
        3 * Enum.count(dice, &(&1 == 3))

      :fours ->
        4 * Enum.count(dice, &(&1 == 4))

      :fives ->
        5 * Enum.count(dice, &(&1 == 5))

      :sixes ->
        6 * Enum.count(dice, &(&1 == 6))
    end
  end
end

This gives the error at :threes:

** (SyntaxError) lib/yacht.ex:32: unexpected operator ->. If you want to define multiple clauses, the first expression must use ->. Syntax error before: '->'
    (elixir 1.15.5) lib/kernel/parallel_compiler.ex:377: anonymous fn/5 in Kernel.ParallelCompiler.spawn_workers/8

I found this forum thread about defining cases with macros, but it recommends defining the entire case as syntax blocks and there are some extra cases not shown here that I don't want to do that with.

So. Is there a way to define a single case item with a macro?


Solution

  • TL;DR Technically, it’s possible, but in a way, you won’t like let alone use it.


    Let’s start with distilling the issue. This is the MRE of your code.

    defmodule Yacht do
      defmacro count(symbol, value) do
        quote do
          unquote(symbol) -> unquote(value)
        end
      end
    
      def score(category) do
        case category do
          # this causes `unexpected operator ->`
          count(:one, 1)
    
          # this works as expected
          :two -> 2
        end
      end
    end
    

    What happens when we have generic inline clauses? Let’s see.

    quote do
      case category do
        :one -> 1
        :two -> 2
      end
    end
    #⇒ {:case, [],
    #  [
    #    {:category, [], Elixir},
    #    [do: [{:->, [], [[:one], 1]}, {:->, [], [[:two], 2]}]]
    #  ]}
    

    As one might see, case clauses are stacked into a list under do block in the AST. Unfortunately, there is no unquote variant allowing us to embed our elements into the list, separated with spaces in code. The chicken-egg-like issue is appearing here, for our code to be understandable by a parser, we want to inject AST into something that was not yet compiled to AST.

    The best resort would be to compile the rest of the clauses into AST and inject our AST into it. Somewhat alongside the below would work

    defmodule Yacht do
      defmodule Counter do
        def count(symbol, value) do
          quote do
            unquote(symbol) -> unquote(value)
          end
        end
        def counts(symbol_values) do
          for [symbol, value] <- symbol_values, do: hd(count(symbol, value))
        end
      end
    
      def score(category) do
        case category do
          [unquote_splicing(
            Counter.counts([[:one, 1]]) ++ 
            quote do: (:two -> 2))]
        end
      end
    end
    

    Resulting in:

    iex|💧|2 ▶ Yacht.score(:one)
    1
    iex|💧|3 ▶ Yacht.score(:two)
    2
    

    As stated above, this is not something one wants to use in the real project and I put the answer only to dive it all turtles down.


    Sidenote: another module is needed to make Yacht.Counter.counts/1 available during compilation of Yacht.