Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

ElixirZone: Metaprogramming Elixir: Part 2 - James Lavin

metaprogramming_part_2.livemd

ElixirZone: Metaprogramming Elixir: Part 2 - James Lavin

Metaprogramming Costs

  • Introduces an additional layer of indirection/abstraction
  • Harder to debug code
  • Harder to understand code
  • Requires thinking about both compilation-time code execution and runtime-code execution

Jay Hayes - Elixir in Elixir

https://www.youtube.com/watch?v=p8MGNw045AE

:lol = :wat
# This does NOT throw a MatchError because it's AST describing an
# assignment, but the assignment is NOT EXECUTED

quote do
  :lol = :wat
end
quote do
  :lol = :wat
end == {:=, [], [:lol, :wat]}

If we attempt to execute/evaluate it, it will blow up:

{:=, [], [:lol, :wat]} |> Code.eval_quoted()

Jay then uses pattern-matching to define an Expression.match?/1 function returning a Boolean representing whether the argument represents a match/assignment:

Expression.match?

Expression.match? 2

num = 42
quote do: 1 + num
num = 42
quote do: 1 + unquote(num)
quote do
  1 + unquote(num)
end
|> Code.eval_quoted()

:elixir_module.compile

:elixir_module.compile 2

Tyler Pachal - Metaprogramming for Dummies

https://www.youtube.com/watch?v=DFa1bC95wxA

tyler_1 tyler_2

decompile_3 decompile_4

Sasha Fondeca - Metaprogramming With Elixir

https://www.youtube.com/watch?v=uBWFBU97Qkw

pixels_1 pixels_2 pixels_3 pixels_4 pixels_5

Marlus Saraiva - Surface, HEEX and the Power of Choice

https://www.youtube.com/watch?v=ChGSLUVe5Gs

macro components

Benefits of LiveView Surface’s Macro Components:

  • Extensibility via compile-time AST manipulation
  • Static evaluation of body/content (warn about errors, etc.)
  • Can embed other languages
  • Performance optimizations

for vs. for

Qing Wu: A Gentle Introduction to Elixir Macros

https://www.youtube.com/watch?v=CHHQ_xzv4pw (doesn’t indicate the speaker’s name) https://www.youtube.com/watch?v=X8QfT7BNE44 is from Qing Wu, who I believe is the same presenter)

gentle intro

simple timer

Billy Ceskavich: Is Elixir Just Lisp?

https://www.youtube.com/watch?v=F7qaIqcaTDc

Billy defmacro

def is a macro

defmacro def

Bryan Weber: Code Generation in Elixir

https://www.youtube.com/watch?v=-mgwW3RVI50

code gen mix

macros code tradeoffs

defmodule unquote

# Code.compile_quoted(quoted_code)

# File.write(...)

Code compilation process

compilation

Jesse Anderson: Don’t Write Macros But Do Learn How They Work

jesse_1 compilation process

literals & tuples jesse 2 jesse 3

if_ast = quote do: if(var!(x) == var!(y), do: "yeah", else: "nah")
if_ast |> Macro.to_string() |> IO.puts()
if_ast |> Code.eval_quoted(x: 7, y: 9)
if_ast |> Code.eval_quoted(x: 19, y: 19)

Andrew Summers - Domain Specific Languages and Metaprogramming in Elixir

Andrew David Summers andrew_summers_0 andrew_summers_1 andrew_summers_2 andrew_summers_3 andrew_summers_4 andrew_summers_5 andrew_summers_6 andrew_summers_7 andrew_summers_8 andrew_summers_9 andrew_summers_10 andrew_summers_11

Let’s Play With Metaprogramming!

# runtime evaluation

r = 2
:math.pi() * :math.pow(r, 2)
# ordinary function created at compile time

defmodule Area do
  def circle(radius) do
    :math.pi() * :math.pow(radius, 2)
  end
end

# runtime evaluation

Area.circle(2)
# Macro function

defmodule AreaMacro do
  require Logger

  defmacro circle(radius) do
    Logger.info("compiling...")

    quote do
      :math.pi() * :math.pow(unquote(radius), 2)
    end
  end
end

# runtime evaluation

require AreaMacro
# x = 3
# {AreaMacro.circle(2), AreaMacro.circle(x)}
AreaMacro.circle(2)

use && using/1

