Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
Search, replace, and diff Elixir code by AST pattern.

Patterns are plain Elixir — variables capture, `_` is a wildcard,
structs match partially, pipes are normalized. `...` captures variable arity,
maps and structs match partially, pipes are normalized. `...` matches the rest —
extra call arguments, a block body, or any other map/struct entries —
`^name` matches a literal variable name, `name`/`fun`/`function` can capture
function names in definitions, and `fun`/`function` can capture call names.
`_(...)` and `_._(...)` match any local or remote call. No regex, no custom DSL.
Expand Down Expand Up @@ -110,8 +111,11 @@ a = Repo.get!(_, _); Repo.delete(a)

# Tuples, structs, maps
{:ok, result}
%User{role: :admin}
%{name: name}
%User{role: :admin} # struct with at least this field
%Struct{...} # any struct of that type
%{name: name} # map with at least this key
%{..., name: name} # same subset match, with explicit rest
%{...} # any map, including empty

# Directives and attributes
use GenServer
Expand Down
10 changes: 9 additions & 1 deletion lib/ex_ast/pattern.ex
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,10 @@ defmodule ExAST.Pattern do
defp signature({{:., nil, [_target, name]}, nil, args}) when is_atom(name) and is_list(args),
do: {:call, name, arity_signature(args)}

# Maps match a subset of their keys, so entry count must not gate candidacy;
# filter only to map nodes of any size.
defp signature({:%{}, nil, _args}), do: {:call, :%{}, :any}

# Tuples (`{:{}, _, _}`) can't be prefiltered by call name: 2-tuples keep their
# literal `{a, b}` shape at the source, so a `:{}` filter would drop them. And
# `...` is a matcher directive, not a callable — treating it as a call
Expand All @@ -685,6 +689,8 @@ defmodule ExAST.Pattern do
when is_atom(name) and is_list(args),
do: {:call, name, arity_signature(args)}

defp nested_call_signature({:%{}, _meta, _args}), do: {:call, :%{}, :any}

defp nested_call_signature({head, _meta, _args}) when head in [:{}, :...], do: :unknown

defp nested_call_signature({name, nil, args}) when is_atom(name) and is_list(args),
Expand Down Expand Up @@ -1088,7 +1094,9 @@ defmodule ExAST.Pattern do
# --- Subset matching for structs/maps ---

defp match_subset(source_kvs, pattern_kvs, caps) do
Enum.reduce_while(pattern_kvs, {:ok, caps}, fn {pkey, pval}, {:ok, caps} ->
pattern_kvs
|> Enum.reject(&ellipsis?/1)
|> Enum.reduce_while({:ok, caps}, fn {pkey, pval}, {:ok, caps} ->
source_kvs
|> find_value_by_key(pkey)
|> match_kv_value(pval, caps)
Expand Down
46 changes: 46 additions & 0 deletions test/ex_ast/pattern_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,52 @@ defmodule ExAST.PatternTest do
end
end

describe "map and struct subset matching via search" do
test "a plain map pattern matches any map containing that entry" do
src = "one = %{a: 1}\ntwo = %{a: 1, b: 2}\n"
assert [%{}, %{}] = ExAST.Patcher.find_all(src, "%{a: 1}")
end

test "%{..., k: v} matches any map containing that entry" do
src = "one = %{a: 1}\ntwo = %{a: 1, b: 2}\n"
assert [%{}, %{}] = ExAST.Patcher.find_all(src, "%{..., a: 1}")
end

test "%{..., k: capture} binds the value from a larger map" do
assert [%{captures: %{v: 2}}] =
ExAST.Patcher.find_all("x = %{a: 1, b: 2}", "%{..., b: v}")
end

test "a struct pattern matches structs of that type by a subset of fields" do
assert [%{}] =
ExAST.Patcher.find_all(
~s|x = %Config{port: 4000, host: "y"}|,
"%Config{port: 4000}"
)
end
end

describe "map patterns with ellipsis" do
test "%{...} matches any map without crashing on call-valued entries" do
source = """
defmodule M do
def perms, do: %{admin: grant(:admin), user: :ok}
end
"""

assert [%{}] = ExAST.Patcher.find_all(source, "def name do %{...} end")
end

test "%{...} matches an empty map" do
assert [%{}] = ExAST.Patcher.find_all("x = %{}", "%{...}")
end

test "%Struct{...} matches any struct of that type, including empty" do
assert [%{}] = ExAST.Patcher.find_all(~s|x = %Config{port: 4000}|, "%Config{...}")
assert [%{}] = ExAST.Patcher.find_all("x = %Config{}", "%Config{...}")
end
end

describe "arity syntax in definitions" do
defp find_defs(source, pattern) do
ast = Sourceror.parse_string!(source)
Expand Down
23 changes: 9 additions & 14 deletions test/mix/tasks/ex_ast/search_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -262,25 +262,20 @@ defmodule Mix.Tasks.ExAst.SearchTest do
end

@tag :tmp_dir
test "--count-by-file reports per-file counts", %{tmp_dir: dir} do
a = Path.join(dir, "a.ex")
b = Path.join(dir, "b.ex")
File.write!(a, "IO.inspect(1)\nIO.inspect(2)\n")
File.write!(b, "IO.inspect(3)\n")
test "a map pattern with ... matches through the mix task", %{tmp_dir: dir} do
file = Path.join(dir, "sample.ex")

File.write!(
file,
"defmodule M do\n def perms, do: %{admin: grant(:admin), user: :ok}\nend\n"
)

output =
capture_io(fn ->
Mix.Task.run("ex_ast.search", ["IO.inspect(_)", a, b, "--count-by-file"])
Mix.Task.run("ex_ast.search", ["def name do %{...} end", file])
end)

assert output =~ "2\t#{a}"
assert output =~ "1\t#{b}"
assert output =~ "3 match(es) in 2 file(s)"

lines = String.split(output, "\n", trim: true)

assert Enum.find_index(lines, &(&1 == "2\t#{a}")) <
Enum.find_index(lines, &(&1 == "1\t#{b}"))
assert output =~ "1 match(es)"
end
end

Expand Down
Loading