ExPanda
Full macro expansion for Elixir AST introspection.
ExPanda takes Elixir source code (or a pre-parsed AST) and produces an AST
where all macros have been expanded to their underlying forms, while preserving
structural constructs (defmodule, def/defp) as-is.
The Problem
Code.string_to_quoted/1 returns the surface-level AST. Macros such as
unless, |>, use GenServer, Ecto's schema, and Phoenix macros all remain
as opaque calls:
{:ok, ast} = Code.string_to_quoted("1 |> to_string() |> String.upcase()")
# => {:|>, _, [{:|>, _, [1, {:to_string, _, []}]}, ...]}For tools that need to reason about the actual control flow, data flow, or function call graph, this is insufficient. ExPanda resolves all macros to their expanded forms:
{:ok, expanded} = ExPanda.expand_string("1 |> to_string() |> String.upcase()")
# => {{:., _, [String, :upcase]}, _, [{{:., _, [String.Chars, :to_string]}, _, [1]}]}Installation
Add ex_panda to your list of dependencies in mix.exs:
def deps do
[
{:ex_panda, "~> 0.1"}
]
endUsage
Expanding a Source String
{:ok, expanded} = ExPanda.expand_string("unless true, do: :never")
# `unless` is expanded to `case`:
# {:case, _, [true, [do: [{:->, _, [[false], :never]}, {:->, _, [[true], nil]}]]]}Expanding a File
{:ok, expanded} = ExPanda.expand_file("lib/my_module.ex")Expanding a Pre-parsed AST
{:ok, ast} = Code.string_to_quoted("1 |> to_string()")
{:ok, expanded} = ExPanda.expand(ast)
# => {{:., _, [String.Chars, :to_string]}, _, [1]}Expanding to Formatted Source Code
To get back formatted Elixir source instead of AST, use expand_to_string/2.
It accepts both source strings and pre-parsed AST:
{:ok, code} = ExPanda.expand_to_string("1 |> to_string() |> String.upcase()")
# => "String.upcase(String.Chars.to_string(1))"
{:ok, ast} = Code.string_to_quoted("unless true, do: :never")
{:ok, code} = ExPanda.expand_to_string(ast)
# => "case true do\n x when x in [false, nil] ->\n :never\n _ ->\n nil\nend"Expanding with a Custom Environment
When running inside a Mix project where all dependencies are compiled,
you can pass __ENV__ so that library macros (use GenServer,
Ecto schemas, Phoenix macros) are also expanded:
{:ok, expanded, _final_env} = ExPanda.expand(ast, __ENV__, [])use Expansion
use GenServer and similar use directives are expanded by calling the
target module's MACRO-__using__/2 function directly, bypassing the
standard macro dispatch that requires a compile-time module table.
This means use works inside defmodule even without full compilation:
{:ok, expanded} = ExPanda.expand_string("""
defmodule MyServer do
use GenServer
end
""")
# The output contains the expanded @behaviour, def child_spec, etc.
# with no @unexpanded markers.Structural Preservation
defmodule and def/defp/defmacro/defmacrop forms are kept intact in the
output. Only their bodies are expanded:
{:ok, expanded} = ExPanda.expand_string("""
defmodule Foo do
def bar(x), do: unless(x, do: :fallback)
end
""")
# Output preserves defmodule + def structure:
# {:defmodule, _, [{:__aliases__, _, [:Foo]},
# [do: {:def, _, [{:bar, _, _}, [do: {:case, _, _}]]}]]}
Directives (alias, import, require) are also preserved in the output
and applied to the environment so that subsequent macro expansions resolve
correctly.
Unexpandable Macros
When a macro cannot be expanded (e.g., the target module is not loaded in the
current runtime), the original node is kept with an @unexpanded error marker
prepended:
{:__block__, [], [
{:@, [], [{:unexpanded, [], ["use/1: function NonExistentModule.__using__/1 is undefined"]}]},
{:use, [], [{:__aliases__, [], [:NonExistentModule]}]}
]}This makes it straightforward to detect which parts of the AST could not be fully resolved, without crashing the expansion process.
How It Works
ExPanda combines two expansion strategies:
:elixir_expand.expand/3(primary engine) -- the Elixir compiler's internal expansion function. It recursively expands all nested macros, resolves aliases, and handles special forms. Used for expression-level expansion within function bodies.Macro.expand/2(fallback) -- the public API. Used as a fallback when the internal engine fails (e.g., undefined variables).Direct
MACRO-__using__/2call -- forusedirectives, the target module's__using__macro is called directly, bypassing the compiler dispatch that requires an ETS module table. This enablesuse GenServerand similar expansions insidedefmodulewithout full compilation.
The Walker module implements a recursive top-down traversal that threads a
Macro.Env struct through the AST, updating it as directives are encountered:
alias Foo.Bar-- updatesenv.aliasesimport Foo-- loads functions/macros intoenv.functions/env.macrosrequire Foo-- adds toenv.requiresdefmodule-- creates a child scope with the module contextdef/defp-- registers parameters as variables in the env
Integration with Metastatic
ExPanda serves as the foundation for Metastatic’s
Elixir adapter, enabling accurate cross-language code analysis on real, fully-resolved ASTs.
The expanded AST produced by ExPanda feeds directly into
Metastatic.Adapters.Elixir.ToMeta.transform/1, since it produces standard
Elixir AST ({form, meta, args} tuples) -- just with all macros resolved.
This gives Metastatic a true representation of the code's semantics rather
than its surface syntax.
Development
# Run tests
mix test
# Run with coverage
MIX_ENV=test mix coveralls
# Code quality
mix quality
# Generate documentation
mix docsTechnical Risks
:elixir_expandstability — private API, may change between versions. Mitigated by version-guarded calls andMacro.expand/2fallback- Side effects during expansion — some macros register state during compilation (e.g., Ecto schema fields). Without full compilation, expansion may be incomplete for these macros
-
Variable binding —
:elixir_expandraises on undefined vars. Mitigated by pre-registering function params and pattern-match bindings in the env
License
Copyright 2026 Aleksei Matiushkin
This project is licensed under the MIT License. See the LICENSE file for details.