Using macro's to inject a string containing the module name

Hey,

I currently have 2 modules, let’s call them module A and module B. Module B contains a __using__ macro as such:

defmacro __using__(opts) do
      quote location: :keep, bind_quoted: [opts: opts] do
            use Interceptor, config: %{
                String.slice(to_string(__MODULE__), 7..-1) <> ".handle_cast/2" => [
                  before: "Interceptor.intercept_before/1",
                  after: "Interceptor.intercept_after/2"
                ]
              }
      end
end

As you can see, this macro should automatically inject an use statement for the Interceptor library, with some additional config info (map). In this config map the Interceptor library expects a string as such: moduleName.functionToIntercept/arity. In my case, this would be “A.handle_cast/2”. Of course, inside of module B I do not know the module name of where this macro will be used yet, so I use the __MODULE__ to dynamically fill this in. I then use to_string and String.slice to respectively make it a string and trim the “Elixir.” prefix from it (appearantly this prefix is automatically added). I then add the following line to my module A:

use B

This does not work.

Now here is what I found out already. When I put the use Interceptor statement with the config info directly into module A, written exactly like above, it still does not work. However, when I change String.slice(to_string(__MODULE__), 7..-1) <> ".handle_cast/2" into "A" <> ".handle_cast/2, it suddenly does. However, when I test what comes out of String.slice(to_string(__MODULE__), 7..-1), it is exactly the same: "A".

Anyone who could figure this out?

Remember that use Interceptor is also a macro receiving compile-time options, so the opts that their __using__ macro receives will look like:

quote do
  [
    config: %{
      String.slice(to_string(Some.Module), 7..-1) <> ".handle_cast/2" => [
        before: ...,
        after: ...
      ]
    }
  ]
end

That is, they’ll receive the quoted String.slice(...), not the result.

So the challenge is: how do you inject a string into the quote block that you return so that Interceptor receives the options it requires? The key is __ENV__, a special form that resolves to the environment of the caller. In particular __ENV__.module will be, at compile time, whatever __MODULE__ is resolving to in the quoted block. You should be able to use this to construct the string you need at compile time, and inject it. The final quoted block will end up looking something like:

quote do
  use Interceptor, config: %{
    unquote(handle_cast_call) => [
      before: ...,
      after: ...
    ]
  }
end
1 Like

Thank you for your reply! So the code I write in the macro would look as follows?

defmacro __using__(opts) do
      quote location: :keep, bind_quoted: [opts: opts] do
            use Interceptor, config: %{
                String.slice(to_string(__ENV__.module), 7..-1) <> ".handle_cast/2" => [
                  before: "Interceptor.intercept_before/1",
                  after: "Interceptor.intercept_after/2"
                ]
              }
      end
end

I tried this but it still does not work.
Additionally, if I add “unquote” around the whole string construction part it gives me the following error:

(CompileError) unquote called outside quote

Untested, but something like this should be close. Do as little processing as possible in the quote block. It makes things hard to read and it basically is delegating the execution to runtime when, in this case, you can and should do it at compile time (ie outside the quote block).

defmodule B do
  defmacro __using__(opts) do
    fun_name = String.slice(to_string(__CALLER__.module), 7..-1) <> ".handle_cast/2"
    quote location: :keep, bind_quoted: [fun_name: fun_name, opts: opts] do
      use Interceptor, config: %{
        fun_name => [
          before: "Interceptor.intercept_before/1",
          after: "Interceptor.intercept_after/2"
        ]
      }
    end
end

PS: Edited to replace __MODULE__ with __CALLER__.module per @zachallaun’s correction
PPS: Edited to fix the remove the B.fun_name function since it was being called when not available

2 Likes

My apologies — was on my phone and also didn’t test. What you’ll want to use is __CALLER__ to get the env of the calling context (the module that calls use B in this case).

Hi, no problem, thanks for helping out! I get a new compileError doing this:

(CompileError) __CALLER__ is available only inside defmacro and defmacrop

Thanks for your help, using this approach I get the following error in my A module:

(UndefinedFunctionError) function B.fun_name/1 is undefined (module B is not available)

Thanks for your patience. Too much jet lag and not enough coffee. I edited again to remove the function call.

No problem at all! Unfortunatly it still provides me with a warning/error, this time in module B:

warning: variable "fun_name" does not exist and is being expanded to "fun_name()", please use parentheses to remove the ambiguity or change the variable name

(CompileError) undefined function fun_name/0 (there is no such import)
    (interceptor 0.5.4) expanding macro: Interceptor.__using__/1

It feels like what I write within the config map does not have access to the surrounding scope.

Any additional ideas to resolve these errors @kip @zachallaun? Or anyone else

After a lot of experimenting, I found a solution (see below). It was important to not make use of the bind_quoted and then I could just work with unquote

defmodule B do
  defmacro __using__(opts) do    
    quote location: :keep do

      opts = unquote(opts)

      use Interceptor, config: %{
        unquote(String.slice(to_string(__CALLER__.module), 7..-1) <> ".handle_cast/2") => [
          before: "Interceptor.intercept_before/1",
          after: "Interceptor.intercept_after/2"
        ]
      }
    end
end