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
- Everything else matches literally
## Examples
# Find all IO.inspect calls
ExAst.search("lib/**/*.ex", "IO.inspect(_)")
# Replace dbg with the expression itself
ExAst.replace("lib/**/*.ex", "dbg(expr)", "expr")
"""
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`.
"""
@spec search(String.t() | [String.t()], String.t()) :: [match()]
def search(paths, pattern) do
paths
|> resolve_paths()
|> Enum.flat_map(&search_file(&1, pattern))
end
@doc """
Replaces AST pattern matches in files.
Options:
- `:dry_run` — return changes without writing (default: `false`)
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 = Keyword.get(opts, :dry_run, false)
paths
|> resolve_paths()
|> Enum.flat_map(&replace_file(&1, pattern, replacement, dry_run))
end
defp search_file(file, pattern) do
source = File.read!(file)
Patcher.find_all(source, pattern)
|> 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) do
source = File.read!(file)
matches = Patcher.find_all(source, pattern)
if matches == [] do
[]
else
result = Patcher.replace_all(source, pattern, replacement)
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