Order of execution of @on_definition, @before_compile and @after_compile

# A.ex
defmodule A do
  use B

  @description "function1/0 returns :ok"
  def function1() do
    :ok
  end

  @description "function2/0 returns :not_ok"
  def function2() do
    :not_ok
  end
end
# B.ex
defmodule B do
  defmacro __using__(_) do
    quote do
      import B
      Module.register_attribute(__MODULE__, :description, accumulate: true, persist: true)
      Module.register_attribute(__MODULE__, :test, accumulate: true, persist: true)
      @on_definition B
      @after_compile B
      @before_compile B
      IO.puts("B started in #{IO.inspect(__MODULE__)}")
    end
  end
  def __on_definition__(env, _access, name, args, _guards, _body) do
    IO.puts("Reached on_definition")
    desc = Module.get_attribute(env.module, :description) |> hd()
    Module.delete_attribute(env.module, :description)
    Module.put_attribute(env.module, :test, {name, length(args), desc})
  end

  def __before_compile__(_env) do
    IO.puts("Reached before_compile")
  end

  def __after_compile__(_env, bytecode) do
    IO.puts("Reached after_compile")
    # Gets debug_info chunk from BEAM file
    chunks =
     case :beam_lib.chunks(bytecode, [:debug_info]) do
        {:ok, {_mod, chunks}} -> chunks
        {:error, _, error} -> throw("Error: #{inspect(error)}")
      end
    # Gets the (extended) Elixir abstract syntax tree from debug_info chunk
    dbgi_map =
      case chunks[:debug_info] do
        {:debug_info_v1, :elixir_erl, metadata} ->
          case metadata do
            {:elixir_v1, map, _} ->
              # Erlang extended AST available
              map
          end
        x ->
          throw("Error: #{inspect(x)}")
      end
    dbgi_map[:attributes]
    |> IO.inspect
  end
end

Considering the two modules shown above, module B is inserted in module A using the use macro. They produce this output:

B started in Elixir.A
Reached on_definition
Reached on_definition
Reached before_compile
Reached after_compile
[
  test: {:function1, 0, "function1/0 returns :ok"},
  test: {:function2, 0, "function2/0 returns :not_ok"}
]

From the docs, it states that @on_definition is “invoked when each function or macro in the current module is defined”.
A function can only be defined after it is compiled, so how is @on_definition reached before @before_compile and @after_compile?
Why is the order of execution: @on_definition@before_compile@after_compile, and not @before_compile@after_compile@on_definition?

Another question, I used put_attribute in the @on_definition callback (to set @description "..."). These new attributes were read successfully in the @after_compile callback as shown in the output.
My question is, if attributes can only be set at compile time, how is @after_compile able to read the new changes that were done after the functions were already defined (in @on_definition)?

Appreciate any feedback. Thanks.

To compile a module, Elixir first has to build the desired AST - expanding macros, module attributes, etc. @on_definition is called when a function is added to that AST.

Once the AST has been assembled, @before_compile is called - it can add additional AST to the module at that point.

Finally the AST is compiled into a BEAM file, and @after_compile is invoked.

3 Likes

This answered my queries. So the order of execution is: @on_definition (during AST formation) → @before_compile@after_compile.
Thanks.