Current section
Files
Jump to
Current section
Files
lib/ex_ast.ex
defmodule ExAST do
@moduledoc """
Search and replace Elixir code by AST pattern.
Patterns are valid Elixir syntax:
- Variables (`name`, `expr`) capture matched nodes
- `_` and `_name` are wildcards
- Structs/maps match partially
- Pipes are normalized (`data |> Enum.map(f)` matches `Enum.map(data, f)`)
- Everything else matches literally
## Options
* `:inside` — only match nodes inside an ancestor matching this pattern
* `:not_inside` — reject nodes inside an ancestor matching this pattern
## Examples
# Find all IO.inspect calls
ExAST.search("lib/**/*.ex", "IO.inspect(_)")
# Find IO.inspect only inside test blocks
ExAST.search("test/", "IO.inspect(_)", inside: "test _ do _ end")
# Replace dbg with the expression itself
ExAST.replace("lib/**/*.ex", "dbg(expr)", "expr")
# Match piped and direct calls interchangeably
ExAST.search("lib/", "Enum.map(_, _)") # also finds `data |> Enum.map(f)`
"""
alias ExAST.Patcher
@type match :: %{
file: String.t(),
line: pos_integer(),
source: String.t(),
captures: ExAST.Pattern.captures()
}
@doc """
Searches files for AST pattern matches.
Returns a list of match maps with `:file`, `:line`, `:source`, and `:captures`.
Accepts `:inside` and `:not_inside` options to filter by context.
"""
@spec search(String.t() | [String.t()], String.t(), keyword()) :: [match()]
def search(paths, pattern, opts \\ []) do
paths
|> resolve_paths()
|> Enum.flat_map(&search_file(&1, pattern, opts))
end
@doc """
Replaces AST pattern matches in files.
Options:
- `:dry_run` — return changes without writing (default: `false`)
- `:inside` — only replace inside ancestors matching this pattern
- `:not_inside` — skip replacements inside ancestors matching this pattern
Returns a list of `{file, count}` tuples for modified files.
"""
@spec replace(String.t() | [String.t()], String.t(), String.t(), keyword()) :: [
{String.t(), pos_integer()}
]
def replace(paths, pattern, replacement, opts \\ []) do
{dry_run, where_opts} = Keyword.pop(opts, :dry_run, false)
paths
|> resolve_paths()
|> Enum.flat_map(&replace_file(&1, pattern, replacement, dry_run, where_opts))
end
defp search_file(file, pattern, opts) do
source = File.read!(file)
Patcher.find_all(source, pattern, opts)
|> Enum.map(fn %{range: range, node: node, captures: captures} ->
%{
file: file,
line: range.start[:line],
source: Sourceror.to_string(node),
captures: captures
}
end)
end
defp replace_file(file, pattern, replacement, dry_run, where_opts) do
source = File.read!(file)
matches = Patcher.find_all(source, pattern, where_opts)
if matches == [] do
[]
else
result = Patcher.replace_all(source, pattern, replacement, where_opts)
unless dry_run, do: File.write!(file, result)
[{file, length(matches)}]
end
end
defp resolve_paths(paths) when is_list(paths), do: Enum.flat_map(paths, &resolve_paths/1)
defp resolve_paths(glob) when is_binary(glob) do
cond do
String.contains?(glob, "*") -> Path.wildcard(glob)
File.dir?(glob) -> Path.wildcard(Path.join(glob, "**/*.ex"))
true -> [glob]
end
|> Enum.filter(&String.ends_with?(&1, ".ex"))
end
end