Skip to content

Commit

Permalink
Ensure get_range works for all syntax nodes parsed using `Sourceror…
Browse files Browse the repository at this point in the history
….parse_string` (#104)

* fix: correct range for quoted atoms in calls

* Fix `get_range/1` for more syntax nodes
  • Loading branch information
zachallaun authored Sep 10, 2023
1 parent 0dac9b9 commit 2eba98f
Show file tree
Hide file tree
Showing 2 changed files with 269 additions and 12 deletions.
81 changes: 69 additions & 12 deletions lib/sourceror/range.ex
Original file line number Diff line number Diff line change
Expand Up @@ -153,17 +153,24 @@ defmodule Sourceror.Range do
end

# Block with no parenthesis
defp do_get_range({:__block__, _, args} = quoted) do
defp do_get_range({:__block__, meta, args} = quoted) do
if Sourceror.has_closing_line?(quoted) do
get_range_for_node_with_closing_line(quoted)
else
{first, rest} = List.pop_at(args, 0)
{last, _} = List.pop_at(rest, -1, first)

%{
start: get_range(first).start,
end: get_range(last).end
}
case args do
[{_, _, _} | _] ->
{first, rest} = List.pop_at(args, 0)
{last, _} = List.pop_at(rest, -1, first)

%{
start: get_range(first).start,
end: get_range(last).end
}

[charlist] when is_list(charlist) ->
string = List.to_string(charlist)
do_get_range({:__block__, meta, [string]})
end
end
end

Expand Down Expand Up @@ -204,7 +211,16 @@ defmodule Sourceror.Range do
%{start: start_pos, end: end_pos}
end

# Stabs
# Stabs without args
# -> b
defp do_get_range({:->, meta, [[], right]}) do
start_pos = Keyword.take(meta, [:line, :column])
end_pos = get_range(right).end

%{start: start_pos, end: end_pos}
end

# Stabs with args
# a -> b
defp do_get_range({:->, _, [left_args, right]}) do
start_pos = get_range(left_args).start
Expand All @@ -213,6 +229,28 @@ defmodule Sourceror.Range do
%{start: start_pos, end: end_pos}
end

# Argument capture syntax
# &1
defp do_get_range({:&, meta, [int]}) when is_integer(int) do
start_pos = Keyword.take(meta, [:line, :column])
int_len = int |> Integer.to_string() |> String.length()

%{start: start_pos, end: [line: meta[:line], column: meta[:column] + int_len + 1]}
end

# Unwrapped Access syntax
defp do_get_range({:., _, [Access, :get]} = quoted) do
get_range_for_node_with_closing_line(quoted)
end

# Unwrapped qualified calls
defp do_get_range({:., meta, [left, atom]}) when is_atom(atom) do
start_pos = get_range(left).start
atom_length = atom |> inspect() |> String.length()

%{start: start_pos, end: [line: meta[:line], column: meta[:column] + atom_length]}
end

# Access syntax
defp do_get_range({{:., _, [Access, :get]}, _, _} = quoted) do
get_range_for_node_with_closing_line(quoted)
Expand All @@ -231,6 +269,15 @@ defmodule Sourceror.Range do
get_range_for_interpolation(interpolation)
end

# Interpolated charlists
defp do_get_range({{:., _, [List, :to_charlist]}, meta, [segments]}) do
start_pos = Keyword.take(meta, [:line, :column])

end_pos = get_end_pos_for_interpolation_segments(segments, meta[:delimiter] || "'", start_pos)

%{start: start_pos, end: end_pos}
end

# Qualified call
defp do_get_range({{:., _, [_left, right]}, _meta, []} = quoted) when is_atom(right) do
get_range_for_qualified_call_without_arguments(quoted)
Expand Down Expand Up @@ -356,7 +403,7 @@ defmodule Sourceror.Range do
else
{left, right_len} =
case call do
[left, right] -> {left, String.length(Atom.to_string(right))}
[left, right] -> {left, String.length(inspect(right)) - 1}
[left] -> {left, 0}
end

Expand Down Expand Up @@ -437,7 +484,15 @@ defmodule Sourceror.Range do

{:"::", _, [{_, meta, _}, {:binary, _, _}]}, _pos ->
meta
|> Keyword.get(:closing)
|> Keyword.fetch!(:closing)
|> Keyword.take([:line, :column])
# Add the closing }
|> Keyword.update!(:column, &(&1 + 1))

# interpolation in charlist
{{:., _, [Kernel, :to_string]}, meta, _}, _pos ->
meta
|> Keyword.fetch!(:closing)
|> Keyword.take([:line, :column])
# Add the closing }
|> Keyword.update!(:column, &(&1 + 1))
Expand All @@ -456,7 +511,9 @@ defmodule Sourceror.Range do
end

defp has_interpolations?(segments) do
Enum.any?(segments, &match?({:"::", _, _}, &1))
Enum.any?(segments, fn segment ->
match?({:"::", _, _}, segment) or match?({{:., _, [Kernel, :to_string]}, _, _}, segment)
end)
end

defp multiline_delimiter?(delimiter) do
Expand Down
200 changes: 200 additions & 0 deletions test/range_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,63 @@ defmodule SourcerorTest.RangeTest do
}
end

test "charlists" do
assert to_range(~S/'foo'/) == %{start: [line: 1, column: 1], end: [line: 1, column: 6]}
assert to_range(~S/'fo\no'/) == %{start: [line: 1, column: 1], end: [line: 1, column: 8]}

assert to_range(~S"""
'''
foo
bar
'''
""") == %{start: [line: 1, column: 1], end: [line: 5, column: 4]}

