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
44 changes: 42 additions & 2 deletions lib/ex_ast/pattern.ex
Original file line number Diff line number Diff line change
Expand Up @@ -257,14 +257,28 @@ defmodule ExAST.Pattern do
def normalize({form, _meta, args}),
do: {normalize(form), nil, normalize(args)}

# Genuine 2-tuple literal: fold into the variadic `{:{}, _, [a, b]}` form so
# every arity flows through one path. Map/keyword entries are also `{k, v}`
# 2-tuples, but they arrive as list elements and are preserved by
# `normalize_entry/1` — only standalone tuples reach here.
def normalize({left, right}),
do: {normalize(left), normalize(right)}
do: {:{}, nil, [normalize(left), normalize(right)]}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes pattern normalization and source normalization asymmetric. A plain quoted source tuple stays {a, b}, while the pattern is folded to {:{}, nil, [...]}:

Pattern.match(
  quote(do: {:ok, value}),
  quote(do: {:ok, _})
)
# :error

Could you normalize both paths the same way (or handle the reverse shape) and add a test with an ordinary quote source?


def normalize(list) when is_list(list),
do: Enum.map(list, &normalize/1)
do: Enum.map(list, &normalize_entry/1)

def normalize(other), do: other

defp normalize_entry({key, value}), do: {normalize(key), normalize(value)}
defp normalize_entry(node), do: normalize(node)

# Sourceror encodes a genuine 2-tuple literal as `{:__block__, _, [{a, b}]}`
# (the extra block carries metadata a bare 2-tuple has no slot for). Fold it
# into the variadic `{:{}, _, [a, b]}` form so all tuple arities share one
# path. Bare `{k, v}` 2-tuples are map/keyword entries and stay untouched.
defp normalize({:__block__, _meta, [{a, b}]}, alias_env),
do: {:{}, nil, [normalize(a, alias_env), normalize(b, alias_env)]}

defp normalize({:__block__, _meta, [inner]}, alias_env), do: normalize(inner, alias_env)

defp normalize({:|>, _meta, [left, {form, meta2, args}]}, alias_env) when is_list(args),
Expand Down Expand Up @@ -640,6 +654,12 @@ 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)}

# 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
# (`{:contains_call, :..., 0}`) wrongly filters out candidates.
defp signature({head, nil, _args}) when head in [:{}, :...], do: :unknown

defp signature({name, nil, args}) when is_atom(name) and is_list(args),
do: {:call, name, arity_signature(args)}

Expand All @@ -656,6 +676,8 @@ defmodule ExAST.Pattern do
when is_atom(name) and is_list(args),
do: {:call, name, arity_signature(args)}

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),
do: {:call, name, arity_signature(args)}

Expand Down Expand Up @@ -815,6 +837,24 @@ defmodule ExAST.Pattern do
end
end

# Bare 2-tuple pattern vs a folded 2-element source tuple. A genuine 2-tuple
# in a list is indistinguishable from a keyword entry at parse time, so the
# pattern side keeps it bare while the source side folds it to the variadic
# `{:{}, _, [a, b]}` form. Bridge the two shapes here.
defp do_match({:{}, nil, [sa, sb]}, {pa, pb}, caps) do
with {:ok, caps} <- do_match(sa, pa, caps) do
do_match(sb, pb, caps)
end
end

# Reverse of the above: a quoted source keeps a genuine 2-tuple bare, while a
# standalone pattern tuple folds to `{:{}, _, [a, b]}`. Bridge that shape too.
defp do_match({sa, sb}, {:{}, nil, [pa, pb]}, caps) do
with {:ok, caps} <- do_match(sa, pa, caps) do
do_match(sb, pb, caps)
end
end

# 2-tuple (keyword pair, two-element tuple)
defp do_match({sa, sb}, {pa, pb}, caps) do
with {:ok, caps} <- do_match(sa, pa, caps) do
Expand Down
187 changes: 187 additions & 0 deletions test/ex_ast/pattern_test.exs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
defmodule ExAST.PatternTest do
use ExUnit.Case, async: true

alias ExAST.Patcher
alias ExAST.Pattern

defp match!(source, pattern) do
Expand Down Expand Up @@ -145,6 +146,192 @@ defmodule ExAST.PatternTest do
assert {:ok, caps} = match!("{:noreply, []}", "{:noreply, state}")
assert Map.has_key?(caps, :state)
end

test "quoted source 2-tuple against quoted pattern" do
assert {:ok, %{}} = Pattern.match(quote(do: {:ok, value}), quote(do: {:ok, _}))

assert {:ok, %{v: _}} = Pattern.match(quote(do: {:ok, value}), quote(do: {:ok, v}))

assert :error = Pattern.match(quote(do: :error), quote(do: {:ok, _}))
end
end

describe "tuple ellipsis via find_all (all arities)" do
defp find(source, pattern), do: Patcher.find_all(source, pattern)

test "{...} matches every tuple arity" do
assert [_] = find("x = {}", "{...}")
assert [_] = find("x = {:only}", "{...}")
assert [_] = find("x = {:ok, value}", "{...}")
assert [_] = find("x = {1, 2, 3}", "{...}")
assert [_] = find("x = {1, 2, 3, 4}", "{...}")
end

test "{...} does not match non-tuples" do
assert [] = find("x = [1, 2, 3]", "{...}")
assert [] = find("x = %{a: 1}", "{...}")
end

test "{:ok, ...} matches 2-tuples specifically" do
assert [_] = find("x = {:ok, value}", "{:ok, ...}")
assert [_] = find("x = {:ok, 1, 2}", "{:ok, ...}")
assert [] = find("x = {:error, reason}", "{:ok, ...}")
end

