Current section

Files

Jump to
pi_bridge lib pi ast.ex
Raw

lib/pi/ast.ex

defmodule Pi.AST do
@moduledoc "Structured ExAST helpers for bridge tools."
alias Pi.Protocol.Tool.AST.Diff
alias Pi.Protocol.Tool.AST.Match
alias Pi.Protocol.Tool.AST.Replace
alias Pi.Protocol.Tool.AST.Replacement
alias Pi.Protocol.Tool.AST.Search
alias Pi.Protocol.UI.Block
alias Pi.Protocol.UI.Display
@missing_ex_ast "ex_ast is not installed. Add {:ex_ast, \"~> 0.1\", only: [:dev, :test], runtime: false} to mix.exs"
def search(pattern, opts \\ []) when is_binary(pattern) do
with :ok <- ensure_ex_ast() do
path = Keyword.get(opts, :path)
paths = paths(path)
matches =
paths
|> ex_ast_search(pattern, search_opts(opts))
|> Enum.map(&match_payload/1)
{:ok,
%Search{
pattern: pattern,
path: path,
matches: matches,
total: length(matches),
display: search_display(matches)
}}
end
end
def search_many(patterns, opts \\ []) when is_map(patterns) or is_list(patterns) do
with :ok <- ensure_ex_ast() do
path = Keyword.get(opts, :path)
paths = paths(path)
matches =
patterns
|> Enum.flat_map(fn {name, pattern} ->
paths
|> ex_ast_search(pattern, search_opts(opts))
|> Enum.map(&Map.put(&1, :pattern, to_string(name)))
end)
|> maybe_limit(Keyword.get(opts, :limit))
|> Enum.map(&match_payload/1)
{:ok,
%Search{
pattern: inspect(patterns, limit: 20),
path: path,
matches: matches,
total: length(matches),
display: search_display(matches)
}}
end
end
def replace(pattern, replacement, opts \\ [])
when is_binary(pattern) and is_binary(replacement) do
with :ok <- ensure_ex_ast() do
path = Keyword.get(opts, :path)
dry_run = Keyword.get(opts, :dry_run, false)
paths = paths(path)
opts = Keyword.merge(search_opts(opts), dry_run: dry_run)
diffs = if dry_run, do: replacement_diffs(paths, pattern, replacement, opts), else: []
replacements =
paths
|> ex_ast_replace(pattern, replacement, opts)
|> Enum.map(fn {file, count} -> %Replacement{file: file, count: count} end)
total = Enum.reduce(replacements, 0, fn %Replacement{count: count}, acc -> acc + count end)
{:ok,
%Replace{
dry_run: dry_run,
pattern: pattern,
replacement: replacement,
path: path,
replacements: replacements,
total: total,
diffs: diffs,
display: replace_display(replacements, diffs)
}}
end
end
defp search_opts(opts) do
[]
|> maybe_put(:inside, Keyword.get(opts, :inside))
|> maybe_put(:not_inside, Keyword.get(opts, :not_inside))
|> maybe_put(:allow_broad, Keyword.get(opts, :allow_broad))
|> maybe_put(:limit, Keyword.get(opts, :limit))
end
defp maybe_put(opts, _key, nil), do: opts
defp maybe_put(opts, key, value), do: Keyword.put(opts, key, value)
defp match_payload(%{file: file, line: line, source: source, captures: captures}) do
%Match{file: file, line: line, source: source, captures: render_captures(captures)}
end
defp match_payload(%{file: file, line: line, source: source} = match) do
captures =
match
|> Map.take([:pattern])
|> Map.new(fn {key, value} -> {to_string(key), value} end)
%Match{file: file, line: line, source: source, captures: captures}
end
defp maybe_limit(matches, nil), do: matches
defp maybe_limit(matches, limit) when is_integer(limit), do: Enum.take(matches, limit)
defp search_display(matches) do
%Display{
summary: "#{length(matches)} match(es)",
blocks:
Enum.flat_map(matches, fn %Match{} = match ->
[
%Block{type: :location, path: match.file, line: match.line},
%Block{type: :source, text: match.source, language: language_from_path(match.file)}
]
end)
}
end
defp replace_display(replacements, diffs) do
replacement_blocks =
Enum.map(replacements, fn %Replacement{} = replacement ->
%Block{
type: :text,
text: "#{replacement.file}: #{replacement.count} replacement(s)",
path: replacement.file
}
end)
diff_blocks =
Enum.map(diffs, fn %Diff{} = diff ->
%Block{type: :diff, text: diff.diff, path: diff.file, language: diff.language}
end)
%Display{
summary: "#{length(replacements)} file(s)",
blocks: replacement_blocks ++ diff_blocks
}
end
defp replacement_diffs(paths, pattern, replacement, opts) do
opts = Keyword.drop(opts, [:dry_run])
paths
|> Enum.flat_map(&resolve_paths/1)
|> Enum.flat_map(fn file ->
source = File.read!(file)
replaced = ex_ast_replace_all(source, pattern, replacement, opts)
if source == replaced do
[]
else
[%Diff{file: file, diff: unified_diff(source, replaced, file)}]
end
end)
end
defp unified_diff(old, new, file) do
old_lines = String.split(old, "\n")
new_lines = String.split(new, "\n")
if old == new do
""
else
["--- ", file, "\n", "+++ ", file, "\n" | diff_lines(old_lines, new_lines)]
|> IO.iodata_to_binary()
end
end
defp diff_lines(old_lines, new_lines), do: diff_lines(old_lines, new_lines, [])
defp diff_lines([], [], acc), do: acc |> Enum.reverse() |> List.flatten()
defp diff_lines([old_line | old_rest], [new_line | new_rest], acc) do
diff_lines(old_rest, new_rest, [diff_line(old_line, new_line) | acc])
end
defp diff_lines([], [new_line | new_rest], acc) do
diff_lines([], new_rest, [diff_line(nil, new_line) | acc])
end
defp diff_lines([old_line | old_rest], [], acc) do
diff_lines(old_rest, [], [diff_line(old_line, nil) | acc])
end
defp diff_line(nil, nil), do: []
defp diff_line(line, line), do: [" ", line, "\n"]
defp diff_line(nil, new_line), do: ["+", new_line, "\n"]
defp diff_line(old_line, nil), do: ["-", old_line, "\n"]
defp diff_line(old_line, new_line), do: ["-", old_line, "\n", "+", new_line, "\n"]
defp resolve_paths(path) when is_binary(path) do
cond do
String.contains?(path, "*") -> Path.wildcard(path)
File.dir?(path) -> Path.wildcard(Path.join(path, "**/*.ex*"))
true -> [path]
end
end
defp language_from_path(path) do
case Path.extname(path || "") do
".ex" -> "elixir"
".exs" -> "elixir"
".heex" -> "heex"
ext -> String.trim_leading(ext, ".")
end
end
defp ex_ast_search(paths, pattern, opts), do: apply(ExAST, :search, [paths, pattern, opts])
defp ex_ast_replace(paths, pattern, replacement, opts),
do: apply(ExAST, :replace, [paths, pattern, replacement, opts])
defp ex_ast_replace_all(source, pattern, replacement, opts),
do: apply(ExAST.Patcher, :replace_all, [source, pattern, replacement, opts])
defp ensure_ex_ast do
if Code.ensure_loaded?(ExAST), do: :ok, else: {:error, @missing_ex_ast}
end
defp paths(path) when is_binary(path), do: [path]
defp paths(_path), do: ["lib/"]
defp render_captures(captures) when map_size(captures) == 0, do: %{}
defp render_captures(captures) do
Map.new(captures, fn {name, value} ->
rendered =
Macro.prewalk(value, fn
{form, nil, args} -> {form, [], args}
other -> other
end)
|> Macro.to_string()
{to_string(name), rendered}
end)
end
end