Dialyxir issue with macro code transformation

43 views Asked by At

I'm trying to build a macro that would transform Elixir code into some kind of "configuration" for later use, so this code:

    build_config with one_and_two <- SomeModule.run("one", "two"),
                      _three_and_four <- SomeModule.run(one_and_two, "four"),
                      _six_and_five <- SomeModule.run("six", "five") do
      IO.puts(one_and_two)
    end

is going to be represented as

[
  {:one_and_two, SomeModule, :run, ["one", "two"]},
  {:_three_and_four, SomeModule, :run, [{:from, :one_and_two}, "four"]},
  {:_six_and_five, SomeModule, :run, ["six", "five"]}
]

The issue with such approach is that when I run mix dialyzer, it won't complain about obviously bad types, but when I run regular code, it does complain.

For instance, I have the following code in SomeModule:

defmodule SomeModule do
  @spec run(binary, atom) :: binary
  def run(arg1, arg2) do
    IO.puts("RUN FOR #{arg1} AND #{arg2}")
    arg1 <> Atom.to_string(arg2)
  end
end

and the regular code that makes Dialyzer complain is this:

one_and_two = SomeModule.run("one", "two")
SomeModule.run(one_and_two, "four")

I tried to oversimplify the macro I built, but it still a bit too complex:

  defmacro build_config({:with, _meta, args}, do: _block) do
    {lines, _variables_map} =
      Enum.map_reduce(args, MapSet.new(), fn arg, names ->
        {:<-, _,
         [
           {name, _, nil},
           {{:., _, [{:__aliases__, _, [_module]}, _function]} = call, meta, variables}
         ]} = arg

        names = MapSet.put(names, name)

        new_variables =
          Enum.map(variables, fn
            {variable_name, _, nil} = variable ->
              if variable_name in names do
                {:from, variable_name}
              else
                variable
              end

            other ->
              other
          end)

        {module, function, params} = Macro.decompose_call({call, meta, new_variables})

        line =
          quote do
            {unquote(name), unquote(module), unquote(function), unquote(params)}
          end

        {line, names}
      end)

    lines
  end

A repository on Github with the sample code: https://github.com/denispeplin/config_generator

My question isn't about the macro itself, but about the whole approach: if a macro outputs something that isn't executable "code", but some kind of "configuration", that doesn't call anything by itself, should Dialyzer complain about type errors?

Is there any way to achive what I want: generate a configuration from code and have Dialyzer working at the same time?

1

There are 1 answers

2
Aleksei Matiushkin On

The MRE of the macro above can be reduced to a one-liner returning a tuple. The question, in general, has nothing to do with a macro, nor with its return type. Macro does inject an AST in place, there is no magic, dialyzer has no idea about macros and it works with the injected code.

Your macro outcome is just a tuple. You didn’t show how you are to use this tuple, but I would guess it’d be via Kernel.apply/3, which has a list() type as a third parameter.

There is no such type in (and hence in ) as a list of fixed length with specified types. That’s why there is no way to check elements in the list of arguments in the approach you introduced above.


What you might do though, would be to introduce your own apply/3 macro, which would expand arguments inplace with unquote_splicing/1. Then the injected code would be similar to the regular code with arguments passed one-by-one, not as a list, and therefore dialyzer would be able to check their types.


My advice would be to avoid this path until you have an absolutely clear understanding of how macros work and use somewhat less cumbersome.