defmodule AreaMacro2 do
  defmacro __using__(opts) do
    case opts do
      :circle ->
        quote location: :keep, bind_quoted: [opts: opts] do
          def area(radius) do
            :math.pi() * :math.pow(radius, 2)
          end
        end

      :square ->
        quote location: :keep, bind_quoted: [opts: opts] do
          def area(side) do
            :math.pow(side, 2)
          end
        end

      other when is_binary(other) ->
        quote location: :keep, bind_quoted: [opts: opts] do
          other = other |> String.to_atom()
          IO.inspect("Do not recognize shape #{other}")
        end
    end
  end
end

defmodule Circle do
  use AreaMacro2, :circle
end

defmodule Square do
  use AreaMacro2, :square
end

# runtime evaluation

{Circle.area(2), Square.area(2)}

On p. 99 of “Metaprogramming Elixir,” Chris tells us to avoid using use just for mix-ins.

alias or import is fine for just pulling in functions to DRY up your code. Or you can just call functions using their fully qualified names.

Circle
# runtime evaluation

if 1 + 2 == 3, do: "this", else: "that"
# runtime evaluation

if 1 + 2 == 3 do
  "this"
else
  "that"
end
# runtime evaluation

if(Kernel.==(Kernel.+(1, 2), 3), [{:do, "this"}, {:else, "that"}])
# compile without evaluation

quote do
  if(1 + 2 == 3, do: "this", else: "that")
end
# compile, then evaluate

quote do
  if(1 + 2 == 3, do: "this", else: "that")
end
|> Code.eval_quoted()
one = 1
two = 2
three = 3
if(one + two == three, do: "this", else: "that")
quote do
  if(one + two == three, do: "this", else: "that")
end
quote do
  if(var!(one) + var!(two) == var!(three), do: "this", else: "that")
end
|> Code.eval_quoted(one: 1, two: 2, three: 3)
quote do
  if(var!(one) + var!(two) == var!(three), do: "this", else: "that")
end
|> Code.eval_quoted(one: 1, two: 2, three: 4)
quote do
  if(one + two == three, do: "this", else: "that")
end

Star Wars MadLibs

# Fails with ** (CompileError) nofile:1: undefined variable "three"

quote do
  if(var!(one) + var!(two) == var!(three), do: "this", else: "that")
end
|> Code.eval_quoted(one: 1, two: 2)

Delegate to Erlang functions whenever possible

plus

Macro.postwalk() …and Macro.prewalk()

This is a MUCH simplified version of just one part of Argentinian Lucas San Román’s 3-part series at https://dorgan.netlify.app/posts/2021/04/the_elixir_ast/. I recommend the whole series to you!

filepath =
  "/Users/jameslavin/Git/ElixirZone/Elixir/LiveBooks/Elixir_104_Metaprogramming/AreaMacro2.ex"

areamacro2_ast =
  filepath
  |> Path.expand()
  |> File.read!()
  |> Code.string_to_quoted!(columns: true)
defmodule Checker do
  @warning "You can create only a finite number of atoms. You should not be generating them dynamically from strings."

  def check(ast) do
    {_ast, issues} = Macro.postwalk(ast, [], &handle_node/2)
    issues
  end

  defp handle_node({{:., _, [{:__aliases__, _, [:String]}, :to_atom]}, meta, _args} = node, acc) do
    issue = %{
      warning: @warning,
      line: meta[:line],
      column: meta[:column]
    }

    {node, [issue | acc]}
  end

  defp handle_node(node, acc), do: {node, acc}
end

areamacro2_ast |> Checker.check()

Macro.prewalk and Macro.postwalk can be used not only to report on AST but to MODIFY AST, as the documentation explains:

> Returns a new AST where each node is the result of invoking fun on each corresponding node of ast. > Example: > Macro.prewalk()

bind_quoted & execution context of unquote()

The following is one small portion of Daniel Xu’s excellent article, “The Minimum Knowledge You Need to Start Metaprogramming in Elixir,” https://dockyard.com/blog/2016/08/16/the-minumum-knowledge-you-need-to-start-metaprogramming-in-elixir

defmodule M do
  defmacro my_macro(name) do
    IO.puts(1)

    quote do
      IO.puts(4)
      unquote(IO.puts(2))
      IO.puts("hello, #{unquote(name)}")
    end
  end
end

defmodule Create do
  import M

  IO.puts(3)
  my_macro("hello")