test "{..., :done} matches 2-tuples specifically" do
assert [_] = find("x = {:step, :done}", "{..., :done}")
assert [_] = find("x = {1, 2, :done}", "{..., :done}")
assert [] = find("x = {:step, :pending}", "{..., :done}")
end

test "captures still work on 2-tuples" do
assert [%{captures: caps}] = find("x = {:ok, payload}", "{:ok, v}")
assert Map.has_key?(caps, :v)

assert [%{captures: caps}] = find("x = {:ok, payload}", "{a, b}")
assert Map.has_key?(caps, :a)
assert Map.has_key?(caps, :b)

assert [_] = find("x = {:ok, payload}", "{_, _}")
end

test "ellipsis captures head, tail, and both sides" do
assert [%{captures: %{first: _}}] = find("x = {1, 2, 3}", "{first, ...}")
assert [%{captures: %{last: _}}] = find("x = {1, 2, 3}", "{..., last}")

assert [%{captures: caps}] = find("x = {1, 2, 3, 4}", "{first, ..., last}")
assert Map.has_key?(caps, :first)
assert Map.has_key?(caps, :last)
end

test "multiple fixed elements on either side of ellipsis" do
assert [%{captures: caps}] = find("x = {1, 2, 3, 4}", "{a, b, ...}")
assert Map.has_key?(caps, :a)
assert Map.has_key?(caps, :b)

assert [%{captures: caps}] = find("x = {1, 2, 3, 4}", "{..., c, d}")
assert Map.has_key?(caps, :c)
assert Map.has_key?(caps, :d)

assert [_] = find("x = {1, 2, 3, 4, 5}", "{a, ..., d, e}")
end

test "ellipsis enforces the minimum arity of fixed elements" do
assert [_] = find("x = {1, 2}", "{first, ..., last}")
assert [] = find("x = {1}", "{first, ..., last}")

assert [_] = find("x = {1, 2}", "{a, b, ...}")
assert [] = find("x = {1}", "{a, b, ...}")
end

test "ellipsis still respects fixed literals around it" do
assert [_] = find("x = {:ok, 1, 2, :done}", "{:ok, ..., :done}")
assert [] = find("x = {:ok, 1, 2, :nope}", "{:ok, ..., :done}")
assert [] = find("x = {:no, 1, 2, :done}", "{:ok, ..., :done}")
end

test "maps and keywords are unaffected" do
assert [_] = find("x = %{a: 1}", "%{a: 1}")
assert [%{captures: %{n: _}}] = find("x = %{a: 1}", "%{a: n}")

assert [_ | _] = find("x = [a: 1]", "[a: 1]")
assert [%{captures: %{v: _}} | _] = find("x = [a: 1]", "[a: v]")
end
end

describe "structural patterns on = and <-" do
test "tuple on LHS of = (match operator)" do
assert {:ok, %{}} = match!("{:ok, val} = fetch(y)", "{:ok, _} = _")
end

test "tuple with capture on LHS of =" do
assert {:ok, caps} = match!("{:ok, val} = fetch(y)", "{:ok, x} = _")
assert Map.has_key?(caps, :x)
end

test "tuple as RHS value of =" do
assert {:ok, %{}} = match!("result = {:ok, val}", "_ = {:ok, _}")
end

test "3-tuple on LHS of =" do
assert {:ok, %{}} = match!("{:ok, a, b} = fetch(y)", "{:ok, _, _} = _")
end

test "list on LHS of =" do
assert {:ok, %{}} = match!("[h | t] = fetch(y)", "[_ | _] = _")
end

test "map on LHS of =" do
assert {:ok, %{}} = match!("%{a: v} = fetch(y)", "%{a: _} = _")
end

test "wildcard on LHS of =" do
assert {:ok, %{}} = match!("{:ok, val} = fetch(y)", "_ = _")
end

test "capture on LHS of =" do
assert {:ok, %{pat: _}} = match!("{:ok, val} = fetch(y)", "pat = _")
end

test "tuple on LHS of <- (with clause)" do
assert {:ok, %{}} =
match!(
"with {:ok, val} <- fetch(x) do\n val\nend",
"with {:ok, _} <- _ do ... end"
)
end

test "tuple with capture on LHS of <-" do
assert {:ok, caps} =
match!(
"with {:ok, val} <- fetch(x) do\n val\nend",
"with {:ok, x} <- _ do ... end"
)

assert Map.has_key?(caps, :x)
end

test "find_all: tuple on LHS of = (match operator)" do
source = """
def g(y) do
{:ok, val} = fetch(y)
val
end
"""

assert [_] = ExAST.Patcher.find_all(source, "{:ok, _} = _")
end

test "find_all: tuple on LHS of <- (with clause)" do
source = """
def f(x) do
with {:ok, val} <- fetch(x) do
val
end
end
"""

assert [_] = ExAST.Patcher.find_all(source, "with {:ok, _} <- _ do ... end")
end

test "find_all: tuple as RHS value of =" do
source = """
def g(y) do
result = {:ok, y}
result
end
"""

assert [_] = ExAST.Patcher.find_all(source, "_ = {:ok, _}")
end

test "tuple as a list element" do
assert {:ok, %{}} = match!("[{:ok, val}]", "[{:ok, _}]")
end

test "tuple as a call argument inside a list" do
assert {:ok, %{}} = match!("foo([{:ok, val}])", "foo([{:ok, _}])")
end
end

describe "function definitions" do
Expand Down
Loading