Any idea how to emit an empty AST node in a macro?

I’m doing some questionable meta-programming, and could use your help!

I’m using Macro.prewalk to traverse some source code, with the goal of pruning entire expressions matching a pattern as I go. I would like replace the matching expression with a functionally “no-op” AST node, but so far everything I try displays as a value upon being passed to Macro.to_string, including:

  • nil
  • {}
  • []
  • {:__block__, [], []} (this becomes a nil value)

Any ideas? Or elegant alternative approaches to pruning a node matching a pattern from a tree?

If you want to remove matching clauses, you would generally remove them from a list of clauses:

iex(1)> quote do
...(1)> case val do
...(1)> {:ok, v} -> v
...(1)> {:error, e} -> raise e
...(1)> end
...(1)> end
{:case, [],
 [
   {:val, [], Elixir},
   [
     do: [
       {:->, [],
        [
          [
            ok: {:v,
             [
               if_undefined: :apply,
               context: Elixir,
               imports: [{0, IEx.Helpers}, {1, IEx.Helpers}]
             ], Elixir}
          ],
          {:v,
           [
             if_undefined: :apply,
             context: Elixir,
             imports: [{0, IEx.Helpers}, {1, IEx.Helpers}]
           ], Elixir}
        ]},
       {:->, [],
        [
          [error: {:e, [], Elixir}],
          {:raise, [context: Elixir, imports: [{1, Kernel}, {2, Kernel}]],
           [{:e, [], Elixir}]}
        ]}
     ]
   ]
 ]}
iex(2)> pruned = {:case, [],
 [
...(2)>    {:val, [], Elixir},
...(2)>    [
     do: [
...(2)>        {:->, [],
...(2)>         [
...(2)>           [
...(2)>             ok: {:v,
...(2)>              [
...(2)>                if_undefined: :apply,
...(2)>                context: Elixir,
...(2)>                imports: [{0, IEx.Helpers}, {1, IEx.Helpers}]
...(2)>              ], Elixir}
...(2)>           ],
...(2)>           {:v,
...(2)>            [
...(2)>              if_undefined: :apply,
...(2)>              context: Elixir,
...(2)>              imports: [{0, IEx.Helpers}, {1, IEx.Helpers}]
...(2)>            ], Elixir}
...(2)>         ]}
...(2)>      ]
...(2)>    ]
...(2)>  ]}
iex(3)> Macro.to_string(pruned) |> IO.puts()
case val do
  {:ok, v} -> v
end
:ok

But if for instance you have a case with a single clause, you cannot remove it, you must remove the case expression entirely. And if the value of that expression is used, then you must remove that too, etc.

A quick and dirty hack would be to replace your clause by a value that could never match, like a ref. You inject never_match = make_ref()and then you replace the match clauses by ^never_match.

But it is a dirty hack. What are you trying to accomplish?

1 Like

On my phone so can’t give a good example, but I’d recommend looking into Sourceror’s Zipper, which is a higher level structure that you can traverse over but has support for “remove the current node”.

linky

alias Sourceror.Zipper, as: Z

ast
|> Z.zip() # ast to zipper
|> Z.traverse(fn zipper ->
  if should_remove?(Z.node(zipper)) do
    Z.remove(zipper)
  else
    zipper
  end
end)
|> Z.node() # back to ast
2 Likes

I’m trying to accomplish a dirty hack :slight_smile:. This is a DSL for personal usage only so I can make stronger guarantees—in this case, the expression I am trying to pluck (assignment to a specific variable I do not intend to reference again) I know I will only ever place inside a :__block__, so it is safe to extract without modifying semantics unlike a -> clause, and I validate the AST pre-and-post extraction. Would definitely never attempt this in shared code.

Yep, reading thru various implementations it seems like the common approach here is to not to match on the expression node I am interested in directly in prewalk, but prune it out from the list of expressions in the third position of an AST tuple before descending into it at all, with a special-case check that the top level node is not a match. Makes sense, though complicates the descent I’m doing as there are other cases in my prewalk I’m handling.


These approaches work, thanks for the input—but I’m really more intrigued by the initial question, and want to leave this open to solicit more eyes:

Is there such a thing as a no-op “null” node in Elixir AST? I’ve spent quite a bit of time within the erlang compiler and am increasingly convinced there is not, but would be fascinated by a counter-example or core member refutation or confirmation of such a construct.

I believe that [] would be the nearest to a no-op. Pretty sure I’ve used that for the same purpose but on my phone now so difficult to find.

1 Like

Effectively, I’m wondering if there is a t:Macro.output/0 for which String.trim(Macro.to_string(output)) == "".

My intended usecase here is to extract something from code to prepare the code for textual display, so emitting a semantically neutral node is not quite what I want (and non-trivial, in a blocks-are-expressions-that-return-their-last-value language). I want it to be erased entirely, and a “null node” is an interesting concept.

It is also possible to use Macro.traverse/4 and on the pre order phase, replace the node with a sentinel like :__remove_me__, and in the post order phase, find that node in the args (as someone said above) and delete it.

The post order phase function being as simple as

            fn
              {node, meta, args}, acc when is_list(args) ->
                args = List.delete(args, :__remove_me__)
                {{node, meta, args}, acc}

              node, acc ->
                {node, acc}
            end
1 Like

Yep, IIRC that’s exactly what I ended up doing, making a two-tuple sentinel-node with a double-underscore atom, ex {:__my_lib_sentinel__, inner_ast}.