From f7794c55faacbb46201a5c3a00fcbb6954195dea Mon Sep 17 00:00:00 2001 From: Victor Rodrigues Date: Sat, 18 Jul 2026 12:59:43 +0100 Subject: [PATCH 1/3] Fix crash on `...` in map and struct patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `%{...}`, `%{..., k: v}` and `%Struct{...}` raised FunctionClauseError: match_subset/3 had no clause for the `...` entry. Skip `...` during subset matching so it imposes no key constraint. The candidate filter already treated a `...` map/struct as any-arity, so this completes the feature end to end: `...` matches any map/struct (including empty), and `%{..., k: v}` matches any map with at least that entry. Plain `%{k: v}` is unchanged — still an exact key set. --- README.md | 10 ++++-- lib/ex_ast/pattern.ex | 4 ++- test/ex_ast/pattern_test.exs | 45 +++++++++++++++++++++++++++ test/mix/tasks/ex_ast/search_test.exs | 23 ++++++-------- 4 files changed, 64 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index c61834b..90aeb89 100644 --- a/README.md +++ b/README.md @@ -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, +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. No regex, no custom DSL. @@ -103,8 +104,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 exactly these keys +%{..., name: name} # any map with at least this key +%{...} # any map, including empty # Directives and attributes use GenServer diff --git a/lib/ex_ast/pattern.ex b/lib/ex_ast/pattern.ex index 3b11a9f..6340ec7 100644 --- a/lib/ex_ast/pattern.ex +++ b/lib/ex_ast/pattern.ex @@ -928,7 +928,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) diff --git a/test/ex_ast/pattern_test.exs b/test/ex_ast/pattern_test.exs index be71f09..bf907ca 100644 --- a/test/ex_ast/pattern_test.exs +++ b/test/ex_ast/pattern_test.exs @@ -181,6 +181,51 @@ defmodule ExAST.PatternTest do end end + describe "map and struct subset matching via search" do + test "a plain map pattern matches only the exact key set" 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 "pipes" do test "pipe into function" do assert {:ok, %{}} = match!("data |> Enum.map(fun)", "_ |> Enum.map(_)") diff --git a/test/mix/tasks/ex_ast/search_test.exs b/test/mix/tasks/ex_ast/search_test.exs index e289b43..72b76d4 100644 --- a/test/mix/tasks/ex_ast/search_test.exs +++ b/test/mix/tasks/ex_ast/search_test.exs @@ -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 From 38cc686bd4ac83b7594fb7537428995f88e52f35 Mon Sep 17 00:00:00 2001 From: Victor Rodrigues Date: Tue, 21 Jul 2026 15:34:05 +0100 Subject: [PATCH 2/3] Match maps by subset in search, not exact entry count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Map patterns matched a subset of keys at the matcher level, but the top-level search silently required an exact key set: a map pattern's candidate signature was {:call, :%{}, n}, treating the entry count as a call arity, so candidate prefiltering rejected any map with a different number of entries before the matcher ran. Nested maps (e.g. as the RHS of `_ = %{a: 1}`) were never prefiltered this way and already matched partially, and the docs describe maps as partial — so top-level search was the inconsistent case. Give map patterns a :any-arity signature in both signature/1 and nested_call_signature/1, so prefiltering still restricts candidates to map nodes but no longer gates on entry count. Subset matching is then handled by the matcher as intended. Update the search test to assert subset behavior: `%{a: 1}` now matches both `%{a: 1}` and `%{a: 1, b: 2}`. --- lib/ex_ast/pattern.ex | 6 ++++++ test/ex_ast/pattern_test.exs | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/ex_ast/pattern.ex b/lib/ex_ast/pattern.ex index 6340ec7..94a0191 100644 --- a/lib/ex_ast/pattern.ex +++ b/lib/ex_ast/pattern.ex @@ -640,6 +640,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} + defp signature({name, nil, args}) when is_atom(name) and is_list(args), do: {:call, name, arity_signature(args)} @@ -656,6 +660,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({name, nil, args}) when is_atom(name) and is_list(args), do: {:call, name, arity_signature(args)} diff --git a/test/ex_ast/pattern_test.exs b/test/ex_ast/pattern_test.exs index bf907ca..1f73a5f 100644 --- a/test/ex_ast/pattern_test.exs +++ b/test/ex_ast/pattern_test.exs @@ -182,9 +182,9 @@ defmodule ExAST.PatternTest do end describe "map and struct subset matching via search" do - test "a plain map pattern matches only the exact key set" 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}") + assert [%{}, %{}] = ExAST.Patcher.find_all(src, "%{a: 1}") end test "%{..., k: v} matches any map containing that entry" do From 2693868b42355dd1e1ae7e28b91ea155be95c02c Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 22 Jul 2026 14:33:16 +0200 Subject: [PATCH 3/3] Fix map subset examples in README --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 982c904..214cf5e 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Search, replace, and diff Elixir code by AST pattern. Patterns are plain Elixir — variables capture, `_` is a wildcard, -structs match partially, pipes are normalized. `...` matches the rest — +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. @@ -113,8 +113,8 @@ a = Repo.get!(_, _); Repo.delete(a) {:ok, result} %User{role: :admin} # struct with at least this field %Struct{...} # any struct of that type -%{name: name} # map with exactly these keys -%{..., name: name} # any map with at least this key +%{name: name} # map with at least this key +%{..., name: name} # same subset match, with explicit rest %{...} # any map, including empty # Directives and attributes