end
defmodule M do
  defmacro my_macro(name) do
    IO.puts(1)

    quote bind_quoted: [name: name] do
      IO.puts(4)

      def unquote(name)() do
        unquote(IO.puts(2))
        IO.puts("hello, #{unquote(name)}")
      end
    end
  end
end

defmodule Create do
  import M

  IO.puts(3)
  my_macro(:hello)
end
defmodule M do
  defmacro my_macro(name) do
    IO.puts(1)

    quote bind_quoted: [name: name] do
      IO.puts(4)

      def unquote(name)() do
        unquote(IO.puts(2))
        IO.puts("hello, #{unquote(name)}")
      end
    end
  end
end

defmodule Create do
  import M

  IO.puts(3)

  [:foo, :bar]
  |> Enum.each(&my_macro(&1))
end
{Create.foo(), Create.bar()}

Macro.escape()

long_tuple = {:x, :y, :z}

inspect_long_tuple_ast = quote do: IO.inspect(unquote(long_tuple))
# throws an ArgumentError because {:x, :y, :z} is not valid AST

# inspect_long_tuple_ast |> Code.eval_quoted()
long_tuple = {:x, :y, :z} |> Macro.escape()
# returns {:{}, [], [:x, :y, :z]}

inspect_long_tuple_ast = quote do: IO.inspect(unquote(long_tuple))

inspect_long_tuple_ast |> Code.eval_quoted()

inlining

&Kernel.+/2
quote do: 1 + 2
quote do
  Kernel.+(1, 2)
end

Environments (ENV & CALLER)

IO.inspect(__ENV__, limit: :infinity, printable_limit: :infinity, structs: false)

Why not just use functions?

defmodule Our do
  def if_fun(condition, do: this, else: that) do
    if(condition) do
      this
    else
      that
    end
  end
end

if macro

Our.if_fun 1 == 1 do
  IO.inspect("true")
else
  IO.inspect("false")
end
Our.if_fun 1 == 3 do
  IO.inspect("true")
else
  IO.inspect("false")
end

Function args are always evaluated when the function gets called!

To avoid that, we use macros, which receive AST, which represents unevaluatable data structures that have not (yet) been evaluated.

Nicholas J. Henry - The Upside Down Dimension of Elixir

def vs defmacro

Adi Iyengar: The Pillars of Metaprogramming in Elixir

quote as AST

Lizzie Paquette: Responsible Code Generation

https://www.youtube.com/watch?v=55-X7rSw8M0

code vs AST

contexts

macro contexts

Brex.Struct

defmodule MyMod do
  defmacro who_am_i() do
    IO.inspect(__MODULE__, label: "Macro Context")
    IO.inspect(__CALLER__.module, label: "Caller Env")

    quote do
      IO.inspect(__MODULE__, label: "Caller Context")
      IO.inspect(unquote(__MODULE__), label: "Value from Macro Context")
    end
  end
end
defmodule MyCaller do
  require MyMod
  MyMod.who_am_i()
end

Dynamic function names (using “unquote fragments”)

For more on unquote fragments, see:

defmodule TalkingHeads do
  @funs [ask_yourself: "How did I get here?", no_party: "No disco!"]
  # @funs [{:ask_yourself, "How did I get here?"}, {:no_party, "No disco!"}]

  for {k, v} <- @funs do
    def unquote(k)(answer), do: answer
    def unquote(k)(), do: unquote(v)
  end
end
TalkingHeads.ask_yourself()
TalkingHeads.no_party()
TalkingHeads.ask_yourself("By taxi")

After I run:

elixirc talking_heads.ex
mix decompile Elixir.TalkingHeads.beam --to expanded
vi Elixir.TalkingHeads.ex

I see basically what the compiled BEAM file logic looks like:

defmodule TalkingHeads do
  def no_party(answer) do
    answer
  end

  def no_party() do
    "No disco!"
  end

  def ask_yourself(answer) do
    answer
  end

  def ask_yourself() do
    "How did I get here?"
  end
end

quote/2 transforms Elixir code –> an AST (“abstract syntax tree”) data structure

