Packages
metastatic
0.23.0
0.26.0
0.25.0
0.24.1
0.24.0
0.23.0
0.22.2
0.22.1
0.22.0
0.21.3
0.21.2
0.21.1
0.21.0
0.20.3
0.20.2
0.20.1
0.20.0
0.19.0
0.18.0
0.17.0
0.16.0
0.15.1
0.15.0
0.14.2
0.14.1
0.14.0
0.13.3
0.13.2
0.13.1
0.13.0
0.12.0
0.11.0
0.10.4
0.10.3
0.10.2
0.10.1
0.10.0
0.9.2
0.9.1
0.9.0
0.8.6
0.8.5
0.8.4
0.8.3
0.8.2
0.8.1
0.8.0
0.7.7
0.7.6
0.7.5
0.7.4
0.7.3
0.7.1
0.7.0
0.6.1
0.6.0
0.5.2
0.5.1
0.5.0
0.4.2
0.4.1
0.4.0
0.3.5
0.3.4
0.3.3
0.3.2
0.3.1
0.3.0
0.2.0
0.1.3
0.1.2
0.1.1
0.1.0
Cross-language code meta-model library using unified MetaAST representation. Parse, transform, and translate code across Python, Elixir, Ruby, Erlang, Haskell, and more via a shared three-tuple AST format.
Current section
Files
Jump to
Current section
Files
lib/metastatic/adapters/elixir/macro_expander.ex
defmodule Metastatic.Adapters.Elixir.MacroExpander do
@moduledoc """
Full macro expansion for the Elixir adapter using ExPanda.
This module takes a surface-level Elixir AST (from `Code.string_to_quoted/1`)
and produces a fully expanded AST where every macro has been resolved, while
annotating expansion boundaries with the original surface form so that the
`FromMeta` transformer can reconstruct macros faithfully.
## How It Works
1. Parse the surface AST via `Code.string_to_quoted/1` (done upstream)
2. Expand the AST fully with `ExPanda.expand/1`
3. Walk both ASTs in parallel (`annotate/2`):
- **Structural match** (same form atom): recurse into children pairwise
- **Macro boundary** (different form atoms): tag the expanded node with
`__original_macro__: surface_node` in its Elixir AST metadata
- **Leaf/literal match**: return expanded as-is
4. Return the annotated expanded AST
The `__original_macro__` annotation is later propagated by `ToMeta` into
the MetaAST keyword metadata as `:original_macro`, and consumed by `FromMeta`
to restore the original macro call during round-tripping.
## Examples
iex> {:ok, surface} = Code.string_to_quoted("unless true, do: :never")
iex> {:ok, annotated} = MacroExpander.expand_and_annotate(surface)
iex> {form, meta, _args} = annotated
iex> form
:case
iex> Keyword.has_key?(meta, :__original_macro__)
true
"""
@doc """
Expand all macros in `surface_ast` via ExPanda and annotate expansion points.
Returns `{:ok, annotated_ast}` where every node that resulted from a macro
expansion carries `__original_macro__: original_surface_node` in its
Elixir AST metadata keyword list.
## Options
- `:env` - custom `Macro.Env` to pass to ExPanda (default: fresh env)
- `:file` - file path for error messages (default: `"nofile"`)
## Examples
iex> {:ok, surface} = Code.string_to_quoted("1 |> to_string()")
iex> {:ok, annotated} = MacroExpander.expand_and_annotate(surface)
iex> match?({{:., _, _}, _, _}, annotated)
true
"""
@spec expand_and_annotate(Macro.t(), keyword()) ::
{:ok, Macro.t()} | {:error, term()}
def expand_and_annotate(surface_ast, opts \\ []) do
case ExPanda.expand(surface_ast, opts) do
{:ok, expanded_ast} ->
annotated = annotate(surface_ast, expanded_ast)
{:ok, annotated}
{:error, _reason} = error ->
error
end
end
@doc """
Expand all macros in a source string and return the annotated AST.
Convenience wrapper that parses + expands + annotates in one step.
## Examples
iex> {:ok, annotated} = MacroExpander.expand_and_annotate_string("unless true, do: :never")
iex> {form, meta, _} = annotated
iex> form
:case
iex> is_list(Keyword.get(meta, :__original_macro__))
false
"""
@spec expand_and_annotate_string(String.t(), keyword()) ::
{:ok, Macro.t()} | {:error, term()}
def expand_and_annotate_string(source, opts \\ []) do
case Code.string_to_quoted(source) do
{:ok, surface_ast} ->
expand_and_annotate(surface_ast, opts)
{:error, _} = error ->
error
end
end
# -------------------------------------------------------------------
# Parallel walk: annotate expansion boundaries
# -------------------------------------------------------------------
# Both are identical literals/atoms -- no expansion happened
defp annotate(same, same), do: same
# Both are 3-tuples with the SAME form atom -- structural match, recurse
defp annotate(
{form, _s_meta, s_args} = _surface,
{form, e_meta, e_args} = _expanded
)
when is_atom(form) and is_list(e_args) do
annotated_args = annotate_children(s_args, e_args)
{form, e_meta, annotated_args}
end
# Structural forms with different metadata but same form -- handled above.
# This clause catches structural forms where children diverged in shape
# (e.g. defmodule body changed due to `use` expansion).
# We still recurse pairwise into do/end blocks.
# Remote call structural match: both are {{:., ...}, meta, args}
# Recurse into the dot-tuple and the args pairwise.
defp annotate(
{{:., _s_dot_meta, s_dot_args}, _s_meta, s_args},
{{:., e_dot_meta, e_dot_args}, e_meta, e_args}
)
when is_list(e_meta) and is_list(e_args) do
annotated_dot_args = annotate_children(s_dot_args, e_dot_args)
annotated_args = annotate_children(s_args, e_args)
{{:., e_dot_meta, annotated_dot_args}, e_meta, annotated_args}
end
# Different form atoms -- MACRO BOUNDARY
# Tag the expanded node with the original surface form.
defp annotate(
{_s_form, _s_meta, _s_args} = surface,
{e_form, e_meta, e_args}
)
when is_atom(e_form) and is_list(e_meta) do
tagged_meta = [{:__original_macro__, surface} | e_meta]
{e_form, tagged_meta, e_args}
end
# Surface is an atom-form call, expanded is a remote call (e.g. pipe -> Module.func)
# This is a macro boundary.
defp annotate(
{s_form, _s_meta, _s_args} = surface,
{e_dot_tuple, e_meta, e_args}
)
when is_atom(s_form) and is_tuple(e_dot_tuple) and is_list(e_meta) do
tagged_meta = [{:__original_macro__, surface} | e_meta]
{e_dot_tuple, tagged_meta, e_args}
end
# Lists -- recurse element-wise when lengths match
defp annotate(s_list, e_list) when is_list(s_list) and is_list(e_list) do
annotate_children(s_list, e_list)
end
# 2-element tuples (keyword pairs, etc.)
defp annotate({s_k, s_v}, {e_k, e_v}) do
{annotate(s_k, e_k), annotate(s_v, e_v)}
end
# Fallback -- use expanded as-is (different shapes, can't correlate)
defp annotate(_surface, expanded), do: expanded
# -------------------------------------------------------------------
# Children annotation helpers
# -------------------------------------------------------------------
# Same-length lists: pairwise annotate
defp annotate_children(s_args, e_args)
when is_list(s_args) and is_list(e_args) and length(s_args) == length(e_args) do
Enum.zip_with(s_args, e_args, &annotate/2)
end
# When surface args is nil (e.g. variable node) but expanded has a list
defp annotate_children(nil, e_args) when is_list(e_args), do: e_args
# Different-length lists
# Try to match by keyword keys for do/end blocks, otherwise use expanded.
defp annotate_children(s_args, e_args)
when is_list(s_args) and is_list(e_args) do
if keyword_list?(s_args) and keyword_list?(e_args) do
annotate_keyword_lists(s_args, e_args)
else
# Best effort: annotate the prefix that has matching length,
# then append remaining expanded elements as-is
annotate_partial(s_args, e_args)
end
end
# Catch-all
defp annotate_children(_s_args, e_args), do: e_args
# Annotate keyword lists by matching keys (for do/end/rescue/after blocks)
defp annotate_keyword_lists(s_kw, e_kw) do
Enum.map(e_kw, fn {key, e_val} ->
case List.keyfind(s_kw, key, 0) do
{^key, s_val} -> {key, annotate(s_val, e_val)}
nil -> {key, e_val}
end
end)
end
# Annotate as many elements as we can pair up, then append the rest
defp annotate_partial(s_args, e_args) do
min_len = min(length(s_args), length(e_args))
{s_prefix, _s_rest} = Enum.split(s_args, min_len)
{e_prefix, e_rest} = Enum.split(e_args, min_len)
annotated_prefix = Enum.zip_with(s_prefix, e_prefix, &annotate/2)
annotated_prefix ++ e_rest
end
# -------------------------------------------------------------------
# Utilities
# -------------------------------------------------------------------
defp keyword_list?([{key, _val} | _rest]) when is_atom(key), do: true
defp keyword_list?(_), do: false
end