Credo and specs for macro-generated functions

Hi everyone!
I’m searching for a hint for a problem I cannot understand if it is in my code or a third party misbehaviour.
I’m currently working on a project linted by wonderful credo where I have enabled Credo.Check.Readability.Specs to enforce specs on public functions.

Now, I’m creating with a macro a bunch of functions with the same /arity but different matches based on a couple of parameters, e.g.

# example.exs
defmodule SpecMe do
  defmacro __using__(attrs) do
    for attr <- attrs do
      quote do
        def unquote(attr)(attr = unquote(attr)), do: Atom.to_string(attr)
        def unquote(attr)(attr), do: attr |> Atom.to_string() |> String.reverse()
      end
    end
  end
end

# usage
defmodule UseSpec do
  use SpecMe, [:ab, :bc, :cd]
end

UseSpec.ab(:ab) |> IO.inspect()
UseSpec.ab(:bc) |> IO.inspect()

UseSpec.bc(:bc) |> IO.inspect()
UseSpec.bc(:cd) |> IO.inspect()

You can run the example to see it compiles and work with elixir example.exs
You can trigger the check on a credo-enabled project (configured with the check enabled) with mix credo example.exs
You should see a Functions should have a @spec type specification
( code also available via gist, but you’ll still need a credo-enabled project to run mix against it )

Things I tried:

  • putting @spec unquote(attr)(...) wrapping a generic definition e.g.
@spec unquote(attr)(...) :: ...
def unquote(attr)(params)
  • putting @spec unquote(attr)(...) before the first def
  • since my practical example is slightly more complex, putting it in a different quote/do block before the cycle, since I know the name before it, and returning the corresponding ast

Please note that also the name is dynamic, and more clauses are defined for the same name. If I refactor everything to have a static name and put a normal spec on it, it does not complain.
I’m therefore stuck in not understanding a couple of things:

  • it seems to me I’m failing in setting @spec. I know @ is a macro that wraps Module.get_attribute but since it is in fact setting spec during compilation I imagine it has a special treatment, but I’m probably doing things wrong while trying to set it
  • I don’t fully understand why credo is pointing directly at my code inside __using__ and not against the real module using the macro. It seems it is really pointing to those def , but I may be missing a lot of things that happen during compilation

Anyone has any hint regarding this issue? Thanks in advance!

L

1 Like

Instead of trying to build the spec dynamically I’d probably build the AST of the spec in the macro body and unquote the complete AST as the value for @spec.

You can use quote to figure out how the AST of a spec needs to look like:

iex(3)> quote do: @spec some_function(map()) :: term()
{:@, [context: Elixir, import: Kernel],
 [
   {:spec, [context: Elixir],
    [{:"::", [], [{:some_function, [], [{:map, [], []}]}, {:term, [], []}]}]}
 ]}

So your macro could look something like this:

defmacro __using__(attrs) do
  for attr <- attrs do
    spec_params = …
    spec_return = …
    spec = {:"::", [], [{attr, [], spec_params}, spec_return]}
    quote do
      @spec unquote(spec)
      def unquote(attr)(attr = unquote(attr)), do: Atom.to_string(attr)
      def unquote(attr)(attr), do: attr |> Atom.to_string() |> String.reverse()
    end
  end
end
4 Likes

Thanks for the hint! This has been super useful since I didn’t thought about this technique!
It is still not working with the same outcome (specs are created in both scenarios, e.g. seeable with h Module.fun, but credo complains about them not being present)
I’m starting to think I may try to file a report, but will try to poke around a bit more before!

For whoever it may concern, I ended up writing directly on a similar issue in credo’s repo and it seems it is a current (and maybe not solvable :smiling_face_with_tear:) limitation in credo being a static analyzer

Thanks for the hints and kindness, as always <3