assert to_range(~S"""
'''
foo
bar
'''
""") == %{start: [line: 1, column: 3], end: [line: 4, column: 6]}
end

test "charlists with interpolations" do
assert to_range(~S/'foo#{2}bar'/) == %{
start: [line: 1, column: 1],
end: [line: 1, column: 13]
}

assert to_range(~S"""
'foo#{
2
}bar'
""") == %{
start: [line: 1, column: 1],
end: [line: 3, column: 8]
}

assert to_range(~S"""
'foo#{
2
}
bar'
""") == %{
start: [line: 1, column: 1],
end: [line: 4, column: 7]
}

assert to_range(~S"""
'foo#{
2
}
bar
'
""") == %{
start: [line: 1, column: 1],
end: [line: 5, column: 2]
}
end

test "atoms" do
assert to_range(~S/:foo/) == %{start: [line: 1, column: 1], end: [line: 1, column: 5]}
assert to_range(~S/:"foo"/) == %{start: [line: 1, column: 1], end: [line: 1, column: 7]}
Expand Down Expand Up @@ -256,6 +313,27 @@ defmodule SourcerorTest.RangeTest do
}
end

test "stab without args" do
{:fn, _, [stab]} = Sourceror.parse_string!(~S"fn -> :ok end")

assert Sourceror.Range.get_range(stab) == %{
start: [line: 1, column: 4],
end: [line: 1, column: 10]
}

{:fn, _, [stab]} =
Sourceror.parse_string!(~S"""
fn ->
:ok
end
""")

assert Sourceror.Range.get_range(stab) == %{
start: [line: 1, column: 4],
end: [line: 2, column: 6]
}
end

test "qualified tuples" do
assert to_range(~S/Foo.{Bar, Baz}/) == %{
start: [line: 1, column: 1],
Expand Down Expand Up @@ -371,6 +449,21 @@ defmodule SourcerorTest.RangeTest do
start: [line: 1, column: 1],
end: [line: 1, column: 26]
}

assert to_range(~S/foo."b-a-r"/) == %{
start: [line: 1, column: 1],
end: [line: 1, column: 12]
}

assert to_range(~S/foo."b-a-r"()/) == %{
start: [line: 1, column: 1],
end: [line: 1, column: 14]
}

assert to_range(~S/foo."b-a-r"(1)/) == %{
start: [line: 1, column: 1],
end: [line: 1, column: 15]
}
end

test "qualified calls without parens" do
Expand All @@ -383,6 +476,11 @@ defmodule SourcerorTest.RangeTest do
start: [line: 1, column: 1],
end: [line: 1, column: 17]
}

assert to_range(~S/foo."b-a-r" baz/) == %{
start: [line: 1, column: 1],
end: [line: 1, column: 16]
}
end

test "unqualified calls" do
Expand Down Expand Up @@ -557,5 +655,107 @@ defmodule SourcerorTest.RangeTest do
end: [line: 4, column: 7]
}
end

test "captures" do
assert to_range(~S"&foo/1") == %{
start: [line: 1, column: 1],
end: [line: 1, column: 7]
}

assert to_range(~S"&Foo.bar/1") == %{
start: [line: 1, column: 1],
end: [line: 1, column: 11]
}

assert to_range(~S"&__MODULE__.Foo.bar/1") == %{
start: [line: 1, column: 1],
end: [line: 1, column: 22]
}
end

test "captures with arguments" do
assert to_range(~S"&foo(&1, :bar)") == %{
start: [line: 1, column: 1],
end: [line: 1, column: 15]
}

assert to_range(~S"& &1.foo") == %{
start: [line: 1, column: 1],
end: [line: 1, column: 9]
}

assert to_range(~S"& &1") == %{
start: [line: 1, column: 1],
end: [line: 1, column: 5]
}

# This range currently ends on column 5, though it should be column 6,
# and appears to be a limitation of the parser, which does not include
# any metadata about the parens. That is, this currently holds:
#
# Sourceror.parse_string!("& &1") == Sourceror.parse_string!("&(&1)")
#
# assert to_range(~S"&(&1)") == %{
# start: [line: 1, column: 1],
# end: [line: 1, column: 6]
# }
end

test "arguments in captures" do
{:&, _, [{:&, _, _} = arg]} = Sourceror.parse_string!(~S"& &1")

assert Sourceror.Range.get_range(arg) == %{
start: [line: 1, column: 3],
end: [line: 1, column: 5]
}
end

test "Access syntax" do
assert to_range(~S"foo[bar]") == %{
start: [line: 1, column: 1],
end: [line: 1, column: 9]
}

{{:., _, [Access, :get]} = access, _, _} = Sourceror.parse_string!(~S"foo[bar]")

assert Sourceror.Range.get_range(access) == %{
start: [line: 1, column: 4],
end: [line: 1, column: 9]
}
end

test "should not raise on any three-element tuple parsed by parse_string" do
for relative_path <- Path.wildcard("lib/*/**.ex") do
assert_can_get_ranges(relative_path)
end
end

defp assert_can_get_ranges(relative_path) do
source = relative_path |> Path.relative_to_cwd() |> File.read!()
quoted = Sourceror.parse_string!(source)

Sourceror.prewalk(quoted, fn
{_, _, _} = quoted, acc ->
try do
Sourceror.get_range(quoted)
rescue
e ->
flunk("""
Expected a range from expression in #{relative_path}:
#{inspect(quoted)}
Got error:
#{Exception.format(:error, e)}
""")
end

{quoted, acc}

quoted, acc ->
{quoted, acc}
end)
end
end
end

0 comments on commit 2eba98f

Please sign in to comment.