Search and replace Elixir code by AST pattern.
Patterns are plain Elixir — variables capture, _ is a wildcard,
structs match partially. No regex, no custom DSL.
mix ex_ast.search 'IO.inspect(_)'
mix ex_ast.replace 'IO.inspect(expr, _)' 'Logger.debug(inspect(expr))' lib/def deps do
[{:ex_ast, "~> 0.1", only: [:dev, :test], runtime: false}]
endPatterns are valid Elixir expressions parsed by Code.string_to_quoted!/1.
Three rules:
| Syntax | Meaning |
|---|---|
_ or _name |
Wildcard — matches any node, not captured |
name, expr, x |
Capture — matches any node, bound by name |
| Everything else | Literal — must match exactly |
Structs and maps match partially — only the keys you write must be
present. %User{role: r} matches any User with a role field,
regardless of other fields.
Repeated variable names require the same value at every position:
Enum.map(a, a) only matches calls where both arguments are identical.
# Find all IO.inspect calls (any arity)
mix ex_ast.search 'IO.inspect(_)'
mix ex_ast.search 'IO.inspect(_, _)'
# Find structs by field value
mix ex_ast.search '%Step{id: "subject"}' lib/documents/
# Find {:error, _} tuples
mix ex_ast.search '{:error, _}' lib/ test/
# Find GenServer callbacks
mix ex_ast.search 'def handle_call(_, _, _) do _ end'
# Count matches
mix ex_ast.search --count 'dbg(_)'# Remove debug calls
mix ex_ast.replace 'dbg(expr)' 'expr'
mix ex_ast.replace 'IO.inspect(expr, _)' 'expr'
# Replace struct with function call
mix ex_ast.replace '%Step{id: "subject"}' 'SharedSteps.subject_step(@opts)' lib/types/
# Migrate API
mix ex_ast.replace 'Repo.get!(mod, id)' 'Repo.get!(mod, id) || raise NotFoundError'
# Preview without writing
mix ex_ast.replace --dry-run 'use Mix.Config' 'import Config'Captures from the pattern are substituted into the replacement by name.
# Search
ExAST.search("lib/", "IO.inspect(_)")
#=> [%{file: "lib/worker.ex", line: 12, source: "IO.inspect(data)", captures: %{}}]
# Replace
ExAST.replace("lib/", "dbg(expr)", "expr")
#=> [{"lib/worker.ex", 2}]
# Low-level: single string
ExAST.Patcher.find_all(source_code, "IO.inspect(_)")
ExAST.Patcher.replace_all(source_code, "dbg(expr)", "expr")# Function calls
Enum.map(_, _)
Logger.info(_)
Repo.all(_)
# Definitions
def handle_call(msg, _, state) do _ end
def mount(_, _, _) do _ end
# Pipes
_ |> Repo.all()
_ |> Enum.map(_) |> Enum.filter(_)
# Tuples
{:ok, result}
{:error, reason}
{:noreply, state}
# Structs (partial match)
%User{role: :admin}
%Changeset{valid?: false}
# Maps (partial match)
%{name: name}
# Directives
use GenServer
import Ecto.Query
alias MyApp.Accounts.User
# Module attributes
@impl true
@behaviour mod
# Ecto
from(_ in _, _)
cast(_, _)
validate_required(_)
# Control flow
case _ do _ -> _ end
with {:ok, _} <- _ do _ end
fn _ -> _ end
&_/1
# Misc
raise _
dbg(_)- No function-name wildcards —
def _(_) do _ endwon't match arbitrary function names because_in that position parses as a call, not a wildcard. Use the actual name or match thedoblock. - Exact list length —
[a, b]only matches two-element lists. No rest/splat syntax. - No multi-expression wildcards — can't match "any number of
statements" inside a
doblock. - Replacement formatting — the replacement string is rendered by
Macro.to_string/1. Runmix formatafterward for consistent style.
- Source files are parsed with Sourceror, preserving source positions and comments
- The pattern string is parsed with
Code.string_to_quoted!/1 - Both ASTs are normalized (metadata stripped,
__block__wrappers removed) - The pattern is matched against every node via depth-first traversal using
Sourceror.Zipper - Variables in the pattern bind to the matched subtrees (captures)
- For replacements, captures are substituted into the replacement template
and the result is patched into the original source using
Sourceror.patch_string/2, preserving formatting of unchanged code