# The input `1` is ordinary Elixir code. The output `1` is that value represented as AST
quote do: 1
# Elixir -> AST
# `sum(1,2)` has a different representation in AST than in Elixir code
quote do: sum(1, 2)
# The AST this generates spells out -- in a nested data structure -- 
#       the exact operations to be run.
# This is a nested data structure representing operations to be run.
# This runnable specification does NOT execute those operations.
# In fact, this is not runnable as is because `sum` is not even defined.
# Executing the AST this generates will require a definition for `sum/2`:
quote do: sum(sum(sum(1, 3), sum(2, 4)), sum(5, 6))
# The following cannot be evaluated because we have not provided a definition of `sum`
quote do
  sum(1, 2)
end
|> Code.eval_quoted()
defmodule MyMath do
  def sum(a, b), do: a + b
end

quote do
  MyMath.sum(1, 2)
end
|> Code.eval_quoted()
quote do
  MyMath.sum(MyMath.sum(MyMath.sum(1, 3), MyMath.sum(2, 4)), MyMath.sum(5, 6))
end
|> Code.eval_quoted()
quote do
  import MyMath
  sum(sum(sum(1, 3), sum(2, 4)), sum(5, 6))
end
quote do
  import MyMath
  sum(sum(sum(1, 3), sum(2, 4)), sum(5, 6))
end
|> Code.eval_quoted()
quote do
  1 + 2
end
|> Code.eval_quoted()
quote do: sum(1, 2) |> Code.eval_quoted()
quote do: random_variable_name
random_variable_name = 7

quote do: random_variable_name
random_variable_name = 9

quote do: unquote(random_variable_name)
# AST cannot be further converted
{:sum, [], [1, 2]}
# Elixir -> AST -> String
quote do
  sum(1, 2)
end
|> Macro.to_string()
# Using `quote do: ...`, you'll want to add parentheses or the compiler will likely
# misinterpret your code
quote(do: sum(1, 2))
|> Macro.to_string()
# AST -> String
{:sum, [], [1, 2]} |> Macro.to_string()
# AST -> String -> AST
{:sum, [], [1, 2]} |> Macro.to_string() |> Code.string_to_quoted() |> elem(1)
# AST -> String -> AST
{:%{}, [], [{:a, 1}, {:b, 2}]} |> Macro.to_string() |> Code.string_to_quoted() |> elem(1)
# AST -> String -> AST
{:{}, [], [1, 2, 3, 4]} |> Macro.to_string() |> Code.string_to_quoted() |> elem(1)
# AST -> String -> AST
{:{}, [], [1, 2]} |> Macro.to_string() |> Code.string_to_quoted() |> elem(1)
# AST -> String -> AST
[1, 2, 3, 4, 5] |> Macro.to_string() |> Code.string_to_quoted() |> elem(1)
# AST -> String -> AST
[{:a, 1}, {:b, 2}, {:c, 3}, {:d, 4}, {:e, 5}]
|> Macro.to_string()
|> Code.string_to_quoted()
|> elem(1)
# AST -> String -> AST
[a: 1, b: 2, c: 3, d: 4, e: 5] |> Macro.to_string() |> Code.string_to_quoted() |> elem(1)
fields = [
  %{name: "id", type: "integer"},
  %{name: "name", type: "String.t()"},
  %{name: "status", type: "atom()"}
]

{:%{}, [], fields |> Enum.map(fn x -> {x.name, x.type} end)}
fields = [
  %{name: "id", type: "integer"},
  %{name: "name", type: "String.t()"},
  %{name: "status", type: "atom()"}
]

{:%, [],
 [{:__MODULE__, [], Elixir}, {:%{}, [], fields |> Enum.map(fn x -> {x.name, x.type} end)}]}
|> Macro.to_string()

mail

Understanding DSL “magic” (including Elixir itself)

A blessing of powerful languages like Ruby and Elixir – which provide users the ability to create domain-specific languages (DSLs) – is that you can greatly simplify tasks via DSLs.

But DSLs come with a curse… they add layers of indirection, which can make it harder to understand and debug code that uses those DSLs.

DSLs are “magic.” Magical spells are powerful but also dangerous.

Given that much of Elixir is created using the “magic” of macros and metaprogramming, you can’t fully understand Elixir, leverage its full power, or help extend it without understanding macros and metaprogramming.

92% of Elixir was written in Elixir… via the magic of metaprogramming:

elixir is written in elixir

Kernel.SpecialForms

SpecialForms

case

case definition

^^^ We have touched the metal / hit the event horizon, beyond which we cannot proceed.

Kernel.then/2

then

