Skip to content

Support ... in map and struct patterns - #7

Merged
dannote merged 4 commits into
elixir-vibe:masterfrom
rodrigues:vr/fix_map
Jul 22, 2026
Merged

Support ... in map and struct patterns#7
dannote merged 4 commits into
elixir-vibe:masterfrom
rodrigues:vr/fix_map

Conversation

@rodrigues

@rodrigues rodrigues commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

A literal ... in a map or struct pattern crashed — match_subset/3 had no clause for the ... node — so %{...}, %{..., k: v} and %Struct{...} all raised FunctionClauseError.

The candidate filter already recognized these as any-arity maps/structs, so the feature was half-wired: patterns passed the filter and then blew up in subset matching.

What you can search for now with mix ex_ast.search:

Pattern Matches
%{role: :admin} maps with exactly that key set
%{..., role: :admin} any map with at least role: :admin
%{..., status: s} any map with a status key, capturing its value
%{...} any map, including empty
%Struct{...} any struct of that type

Plain map patterns (%{k: v}) are unchanged — still an exact key set. Structs already matched by subset of fields; %Struct{...} just drops the field constraint entirely.

Before

$ mix ex_ast.search "%{...}" lib/
** (FunctionClauseError) no function clause matching in ExAST.Pattern.match_subset/3

After

$ mix ex_ast.search "%{...}" lib/
%{admin: grant(:admin), user: :ok}
1 match(es)
$ mix ex_ast.search "%{..., start: _, end: _}" lib/
lib/ex_ast.ex:337
  %{start: start, end: end_}

lib/ex_ast/comments.ex:43
  %{start: start, end: end_}

lib/ex_ast/diff/patcher.ex:47
  %{start: point, end: point}

lib/ex_ast/diff/patcher.ex:106
  %{start: start_pos, end: end_pos}

lib/ex_ast/diff/patcher.ex:128
  %{start: s, end: e}

lib/ex_ast/diff/patcher.ex:129
  %{start: [line: s[:line], column: 1], end: [line: e[:line] + 1, column: 1]}

lib/ex_ast/json.ex:2
  {start: start, end: end_}

lib/ex_ast/json.ex:3
  %{start: position(start), end: position(end_)}

lib/ex_ast/patcher.ex:518
  {
          start: Sourceror.get_range(first).start,
          end: Sourceror.get_range(last).end
        }

lib/ex_ast/patcher.ex:768
  %{start: start, end: end_}

lib/ex_ast/patcher.ex:1029
  %{start: start, end: end_}


11 match(es)

@dannote dannote left a comment

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.

The crash fix looks good, but this also codifies an existing inconsistency in map matching.

Comment thread test/ex_ast/pattern_test.exs Outdated
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}")

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 test locks in behavior that doesn't match the rest of the matcher. Maps are documented as partial, and nested maps already behave that way:

Patcher.find_all("x = %{a: 1, b: 2}", "_ = %{a: 1}")
# one match

The top-level pattern only looks exact because candidate filtering checks the number of entries. We should fix that inconsistency rather than document it as the intended behavior. The ellipsis crash fix itself looks good.

`%{...}`, `%{..., 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.
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}`.
@dannote
dannote merged commit 95c0042 into elixir-vibe:master Jul 22, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants