Define a function at compile-time

I have a module for which I want to create a function rule_ids/0 at compile time. The code collects the rule ids at compile time from the first argument of all clauses of the function condition/3 using the AST.
It does not work because the variable rule_ids_ defined at compile time is not accessible when defining the function with def rule_ids(), do: rule_ids_.
How could I proceed to get it working? Should I use a macro somewhere?

defmodule Rules do

  # Collect the rule ids at compile time
  {_ast, rule_ids_} = File.read!(__ENV__.file)
    |> Code.string_to_quoted()
    |> Macro.traverse([],
      fn
        {:def, _, [{:condition, _, [rule_id, {:v, _, nil}, {:site, _, nil}]} | _]} = x, acc -> {x, [rule_id | acc]}
        other, acc -> {other, acc}
      end,
      fn other, acc -> {other, acc} end
    )

  # How could I use rule_ids_ here?
  def rule_ids(), do: rule_ids_

  def condition(rule_id, v, site)

  def condition(1018, v, site) do
    v[site][7018] < 50 && v[site][7019] < 50 && v[site][7020] < 50
  end

  def condition(1017, v, site) do
    v[site][7027] > 42
  end

  # ...

end

These is what module attributes are for: Module — Elixir v1.18.3

I’ve never checked it, but this should work:

  Module.register_attribute(__MODULE__, :rule_ids)

 # Collect the rule ids at compile time
  {_ast, rule_ids_} = File.read!(__ENV__.file)
    |> Code.string_to_quoted()
    |> Macro.traverse([],
      fn
        {:def, _, [{:condition, _, [rule_id, {:v, _, nil}, {:site, _, nil}]} | _]} = x, acc -> {x, [rule_id | acc]}
        other, acc -> {other, acc}
      end,
      fn other, acc -> {other, acc} end
    )
  
  Module.put_attribute(__MODULE__, :rule_ids, rule_ids_)

  # How could I use rule_ids_ here?
  def rule_ids(), do: @rule_ids

  def condition(rule_id, v, site)

  def condition(1018, v, site) do
    v[site][7018] < 50 && v[site][7019] < 50 && v[site][7020] < 50
  end

  def condition(1017, v, site) do
    v[site][7027] > 42
  end

  # ...

end
2 Likes

There are better ways to do this over reading “yourself” and parsing the AST.

You can use @on_definition on a module (say A) to have it call a macro or function elsewhere (Say on B) whenever a function or macro is defined on the module. With a macro being provided you can then pull information from those definitions and store them in an arbitrary module attribute of A.

You can register another callback @before_compile in A, which calls the callback macro (likely also in B) right before the module starts compiling, but after all it’s body has been evaluated. That module can then read your arbitrary module attribute for all the gathered information and turn it into AST returned from the macro. That AST is then added to the module – you can imagine it being injected right before the end line of defmodule.

3 Likes

It worked with:

Module.register_attribute(__MODULE__, :rule_ids, accumulate: false, persist: false)
2 Likes

I’ve put @on_definition {Hooks, :rule_ids} in module Rules but in the other module called Hooks I cannot see how I can accumulate the ids and make use of them in the rule_ids/6 function.

defmodule Hooks do
  Module.register_attribute(__MODULE__, :rule_ids, accumulate: true, persist: false)

  def rule_ids(_env, :def, :condition, [rule_id | _], _guards, _body)
  when is_integer(rule_id)
  do
    IO.puts rule_id
  end
  def rule_ids(_env, _kind, _name, _args, _guards, _body), do: nil
end

You can use Module.put_attribute, though I think Hooks.rule_ids needs to be a macro for that to work. The module attribute needs to be one on the module being compiled though, not on the Hooks module.

I managed to make it work using both your advices. In fact I thought you had given two different ways of doing things but it looks like we have to combine them.

defmodule Rules do
  Module.register_attribute(__MODULE__, :rule_ids, accumulate: true, persist: false)

  @on_definition {Hooks, :rule_ids}
  @before_compile {Hooks, :create_rule_ids}

# ...
end
defmodule Hooks do
  def rule_ids(_env, :def, :condition, [rule_id | _], _guards, _body)
  when is_integer(rule_id)
  do
    Module.put_attribute(Rules, :rule_ids, rule_id)
  end
  def rule_ids(_env, _kind, _name, _args, _guards, _body), do: nil

  defmacro create_rule_ids(_env) do
    quote do
      def rule_ids, do: unquote(Module.get_attribute(Rules, :rule_ids))
    end
  end
end

Yeah, that’s what I was hinting at. You can remove the hardcoded Rules in the Hooks module by replacing it with env.module. Often all the boilerplate you have in Rules is also contained in a __using__ macro of Hooks, so a use Hooks does everything you need within Rules.

3 Likes

While everything suggested above is absolutely correct, I’m to answer your original question.

One does not technically need module attributes, nor hooks here. The issue is scopes and we have unquote fragments for that.

rule_ids_ is defines in the outer scope for rule_ids/0 call, therefore one needs to unquote it there. THe code below would work (I also fixed the issue with condition/3 head mistakenly falling under a match during the parse stage.)

defmodule Rules do

  # Collect the rule ids at compile time
  {_ast, rule_ids_} = File.read!(__ENV__.file)
    |> Code.string_to_quoted()
    |> Macro.traverse([],
      fn
        {:def, _, [{:condition, _, [rule_id, {:v, _, nil}, {:site, _, nil}]} | _]} = x, acc 
            when not is_tuple(rule_id) ->
          {x, [rule_id | acc]}

        other, acc ->
         {other, acc}
      end,
      fn other, acc -> {other, acc} end
    )
  # print it out and see how it goes, this line is to be removed
  |> tap(& &1 |> Macro.to_string() |> IO.puts())

  # unquoting from the outer scope
  # HERE              ⇓⇓⇓⇓⇓⇓⇓                 
  def rule_ids(), do: unquote(rule_ids_)

  def condition(rule_id, v, site)

  def condition(1018, v, site) do
    v[site][7018] < 50 && v[site][7019] < 50 && v[site][7020] < 50
  end

  def condition(1017, v, site) do
    v[site][7027] > 42
  end
  # ...
end
5 Likes