%{}
|> then(&amp;Map.put(&amp;1, :baseball, "Red Sox"))
|> then(&amp;Map.put(&amp;1, :football, "Patriots"))
|> then(&amp;Map.put(&amp;1, :basketball, "Celtics"))
|> then(&amp;Map.put(&amp;1, :hockey, "Bruins"))
|> then(&amp;Map.put(&amp;1, :real_football, "Revolution"))

Macro.expand_once/2

unless macro

# This is compiled and then immediately evaluated
unless true, do: :a, else: :b
quote do
  unless true, do: :a, else: :b
end
|> Macro.expand(__ENV__)
# This is compiled and then immediately evaluated, but it throws an error
# because the variable `unset_value` has no value
unless unset_value, do: "yay", else: "boo"
# This is transformed into AST but NOT immediately evaluated:
quote do
  unless unset_value, do: :a, else: :b
end
quote do
  unless unset_value, do: :a, else: :b
end
|> Macro.to_string()
|> String.split("\n")
|> Enum.each(&amp;IO.puts(&amp;1))
# This is transformed into AST but NOT immediately evaluated,
# but one round of "macro expansion" is applied:
quote do
  unless unset_value, do: :a, else: :b
end
|> Macro.expand_once(__ENV__)
# We can see this more clearly:
quote do
  unless unset_value, do: :a, else: :b
end
|> Macro.expand_once(__ENV__)
|> Macro.to_string()
|> String.split("\n")
|> Enum.each(&amp;IO.puts(&amp;1))
# This is transformed into AST but NOT immediately evaluated,
# but two rounds of "macro expansion" are applied:
quote do
  unless unset_value, do: :a, else: :b
end
|> Macro.expand_once(__ENV__)
|> Macro.expand_once(__ENV__)
|> Macro.to_string()
|> String.split("\n")
|> Enum.each(&amp;IO.puts(&amp;1))

defmacro

  • defmacro takes its arguments in as AST, not as ordinary Elixir values
  • unquote/1 (inside quote do ... end) transforms an AST argument into ordinary Elixir code by evaluating its current Elixir value, allowing you to pull in a value during code compilation (i.e., “at compile time”) from the environment outside the quote do ... end block.
  • Everything inside the quote do ... end gets transformed from ordinary Elixir code into AST
defmodule OurThen do
  defmacro our_then(value, fun) do
    fun |> Macro.to_string() |> IO.inspect(label: "Compile time fun")
    value |> Macro.to_string() |> IO.inspect(label: "Compile time value")

    quote do
      # becomes (at compilation time below): (fn x -> x * 2 end).(1)
      unquote(fun).(unquote(value))
    end
  end
end
require OurThen

1 |> OurThen.our_then(fn x -> x * 2 end)
# becomes (at compilation time):
# (fn x -> x * 2 end).(1)

Kernel.tap/2

code for tap

%{a: 1}
|> tap(&amp;IO.inspect(&amp;1.a))
|> Map.update!(:a, &amp;(&amp;1 + 100))
defmodule OurMod do
  defmacro our_tap(value, fun) do
    fun |> Macro.to_string() |> IO.inspect(label: "Compile time fun")
    value |> Macro.to_string() |> IO.inspect(label: "Compile time value")

    quote bind_quoted: [fun: fun, value: value] do
      # after bind_quoted:
      #   * `fun` will represent the uninvoked function itself
      #   * `value` will be the evaluated value
      _ = fun.(value)
      value
    end
  end
end
%{a: 1} |> Map.update!(:a, &amp;(&amp;1 * 20))
&amp;IO.inspect(&amp;1.a)
&amp;List.first/1
quote do
  &amp;List.first/1
end
|> Macro.to_string()
require OurMod

%{a: 1}
|> Map.update!(:a, &amp;(&amp;1 * 20))
|> OurMod.our_tap(&amp;IO.inspect(&amp;1.a))
|> Map.update!(:a, &amp;(&amp;1 + 100))

Kernel.SpecialForms

In a previous episode, I wasn’t clear on the difference between Kernel & Kernel.SpecialForms.

I’ve since learned that macros in Kernel are expanded while macros in Kernel.SpecialForms are, well, special, and don’t have documented implementations.

Here’s an example, the for comprehension. The documentation provides a LONG description and good examples:

for comprehension

The documentation is about 150 lines long, but here’s the entire documented “implementation”:

defmacro for

It has a secret (?) implementation.

Macros

