ExPanda

ExPanda

CIHex.pmDocs

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"}
  ]
end

Usage

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:

  1. :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.

  2. Macro.expand/2 (fallback) -- the public API. Used as a fallback when the internal engine fails (e.g., undefined variables).

  3. Direct MACRO-__using__/2 call -- for use directives, the target module's __using__ macro is called directly, bypassing the compiler dispatch that requires an ETS module table. This enables use GenServer and similar expansions inside defmodule without 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:

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 docs

Technical Risks

License

Copyright 2026 Aleksei Matiushkin

This project is licensed under the MIT License. See the LICENSE file for details.