Skip to content

Commit

Permalink
[Zipper] Fix search_pattern/2 and move_to_cursor/2 (#172)
Browse files Browse the repository at this point in the history
* [Zipper] Fix `search_pattern/2` and `move_to_cursor/2`

* Disable cyclomatic complexity check

The function is readable as-is

---------

Co-authored-by: doorgan <dorgandash@gmail.com>
  • Loading branch information
zachallaun and doorgan authored Nov 1, 2024
1 parent 2e020b7 commit cb99192
Show file tree
Hide file tree
Showing 2 changed files with 167 additions and 110 deletions.
218 changes: 108 additions & 110 deletions lib/sourceror/zipper.ex
Original file line number Diff line number Diff line change
Expand Up @@ -575,10 +575,39 @@ defmodule Sourceror.Zipper do
do: %{zipper | path: path, supertree: supertree}

@doc """
Searches `zipper` for the given pattern, moving to that pattern or to the
location of `__cursor__()` in that pattern.
Searches forward in `zipper` for the given pattern, moving to that
pattern or a location inside that pattern.
Note that the search may continue outside of `zipper` in a depth-first
order. If this isn't desirable, call this function with a `subtree/1`.
If passed `nil`, this function returns `nil`.
There are two special forms that can be used inside patterns:
* `__cursor__()` - if the pattern matches, the zipper will be focused
at the location of `__cursor__()`, if present
* `__` - "wildcard match" that will match a single node of any form.
## Examples
iex> zipper =
...> \"""
...> defmodule Example do
...> def my_function(arg1, arg2) do
...> arg1 + arg2
...> end
...> end
...> \"""
...> |> Sourceror.parse_string!()
...> |> zip()
...> found = search_pattern(zipper, "my_function(arg1, arg2)")
...> {:my_function, _, [{:arg1, _, _}, {:arg2, _, _}]} = found.node
...> found = search_pattern(zipper, "my_function(__, __)")
...> {:my_function, _, [{:arg1, _, _}, {:arg2, _, _}]} = found.node
...> found = search_pattern(zipper, "def my_function(__, __cursor__()), __")
...> {:arg2, _, _} = found.node
"""
@spec search_pattern(t, String.t() | t) :: t | nil
@spec search_pattern(nil, String.t() | t) :: nil
Expand All @@ -590,102 +619,47 @@ defmodule Sourceror.Zipper do
end

def search_pattern(%Z{} = zipper, %Z{} = pattern_zipper) do
if contains_cursor?(pattern_zipper) do
search_to_cursor(zipper, pattern_zipper)
else
search_to_exact(zipper, pattern_zipper)
case find_pattern(zipper, pattern_zipper, :error) do
{:ok, found} -> found
:error -> zipper |> next() |> search_pattern(pattern_zipper)
end
end

def search_pattern(nil, _pattern), do: nil

defp search_to_cursor(%Z{} = zipper, %Z{} = pattern_zipper) do
with match_kind when is_atom(match_kind) <- match_zippers(zipper, pattern_zipper),
%Z{} = new_zipper <- move_to_cursor(zipper, pattern_zipper) do
new_zipper
else
_ ->
zipper |> next() |> search_to_cursor(pattern_zipper)
end
end

defp search_to_cursor(nil, _), do: nil

defp search_to_exact(%Z{} = zipper, %Z{} = pattern_zipper) do
if similar_or_skip?(zipper.node, pattern_zipper.node) do
zipper
else
zipper |> next() |> search_to_exact(pattern_zipper)
end
end

defp search_to_exact(nil, _), do: nil

defp contains_cursor?(%Z{} = zipper) do
!!find(zipper, &match?({:__cursor__, _, []}, &1))
end

defp similar_or_skip?(_, {:__, _, _}), do: true

defp similar_or_skip?({:__block__, _, [left]}, right) do
similar_or_skip?(left, right)
end

defp similar_or_skip?(left, {:__block__, _, [right]}) do
similar_or_skip?(left, right)
end

defp similar_or_skip?({call1, _, args1}, {call2, _, args2}) do
similar_or_skip?(call1, call2) and similar_or_skip?(args1, args2)
end

defp similar_or_skip?({l1, r1}, {l2, r2}) do
similar_or_skip?(l1, l2) and similar_or_skip?(r1, r2)
end

defp similar_or_skip?(list1, list2) when is_list(list1) and is_list(list2) do
length(list1) == length(list2) and
[list1, list2]
|> Enum.zip()
|> Enum.all?(fn {el1, el2} ->
similar_or_skip?(el1, el2)
end)
end

defp similar_or_skip?(same, same), do: true

defp similar_or_skip?(_, _), do: false

@doc """
Matches `zipper` against the given pattern, moving to the location of `__cursor__()`.
Use `__cursor__()` to match a cursor in the provided source code. Use `__` to skip any code at a point.
This function only moves `zipper` if the current node matches the pattern.
To search for a pattern in `zipper`, use `search_pattern/2`.
There are two special forms that can be used inside patterns:
* `__cursor__()` - if the pattern matches, the zipper will be focused
at the location of `__cursor__()`, if present
* `__` - "wildcard match" that will match a single node of any form.
If passed `nil`, this function returns `nil`.
## Examples
```elixir
zipper =
\"\"\"
if true do
10
end
\"\"\"
|> Sourceror.Zipper.zip()
pattern =
\"\"\"
if __ do
__cursor__()
end
\"\"\"
iex> zipper =
...> \"""
...> if true do
...> 10
...> end
...> \"""
...> |> Sourceror.parse_string!()
...> |> zip()
iex> pattern =
...> \"""
...> if __ do
...> __cursor__()
...> end
...> \"""
iex> found = move_to_cursor(zipper, pattern)
iex> {:__block__, _, [10]} = found.node
zipper
|> Zipper.move_to_cursor(pattern)
|> Zipper.node()
# => 10
```
"""
@spec move_to_cursor(t, String.t() | t) :: t | nil
@spec move_to_cursor(nil, String.t() | t) :: nil
Expand All @@ -696,49 +670,73 @@ defmodule Sourceror.Zipper do
|> then(&move_to_cursor(zipper, &1))
end

def move_to_cursor(%Z{} = zipper, %Z{node: {:__cursor__, _, []}}) do
zipper
end

def move_to_cursor(%Z{} = zipper, %Z{} = pattern_zipper) do
case match_zippers(zipper, pattern_zipper) do
:skip -> move_zippers(zipper, pattern_zipper, &skip/1)
:next -> move_zippers(zipper, pattern_zipper, &next/1)
_ -> nil
case find_pattern(zipper, pattern_zipper, :error) do
{:ok, found} -> found
:error -> nil
end
end

def move_to_cursor(nil), do: nil
def move_to_cursor(nil, _pattern), do: nil

defp find_pattern(%Z{} = zipper, %Z{} = pattern_zipper, result) do
case pattern_zipper.node do
{:__cursor__, _, []} ->
find_pattern(skip(zipper), next(pattern_zipper), {:ok, zipper})

{:__, _, nil} ->
find_pattern(skip(zipper), next(pattern_zipper), result)

_ ->
case {move_similar_zippers(zipper, pattern_zipper), result} do
{{next_zipper, next_pattern_zipper}, {:ok, _}} ->
find_pattern(next_zipper, next_pattern_zipper, result)

defp move_zippers(zipper, pattern_zipper, move) do
with %Z{} = zipper <- move.(zipper),
%Z{} = pattern_zipper <- move.(pattern_zipper) do
move_to_cursor(zipper, pattern_zipper)
{{next_zipper, next_pattern_zipper}, :error} ->
find_pattern(next_zipper, next_pattern_zipper, {:ok, zipper})

{nil, _} ->
:error
end
end
end

defp match_zippers(%Z{node: zipper_node}, %Z{node: pattern_node}) do
case {zipper_node, pattern_node} do
{_, {:__, _, _}} ->
:skip
defp find_pattern(_zipper, nil, result), do: result
defp find_pattern(nil, _pattern, _result), do: :error

# Moves a pair of zippers one step so long as the outermost structure
# matches. Notably, this function unwraps single-element blocks, so
# {:__block__, _, [:foo]} and :foo would match, and the zippers would
# be moved to the next node.
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
defp move_similar_zippers(%Z{} = zipper, %Z{} = pattern_zipper) do
case {zipper.node, pattern_zipper.node} do
{{:__block__, _, [_left]}, _right} ->
move_similar_zippers(next(zipper), pattern_zipper)

{{call, _, _}, {call, _, _}} ->
:next
{_left, {:__block__, _, [_right]}} ->
move_similar_zippers(zipper, next(pattern_zipper))

{{{call, _, _}, _, _}, {{call, _, _}, _, _}} ->
:next
{_, {:__, _, nil}} ->
{skip(zipper), skip(pattern_zipper)}

{{call, _, _}, {call, _, _}} when is_atom(call) ->
{next(zipper), next(pattern_zipper)}

{{{_, _, _}, _, _}, {{_, _, _}, _, _}} ->
{next(zipper), next(pattern_zipper)}

{{_, _}, {_, _}} ->
:next
{next(zipper), next(pattern_zipper)}

{same, same} ->
:next
{skip(zipper), skip(pattern_zipper)}

{left, right} when is_list(left) and is_list(right) ->
:next
{left, right} when is_list(left) and is_list(right) and length(left) == length(right) ->
{next(zipper), next(pattern_zipper)}

_ ->
false
nil
end
end

Expand Down
59 changes: 59 additions & 0 deletions test/zipper_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -980,6 +980,21 @@ defmodule SourcerorTest.ZipperTest do
code |> Z.search_pattern(seek) |> Z.node() |> Sourceror.to_string()
end

test "matches list sub-expressions with cursor" do
code =
"[[:foo, :bar], :baz]"
|> Sourceror.parse_string!()
|> Z.zip()

seek = "[__cursor__(), :bar]"

assert ":foo" == code |> Z.search_pattern(seek) |> Z.node() |> Sourceror.to_string()

seek = "[__cursor__(), :baz]"

assert "[:foo, :bar]" == code |> Z.search_pattern(seek) |> Z.node() |> Sourceror.to_string()
end

test "matches sub-expression with cursor and ignored elements" do
code =
"""
Expand All @@ -997,6 +1012,40 @@ defmodule SourcerorTest.ZipperTest do
assert "IO.puts()" ==
code |> Z.search_pattern(seek) |> Z.node() |> Sourceror.to_string()
end

test "only matches if outer expression still matches" do
code =
"[[:foo, :bar], :baz]"
|> Sourceror.parse_string!()
|> Z.zip()

bad_seek = "[__cursor__(), :buzz]"

assert nil == Z.search_pattern(code, bad_seek)
end

test "continues past current zipper focus" do
code =
[[[:foo], :bar], :baz]
|> Z.zip()
|> Z.next()

assert [[:foo], :bar] = code.node

assert %Z{node: :baz} = Z.search_pattern(code, ":baz")
end

test "doesn't continue past current zipper focus in subtree" do
code =
[[[:foo], :bar], :baz]
|> Z.zip()
|> Z.next()
|> Z.subtree()

assert [[:foo], :bar] = code.node

assert nil == Z.search_pattern(code, ":baz")
end
end

describe "search_pattern/2 without cursor" do
Expand Down Expand Up @@ -1108,6 +1157,16 @@ defmodule SourcerorTest.ZipperTest do

assert "20" == new_zipper |> Z.node() |> Sourceror.to_string()
end

test "requires that elements after the cursor match" do
code =
[[[:foo], :bar], :baz]
|> Z.zip()

seek = "[[[:foo], __cursor__()], :NOMATCH]"

assert nil == Z.move_to_cursor(code, seek)
end
end

describe "at/2" do
Expand Down

0 comments on commit cb99192

Please sign in to comment.