Very heavily inspired by p. 6 of Chris McCord’s “Metaprogramming Elixir”

Chris writes “Rule 1: Don’t Write Macros” because “macros can make programs difficult to debug and reason about.”

Macros receive AST and return AST. In other words, macros transform ASTs

defmodule LogMath do
  defmacro log({:+, ctx, [left_arg, right_arg]}) do
    quote do
      require Logger
      left = unquote(left_arg)
      right = unquote(right_arg)
      Logger.debug("received '#{left} + #{right}'")
      unquote(ctx) |> IO.inspect(label: "ctx")
      left + right
    end
  end

  defmacro log({op, ctx, args}) do
    quote bind_quoted: [op: op, ctx: ctx, args: args] do
      IO.inspect(op, label: "op")
      IO.inspect(ctx, label: "ctx")
      IO.inspect(args, label: "args")
    end
  end

  defmacro log2({op, ctx, [left_arg, right_arg]}) when op in [:+, :-, :*, :/] do
    quote bind_quoted: [op: op, left: left_arg, right: right_arg, ctx: ctx] do
      require Logger
      # left = unquote(left_arg)
      # right = unquote(right_arg)
      Logger.debug("received '#{left} #{op} #{right}'")
      ctx |> IO.inspect(label: "ctx")
      # unquote(op)(left, right)
      {op, ctx, [left, right]} |> Code.eval_quoted() |> elem(0)
    end
  end

  defmacro log2({op, ctx, args}) do
    quote bind_quoted: [op: op, ctx: ctx, args: args] do
      IO.inspect(op, label: "op")
      IO.inspect(ctx, label: "ctx")
      IO.inspect(args, label: "args")
    end
  end
end
Kernel.+(3, 4)

When this code is compiled, 111 + 222 will be converted to AST – {:+, [context: Elixir, import: Kernel], [111, 222]} – and passed into LogMath.log():

require LogMath

(111 + 222)
|> LogMath.log()
(333 + 444)
|> LogMath.log()
# I added second defmacro clause to debug why this wasn't working:
quote do
  555 + 444
end
|> LogMath.log()
quote do
  Kernel.+(555, 444)
end
|> LogMath.log()

# |> Macro.escape()

Why can’t I pattern match against [import: Kernel, context: Elixir]???

(666 - 555)
|> LogMath.log2()
(666 / 3)
|> LogMath.log2()

Unless & navigating Elixir documentation

unless

unless2

Macros receive (and can pattern-match against) the AST representations of their arguments

quote do: %{a: 1, b: 2}
defmodule MyMerge do
  defmacro display({:%{}, [], _kwlist}) do
    quote do
      17
    end
  end

  # defmacro merge(first, _second) do
  #  quote do
  #    unquote(first)
  #  end
  # end
  defmacro merge({:%{}, [], first_kv_pairs}, {:%{}, [], second_kv_pairs}) do
    quote do
      first_map = unquote(first_kv_pairs) |> Enum.into(%{})
      second_map = unquote(second_kv_pairs) |> Enum.into(%{})
      Map.merge(first_map, second_map)
    end
  end
end
require MyMerge

MyMerge.display(%{})
# (quote do: %{}) |> MyMerge.display()
require MyMerge

first = quote do: %{a: 1, b: 2}

second = quote do: %{c: 3, d: 4}

MyMerge.merge(first, second)

Module.create/3

ast =
  quote do
    def answer_to_life, do: 42

    def unladen_swallow_airspeed, do: "African or European?"
  end

Module.create(Culture, ast, Macro.Env.location(__ENV__))

Culture.answer_to_life()
Culture.unladen_swallow_airspeed()

use & using(opts)

use

use2

use3

use4

defoverridable

register_attribute(…, accumulate: true) & @before_compile

Source: Jia Hao Woo, https://blog.appsignal.com/2021/10/26/how-to-use-macros-in-elixir.html

Chris McCord’s “Metaprogramming Elixir” book has a very similar example on pages 39-42.

Jia Hao Woo published four metaprogramming articles at: https://blog.appsignal.com/authors/jia-hao-woo

accumulate

Testing macros

In “Metaprogramming Elixir” (page 69), Chris McCord advises you to test your generated code, not the macro code generation process directly.

For more, you might find this talk useful:

Debugging Elixir Code - The Tools, The Mindset - Michal Buszkiewicz (ElixirConf EU 2021)