Skip to content

Commit 0b668af

Browse files
authored
Make Code.Fragment functions work across newlines (#11980)
Closes #11877
1 parent 2c639a2 commit 0b668af

File tree

2 files changed

+291
-27
lines changed

2 files changed

+291
-27
lines changed

lib/elixir/lib/code/fragment.ex

Lines changed: 225 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -155,15 +155,15 @@ defmodule Code.Fragment do
155155

156156
def cursor_context(binary, opts) when is_binary(binary) and is_list(opts) do
157157
# CRLF not relevant here - we discard everything before last `\n`
158-
binary =
159-
case :binary.matches(binary, "\n") do
160-
[] ->
161-
binary
162-
163-
matches ->
164-
{position, _} = List.last(matches)
165-
binary_part(binary, position + 1, byte_size(binary) - position - 1)
166-
end
158+
binary = last_line(binary)
159+
# case :binary.matches(binary, "\n") do
160+
# [] ->
161+
# binary
162+
163+
# matches ->
164+
# {position, _} = List.last(matches)
165+
# binary_part(binary, position + 1, byte_size(binary) - position - 1)
166+
# end
167167

168168
binary
169169
|> String.to_charlist()
@@ -174,13 +174,14 @@ defmodule Code.Fragment do
174174

175175
def cursor_context(charlist, opts) when is_list(charlist) and is_list(opts) do
176176
# CRLF not relevant here - we discard everything before last `\n`
177-
charlist =
178-
case charlist |> Enum.chunk_by(&(&1 == ?\n)) |> List.last([]) do
179-
[?\n | _] -> []
180-
rest -> rest
181-
end
177+
# charlist =
178+
# case charlist |> Enum.chunk_by(&(&1 == ?\n)) |> List.last([]) do
179+
# [?\n | _] -> []
180+
# rest -> rest
181+
# end
182182

183183
charlist
184+
|> last_line
184185
|> :lists.reverse()
185186
|> codepoint_cursor_context(opts)
186187
|> elem(0)
@@ -593,21 +594,19 @@ defmodule Code.Fragment do
593594
| {:dot, inside_dot, charlist}
594595
def surround_context(fragment, position, options \\ [])
595596

596-
def surround_context(binary, {line, column}, opts) when is_binary(binary) do
597+
def surround_context(string, {line, column}, opts) when is_binary(string) or is_list(string) do
598+
{binary, {lines_before_reversed_lengths, cursor_line_length, lines_after_lengths}} =
599+
join_lines(string, line, column)
600+
601+
prepended_columns = Enum.sum(lines_before_reversed_lengths)
602+
597603
binary
598-
|> String.split(["\r\n", "\n"])
599-
|> Enum.at(line - 1, '')
600604
|> String.to_charlist()
601-
|> position_surround_context(line, column, opts)
602-
end
603-
604-
def surround_context(charlist, {line, column}, opts) when is_list(charlist) do
605-
charlist
606-
|> :string.replace('\r\n', '\n', :all)
607-
|> :string.join('')
608-
|> :string.split('\n', :all)
609-
|> Enum.at(line - 1, '')
610-
|> position_surround_context(line, column, opts)
605+
|> position_surround_context(line, column + prepended_columns, opts)
606+
|> to_multiline_range(
607+
prepended_columns,
608+
{lines_before_reversed_lengths, cursor_line_length, lines_after_lengths}
609+
)
611610
end
612611

613612
def surround_context(other, {_, _} = position, opts) do
@@ -811,6 +810,205 @@ defmodule Code.Fragment do
811810
defp enum_reverse_at([h | t], n, acc) when n > 0, do: enum_reverse_at(t, n - 1, [h | acc])
812811
defp enum_reverse_at(rest, _, acc), do: {acc, rest}
813812

813+
@comment_strip ~r/(?<!\<)\#(?!\{).*$/
814+
815+
defp last_line(binary) when is_binary(binary) do
816+
[last_line | lines_reverse] =
817+
binary
818+
|> String.split(["\r\n", "\n"])
819+
|> Enum.reverse()
820+
821+
lines_reverse =
822+
lines_reverse
823+
|> Enum.map(&Regex.replace(@comment_strip, &1, ""))
824+
825+
last_line = last_line
826+
827+
prepend_lines_before(last_line, lines_reverse)
828+
|> IO.chardata_to_string()
829+
end
830+
831+
defp last_line(charlist) when is_list(charlist) do
832+
[last_line | lines_reverse] =
833+
charlist
834+
|> :string.replace('\r\n', '\n', :all)
835+
|> :string.join('')
836+
|> :string.split('\n', :all)
837+
|> Enum.reverse()
838+
839+
lines_reverse =
840+
lines_reverse
841+
|> Enum.map(&Regex.replace(@comment_strip, List.to_string(&1), ""))
842+
843+
last_line = last_line |> List.to_string()
844+
845+
prepend_lines_before(last_line, lines_reverse)
846+
|> IO.chardata_to_string()
847+
|> String.to_charlist()
848+
end
849+
850+
defp prepend_lines_before(cursor_line, lines_before_reverse) do
851+
lines_before_reverse
852+
|> Enum.reduce_while([cursor_line], fn line, [head | _] = acc ->
853+
line_trimmed = String.trim(line)
854+
acc_trimmed = String.trim_leading(head)
855+
856+
if String.starts_with?(acc_trimmed, ".") or line_trimmed == "" or
857+
String.ends_with?(line_trimmed, ".") or String.ends_with?(line_trimmed, "(") do
858+
{:cont, [line | acc]}
859+
else
860+
{:halt, acc}
861+
end
862+
end)
863+
end
864+
865+
defp append_lines_after(cursor_line, lines_after) do
866+
lines_after
867+
|> Enum.reduce_while([cursor_line], fn line, [head | _] = acc ->
868+
line_trimmed = String.trim_leading(line)
869+
acc_trimmed = String.trim(head)
870+
871+
if String.starts_with?(line_trimmed, ".") or acc_trimmed == "" or
872+
String.ends_with?(acc_trimmed, ".") or String.ends_with?(acc_trimmed, "(") do
873+
{:cont, [line | acc]}
874+
else
875+
{:halt, acc}
876+
end
877+
end)
878+
|> Enum.reverse()
879+
end
880+
881+
def join_lines(binary, line, column) when is_binary(binary) do
882+
lines =
883+
binary
884+
|> String.split(["\r\n", "\n"])
885+
886+
lines_before_reverse =
887+
if line > 1 do
888+
lines
889+
|> Enum.slice(0..(line - 2))
890+
|> Enum.reverse()
891+
|> Enum.map(&Regex.replace(@comment_strip, &1, ""))
892+
else
893+
[]
894+
end
895+
896+
lines_after =
897+
lines
898+
|> Enum.slice(line..-1)
899+
|> Enum.map(&Regex.replace(@comment_strip, &1, ""))
900+
901+
cursor_line =
902+
lines
903+
|> Enum.at(line - 1)
904+
905+
cursor_line_stripped = Regex.replace(@comment_strip, cursor_line, "")
906+
907+
cursor_line_stripped =
908+
if column - 1 > String.length(cursor_line_stripped) do
909+
cursor_line
910+
else
911+
cursor_line_stripped
912+
end
913+
914+
added_before_lines = prepend_lines_before(cursor_line_stripped, lines_before_reverse)
915+
[_ | added_after_lines] = append_lines_after(cursor_line_stripped, lines_after)
916+
917+
built =
918+
[added_before_lines, added_after_lines]
919+
|> IO.chardata_to_string()
920+
921+
{built,
922+
{Enum.map(lines_before_reverse, &String.length/1), String.length(cursor_line_stripped),
923+
Enum.map(lines_after, &String.length/1)}}
924+
end
925+
926+
def join_lines(charlist, line, column) when is_list(charlist) do
927+
lines =
928+
charlist
929+
|> :string.replace('\r\n', '\n', :all)
930+
|> :string.join('')
931+
|> :string.split('\n', :all)
932+
933+
lines_before_reverse =
934+
if line > 1 do
935+
lines
936+
|> Enum.slice(0..(line - 2))
937+
|> Enum.reverse()
938+
|> Enum.map(&Regex.replace(@comment_strip, List.to_string(&1), ""))
939+
else
940+
[]
941+
end
942+
943+
lines_after =
944+
lines
945+
|> Enum.slice(line..-1)
946+
|> Enum.map(&Regex.replace(@comment_strip, List.to_string(&1), ""))
947+
948+
cursor_line =
949+
lines
950+
|> Enum.at(line - 1)
951+
|> List.to_string()
952+
953+
cursor_line_stripped = Regex.replace(@comment_strip, cursor_line, "")
954+
955+
cursor_line_stripped =
956+
if column - 1 > String.length(cursor_line_stripped) do
957+
# no comment strip if cursor is inside a comment
958+
# we want to provide results inside comment
959+
cursor_line
960+
else
961+
cursor_line_stripped
962+
end
963+
964+
added_before_lines = prepend_lines_before(cursor_line_stripped, lines_before_reverse)
965+
[_ | added_after_lines] = append_lines_after(cursor_line_stripped, lines_after)
966+
967+
built =
968+
[added_before_lines, added_after_lines]
969+
|> IO.chardata_to_string()
970+
971+
{built,
972+
{Enum.map(lines_before_reverse, &String.length/1), String.length(cursor_line_stripped),
973+
Enum.map(lines_after, &String.length/1)}}
974+
end
975+
976+
defp to_multiline_range(:none, _, _), do: :none
977+
978+
defp to_multiline_range(
979+
%{begin: {begin_line, begin_column}, end: {end_line, end_column}} = context,
980+
prepended,
981+
{lines_before_reversed_lengths, cursor_line_length, lines_after_lengths}
982+
) do
983+
{begin_line, begin_column} =
984+
lines_before_reversed_lengths
985+
|> Enum.reduce_while({begin_line, begin_column - prepended}, fn line_length,
986+
{acc_line, acc_column} ->
987+
if acc_column < 1 do
988+
{:cont, {acc_line - 1, acc_column + line_length}}
989+
else
990+
{:halt, {acc_line, acc_column}}
991+
end
992+
end)
993+
994+
{end_line, end_column} =
995+
[cursor_line_length | lines_after_lengths]
996+
|> Enum.reduce_while({end_line, end_column - prepended}, fn line_length,
997+
{acc_line, acc_column} ->
998+
if acc_column > line_length + 1 do
999+
{:cont, {acc_line + 1, acc_column - line_length}}
1000+
else
1001+
{:halt, {acc_line, acc_column}}
1002+
end
1003+
end)
1004+
1005+
context
1006+
|> Map.merge(%{
1007+
begin: {begin_line, begin_column},
1008+
end: {end_line, end_column}
1009+
})
1010+
end
1011+
8141012
@doc """
8151013
Receives a string and returns a quoted expression
8161014
with a cursor at the nearest argument position.

lib/elixir/test/elixir/code_fragment_test.exs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,40 @@ defmodule CodeFragmentTest do
5858
assert CF.cursor_context(":hell@@o.wor") == {:dot, {:unquoted_atom, 'hell@@o'}, 'wor'}
5959
assert CF.cursor_context("@hello.wor") == {:dot, {:module_attribute, 'hello'}, 'wor'}
6060

61+
assert CF.cursor_context("@hello. wor") == {:dot, {:module_attribute, 'hello'}, 'wor'}
62+
assert CF.cursor_context("@hello .wor") == {:dot, {:module_attribute, 'hello'}, 'wor'}
63+
assert CF.cursor_context("@hello . wor") == {:dot, {:module_attribute, 'hello'}, 'wor'}
64+
65+
assert CF.cursor_context("@hello.\nwor") == {:dot, {:module_attribute, 'hello'}, 'wor'}
66+
assert CF.cursor_context("@hello. \nwor") == {:dot, {:module_attribute, 'hello'}, 'wor'}
67+
assert CF.cursor_context("@hello.\n wor") == {:dot, {:module_attribute, 'hello'}, 'wor'}
68+
assert CF.cursor_context("@hello.\r\nwor") == {:dot, {:module_attribute, 'hello'}, 'wor'}
69+
assert CF.cursor_context("@hello\n.wor") == {:dot, {:module_attribute, 'hello'}, 'wor'}
70+
assert CF.cursor_context("@hello \n.wor") == {:dot, {:module_attribute, 'hello'}, 'wor'}
71+
assert CF.cursor_context("@hello\n .wor") == {:dot, {:module_attribute, 'hello'}, 'wor'}
72+
73+
assert CF.cursor_context("@hello. # some comment\nwor") ==
74+
{:dot, {:module_attribute, 'hello'}, 'wor'}
75+
76+
assert CF.cursor_context("@hello. # some comment\n\nwor") ==
77+
{:dot, {:module_attribute, 'hello'}, 'wor'}
78+
79+
assert CF.cursor_context("@hello. # some comment\nsub\n.wor") ==
80+
{:dot, {:dot, {:module_attribute, 'hello'}, 'sub'}, 'wor'}
81+
82+
assert CF.cursor_context('@hello.\nwor') == {:dot, {:module_attribute, 'hello'}, 'wor'}
83+
assert CF.cursor_context('@hello.\r\nwor') == {:dot, {:module_attribute, 'hello'}, 'wor'}
84+
assert CF.cursor_context('@hello\n.wor') == {:dot, {:module_attribute, 'hello'}, 'wor'}
85+
86+
assert CF.cursor_context('@hello. # some comment\nwor') ==
87+
{:dot, {:module_attribute, 'hello'}, 'wor'}
88+
89+
assert CF.cursor_context('@hello. # some comment\n\nwor') ==
90+
{:dot, {:module_attribute, 'hello'}, 'wor'}
91+
92+
assert CF.cursor_context('@hello. # some comment\nsub\n.wor') ==
93+
{:dot, {:dot, {:module_attribute, 'hello'}, 'sub'}, 'wor'}
94+
6195
assert CF.cursor_context("nested.map.wor") ==
6296
{:dot, {:dot, {:var, 'nested'}, 'map'}, 'wor'}
6397

@@ -80,6 +114,8 @@ defmodule CodeFragmentTest do
80114
assert CF.cursor_context("hello(") == {:local_call, 'hello'}
81115
assert CF.cursor_context("hello(\s") == {:local_call, 'hello'}
82116
assert CF.cursor_context("hello(\t") == {:local_call, 'hello'}
117+
assert CF.cursor_context("hello(\n") == {:local_call, 'hello'}
118+
assert CF.cursor_context("hello(\r\n") == {:local_call, 'hello'}
83119
end
84120

85121
test "dot_arity" do
@@ -114,6 +150,8 @@ defmodule CodeFragmentTest do
114150
assert CF.cursor_context("foo.hello(") == {:dot_call, {:var, 'foo'}, 'hello'}
115151
assert CF.cursor_context("foo.hello(\s") == {:dot_call, {:var, 'foo'}, 'hello'}
116152
assert CF.cursor_context("foo.hello(\t") == {:dot_call, {:var, 'foo'}, 'hello'}
153+
assert CF.cursor_context("foo.hello(\n") == {:dot_call, {:var, 'foo'}, 'hello'}
154+
assert CF.cursor_context("foo.hello(\r\n") == {:dot_call, {:var, 'foo'}, 'hello'}
117155

118156
assert CF.cursor_context("@f.hello\s") == {:dot_call, {:module_attribute, 'f'}, 'hello'}
119157
assert CF.cursor_context("@f.hello\t") == {:dot_call, {:module_attribute, 'f'}, 'hello'}
@@ -140,6 +178,8 @@ defmodule CodeFragmentTest do
140178
test "alias" do
141179
assert CF.cursor_context("HelloWor") == {:alias, 'HelloWor'}
142180
assert CF.cursor_context("Hello.Wor") == {:alias, 'Hello.Wor'}
181+
assert CF.cursor_context("Hello.\nWor") == {:alias, 'Hello.Wor'}
182+
assert CF.cursor_context("Hello.\r\nWor") == {:alias, 'Hello.Wor'}
143183
assert CF.cursor_context("Hello . Wor") == {:alias, 'Hello.Wor'}
144184
assert CF.cursor_context("Hello::Wor") == {:alias, 'Wor'}
145185
assert CF.cursor_context("Hello..Wor") == {:alias, 'Wor'}
@@ -544,6 +584,24 @@ defmodule CodeFragmentTest do
544584
end: {1, 10}
545585
}
546586
end
587+
588+
assert CF.surround_context("hello # comment\n .wor", {2, 4}) == %{
589+
context: {:dot, {:var, 'hello'}, 'wor'},
590+
begin: {1, 1},
591+
end: {2, 7}
592+
}
593+
594+
assert CF.surround_context("hello. # comment\n\n wor", {3, 4}) == %{
595+
context: {:dot, {:var, 'hello'}, 'wor'},
596+
begin: {1, 1},
597+
end: {3, 6}
598+
}
599+
600+
assert CF.surround_context("hello. # comment\n\n # wor", {3, 5}) == %{
601+
context: {:local_or_var, 'wor'},
602+
begin: {3, 4},
603+
end: {3, 7}
604+
}
547605
end
548606

549607
test "alias" do
@@ -594,6 +652,14 @@ defmodule CodeFragmentTest do
594652
end: {1, 16}
595653
}
596654
end
655+
656+
for i <- 1..3 do
657+
assert CF.surround_context("Foo # dc\n. Bar .\n Baz", {i, 1}) == %{
658+
context: {:alias, 'Foo.Bar.Baz'},
659+
begin: {1, 1},
660+
end: {3, 5}
661+
}
662+
end
597663
end
598664

599665
test "underscored special forms" do

0 commit comments

Comments
 (0)