How to get each line of block and convert it as list of map in macro

After years of not practicing with macros, I recently decided to make a small project with it once again. something like typed_struct/lib/typed_struct.ex at main · ejpcmac/typed_struct · GitHub . I have read the codes but it is very magic for me at first glance.

Imagine you have this code:

defmodule ExampleUsage do
  import MishkaPub.ActivityStream.TypedStruct

  typedstruct do
    field(:name1, type: String.t(), required: true)
    field(:name2, type: DateTime.t(), required: false, default: "shahryar")
    field(:name3, type: list(string()), required: true)
  end
end

I need to get all fields and convert them as a map like this:

[
  %{title: :name1, type: String.t(), required: true},
  %{title: :name2, type: DateTime.t(), required: false, default: "shahryar"},
  %{title: :name3, type: list(string()), required: true},
]

So I created a macro like this:

defmacro typedstruct(do: ast) do
    fields_ast =
      case ast do
        {:__block__, [], fields} -> fields
        field -> [field]
      end

    Enum.map(fields_ast, fn field ->
      IO.inspect(field) # I could not be able to convert it
    end)
  end

For example the pattern of ast is not equal always to let me create a pattern, and some times user puts String.t() some times they put list(string()), again it is different

It should be noted, at first I just want to create something like this and after that I want to create t() type and struct inside a module. but I need to go step by step.

Thank you for your help in advance

2 Likes

You likely don’t want to go down the path of parsing the block into a list by hand, since it could contain arbitrary code:

typedstruct do
  Enum.map(@some_list, fn some_list_el ->
    field some_list_el, type: DateTime.t()
  end)
  field :other_one, type: String.t()
end

typed_struct handles this by unquoteing its block and having field also be a macro that accumulates into @ts_fields

2 Likes

Yes, I saw this before, and the magic I told you. :smiling_face_with_tear:

 import TypedStruct
 unquote(block)

 @enforce_keys @ts_enforce_keys
 defstruct @ts_fields

I copy and delete some extra code of his project to understand what they did. I made the __using__ comment.

  1. the block of macro is passed to __typedstruct__ function
  2. it puts it inside quote and unquote it, so what does it mean, what it does hear?
  3. he uses @enforce_keys and @ts_enforce_keys, the @enforce_keys is for elixir and the other is for this lib.
  4. before this macro they create a list of these global list
  @accumulating_attrs [
    :ts_plugins,
    :ts_plugin_fields,
    :ts_fields,
    :ts_types,
    :ts_enforce_keys
  ]
  1. after that, it registers to the module with this
Enum.each(unquote(@accumulating_attrs), fn attr ->
  Module.register_attribute(__MODULE__, attr, accumulate: true)
end)
  1. after that unquote(block) to let use this as original schema not ast, okey?
  2. it calles @enforce_keys and @ts_enforce_keys, with this part of code every struct should be created with @ts_enforce_keys, without it, the code has to raise an error
  3. it wrote defstruct @ts_fields; it is just located inside @ts_enforce_keys and registered
#defmodule TypedStruct do
 #defmacro __using__(_) do
    #quote do
     # import TypedStruct, only: [typedstruct: 1]
    #end
 # end

  defmacro typedstruct(do: block) do
    ast = TypedStruct.__typedstruct__(block)

    quote do
      # Create a lexical scope.
      (fn -> unquote(ast) end).()
    end
  end

  def __typedstruct__(block) do
    quote do
      # --------------------
      Enum.each(unquote(@accumulating_attrs), fn attr ->
       Module.register_attribute(__MODULE__, attr, accumulate: true)
      end)
      # --------------------
      import TypedStruct
      unquote(block)

      @enforce_keys @ts_enforce_keys
      defstruct @ts_fields
    end
  end

  defmacro field(name, type, opts \\ []) do
    quote bind_quoted: [name: name, type: Macro.escape(type), opts: opts] do
      # IO.inspect(type)
    end
  end
end

As my understanding, he creates some global var under module as module attributes and each step it push data on them, okey?

But where he put each of field to a struct?

typedstruct do
    field(:name1, type: String.t(), required: true)
    field(:name2, type: DateTime.t(), required: false, default: "shahryar")
    field(:name3, type: list(string()), required: true)
end

The code of field:

And after binding all the data I can use it in this line? defstruct @ts_fields, am I right?

And still this part is magic for me why he uses anonymous function

(fn -> unquote(ast) end).()

Why it uses (fn -> unquote(ast) end).()

I asked ChatGPT and I do not know it is true or not!! or is there another way to execute:

In Elixir, the quote macro is used to construct an abstract syntax tree (AST) from the code enclosed within it. The unquote macro is used to interpolate a value or expression into the AST being constructed.

When you use unquote(ast) directly within quote do ... end, it would simply insert the value of ast at that point in the AST. However, if you want to execute code and include the result in the AST, you can wrap it in a function and use the () to invoke that function.

In the case of (fn -> unquote(ast) end).(), the purpose is to execute the code represented by ast and include the result in the AST being constructed by the quote macro. By wrapping unquote(ast) inside an anonymous function (fn -> ... end) and immediately invoking it with (), the code is executed and its result is included in the AST.

This technique is often used when you want to include the result of a computation or expression, rather than the actual code itself, in the generated AST. It allows for dynamic generation of code within macros or other code-generation scenarios.

Nope, this is 100% false. It does not execute the AST, you cannot execute AST.

The comment above that anonymous function thing in the source code says

          # Create a lexical scope.

So perhaps it has something to do with scoping rules for the variables emitted by the macro?

2 Likes

I have read before this article Scoping Rules in Elixir (and Erlang) — Elixir documentation but I still do not know why he is using (fn -> unquote(ast) end).() there

ChatGPT DOES NOT “KNOW” THINGS

This “explanation” is plausible-sounding nonsense, which is the main output of LLMs.

A better way to answer “why does this code exist” questions, IMO, is to read the notes and discussion from the authors. For the “anonymous function” bit, here’s the PR that changed that code to its present-day form:

That PR links to a comment in a discussion about removing Module.eval_quoted:

I had originally put this to avoid the field macro to be available outside of the typedstruct block, which would be obviously not semantically correct.

The intent of the anonymous function is to prevent the import TypedStruct that TypedStruct.__typedstruct__'s generated AST includes from leaking into the module that said typedstruct do.

It’s not required when the module option is given, since then the import is scoped to the generated module.

3 Likes

I have a huge question, how he could find this solution for his problem? I need that way he tested and found this way. is there a specific concept when we want to debug or maintenance a macro?