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?
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
.