Skip to content

Commit eb969ef

Browse files
feat: add custom commands for piping and unpiping text (#515)
* feat: add basic to_pipe command core * refactor: rework text range finding algorithm * feat: make to_pipe/1 work correctly * refactor: move core ast code to separate module * feat: add from_pipe core implementation * feat: working implementation of from_pipe * refactor: remove regex scan * chore: comply with code review * test: make tests pass in older versions * fix: make test use correct elixir syntax * delete: remove stray file * chore: apply suggestions from code review Co-authored-by: Łukasz Samson <lukaszsamson@gmail.com> * refactor: use binary matching recursion * fix: add missing brackets * feat: add checks for avoiding false positives * chore: update apps/language_server/test/providers/execute_command/manipulate_pipes_test.exs Co-authored-by: Łukasz Samson <lukaszsamson@gmail.com> * feat: make code work with all lineseps * feat: do not pipe operators * refactor: do not replace newlines in ast module * fix: off-by-one errors and show code works with 2-byte characters * feat: deal with utf16 graphemes * test: make tests pass * fix: use escaped char as test name suffix * chore: format code * test: \r raises syntax error Co-authored-by: Łukasz Samson <lukaszsamson@gmail.com>
1 parent dbe4f22 commit eb969ef

File tree

4 files changed

+1389
-0
lines changed

4 files changed

+1389
-0
lines changed
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
defmodule ElixirLS.LanguageServer.Providers.ExecuteCommand.ManipulatePipes do
2+
@moduledoc """
3+
This module implements a custom command for converting function calls
4+
to pipe operators and pipes to function calls.
5+
6+
Returns a formatted source fragment.
7+
"""
8+
import ElixirLS.LanguageServer.Protocol
9+
10+
alias ElixirLS.LanguageServer.{JsonRpc, Server}
11+
12+
alias __MODULE__.AST
13+
14+
@behaviour ElixirLS.LanguageServer.Providers.ExecuteCommand
15+
16+
@newlines ["\r\n", "\n", "\r"]
17+
18+
@impl ElixirLS.LanguageServer.Providers.ExecuteCommand
19+
def execute(
20+
%{"uri" => uri, "cursor_line" => line, "cursor_column" => col, "operation" => operation},
21+
state
22+
)
23+
when is_integer(line) and is_integer(col) and is_binary(uri) and
24+
operation in ["to_pipe", "from_pipe"] do
25+
# line and col are assumed to be 0-indexed
26+
source_file = Server.get_source_file(state, uri)
27+
28+
{:ok, %{edited_text: edited_text, edit_range: edit_range}} =
29+
case operation do
30+
"to_pipe" ->
31+
to_pipe_at_cursor(source_file.text, line, col)
32+
33+
"from_pipe" ->
34+
from_pipe_at_cursor(source_file.text, line, col)
35+
end
36+
37+
label =
38+
case operation do
39+
"to_pipe" -> "Convert function call to pipe operator"
40+
"from_pipe" -> "Convert pipe operator to function call"
41+
end
42+
43+
edit_result =
44+
JsonRpc.send_request("workspace/applyEdit", %{
45+
"label" => label,
46+
"edit" => %{
47+
"changes" => %{
48+
uri => [%{"range" => edit_range, "newText" => edited_text}]
49+
}
50+
}
51+
})
52+
53+
case edit_result do
54+
{:ok, %{"applied" => true}} ->
55+
{:ok, nil}
56+
57+
other ->
58+
{:error, :server_error,
59+
"cannot insert spec, workspace/applyEdit returned #{inspect(other)}"}
60+
end
61+
end
62+
63+
defp to_pipe_at_cursor(text, line, col) do
64+
result =
65+
ElixirSense.Core.Source.walk_text(
66+
text,
67+
%{walked_text: "", function_call: nil, range: nil},
68+
fn current_char, remaining_text, current_line, current_col, acc ->
69+
if current_line - 1 == line and current_col - 1 == col do
70+
{:ok, function_call, call_range} =
71+
get_function_call(line, col, acc.walked_text, current_char, remaining_text)
72+
73+
{remaining_text,
74+
%{
75+
acc
76+
| walked_text: acc.walked_text <> current_char,
77+
function_call: function_call,
78+
range: call_range
79+
}}
80+
else
81+
{remaining_text, %{acc | walked_text: acc.walked_text <> current_char}}
82+
end
83+
end
84+
)
85+
86+
case result do
87+
%{function_call: nil} ->
88+
{:error, :function_call_not_found}
89+
90+
%{function_call: function_call, range: range} ->
91+
piped_text = AST.to_pipe(function_call)
92+
93+
{:ok, %{edited_text: piped_text, edit_range: range}}
94+
end
95+
end
96+
97+
defp from_pipe_at_cursor(text, line, col) do
98+
result =
99+
ElixirSense.Core.Source.walk_text(
100+
text,
101+
%{walked_text: "", pipe_call: nil, range: nil},
102+
fn current_char, remaining_text, current_line, current_col, acc ->
103+
if current_line - 1 == line and current_col - 1 == col do
104+
{:ok, pipe_call, call_range} =
105+
get_pipe_call(line, col, acc.walked_text, current_char, remaining_text)
106+
107+
{remaining_text,
108+
%{
109+
acc
110+
| walked_text: acc.walked_text <> current_char,
111+
pipe_call: pipe_call,
112+
range: call_range
113+
}}
114+
else
115+
{remaining_text, %{acc | walked_text: acc.walked_text <> current_char}}
116+
end
117+
end
118+
)
119+
120+
case result do
121+
%{pipe_call: nil} ->
122+
{:error, :pipe_not_found}
123+
124+
%{pipe_call: pipe_call, range: range} ->
125+
unpiped_text = AST.from_pipe(pipe_call)
126+
127+
{:ok, %{edited_text: unpiped_text, edit_range: range}}
128+
end
129+
end
130+
131+
defp get_function_call(line, col, head, current, original_tail) do
132+
tail = do_get_function_call(original_tail, "(", ")")
133+
134+
{end_line, end_col} =
135+
if String.contains?(tail, @newlines) do
136+
tail_list = String.split(tail, @newlines)
137+
end_line = line + length(tail_list) - 1
138+
end_col = tail_list |> Enum.at(-1) |> String.length()
139+
{end_line, end_col}
140+
else
141+
{line, col + String.length(tail) + 1}
142+
end
143+
144+
text = head <> current <> tail
145+
146+
call = get_function_call_before(text)
147+
148+
{head, _tail} = String.split_at(call, -String.length(tail))
149+
150+
col = if head == "", do: col + 2, else: col - String.length(head) + 1
151+
152+
{:ok, call, range(line, col, end_line, end_col)}
153+
end
154+
155+
defp do_get_function_call(text, start_char, end_char) do
156+
text
157+
|> do_get_function_call(start_char, end_char, %{paren_count: 0, text: ""})
158+
|> Map.get(:text)
159+
|> IO.iodata_to_binary()
160+
end
161+
162+
defp do_get_function_call(<<c::binary-size(1), tail::bitstring>>, start_char, end_char, acc)
163+
when c == start_char do
164+
do_get_function_call(tail, start_char, end_char, %{
165+
acc
166+
| paren_count: acc.paren_count + 1,
167+
text: [acc.text | [c]]
168+
})
169+
end
170+
171+
defp do_get_function_call(<<c::binary-size(1), tail::bitstring>>, start_char, end_char, acc)
172+
when c == end_char do
173+
acc = %{acc | paren_count: acc.paren_count - 1, text: [acc.text | [c]]}
174+
175+
if acc.paren_count <= 0 do
176+
acc
177+
else
178+
do_get_function_call(tail, start_char, end_char, acc)
179+
end
180+
end
181+
182+
defp do_get_function_call(<<c::binary-size(1), tail::bitstring>>, start_char, end_char, acc) do
183+
do_get_function_call(tail, start_char, end_char, %{acc | text: [acc.text | [c]]})
184+
end
185+
186+
defp get_pipe_call(line, col, head, current, tail) do
187+
pipe_right = do_get_function_call(tail, "(", ")")
188+
189+
pipe_left =
190+
head
191+
|> String.reverse()
192+
|> :unicode.characters_to_binary(:utf8, :utf16)
193+
|> do_get_pipe_call()
194+
|> :unicode.characters_to_binary(:utf16, :utf8)
195+
196+
pipe_left =
197+
if String.contains?(pipe_left, ")") do
198+
get_function_call_before(head)
199+
else
200+
pipe_left
201+
end
202+
203+
pipe_left = String.trim_leading(pipe_left)
204+
205+
pipe_call = pipe_left <> current <> pipe_right
206+
207+
{line_offset, tail_length} =
208+
pipe_left
209+
|> String.reverse()
210+
|> count_newlines_and_get_tail()
211+
212+
start_line = line - line_offset
213+
214+
start_col =
215+
if line_offset != 0 do
216+
head
217+
|> String.trim_trailing(pipe_left)
218+
|> String.split(["\r\n", "\n", "\r"])
219+
|> Enum.at(-1, "")
220+
|> String.length()
221+
else
222+
col - tail_length
223+
end
224+
225+
{line_offset, tail_length} = (current <> pipe_right) |> count_newlines_and_get_tail()
226+
227+
end_line = line + line_offset
228+
229+
end_col =
230+
if line_offset != 0 do
231+
tail_length
232+
else
233+
col + tail_length
234+
end
235+
236+
{:ok, pipe_call, range(start_line, start_col, end_line, end_col)}
237+
end
238+
239+
# do_get_pipe_call(text :: utf16 binary, {utf16 binary, has_passed_through_whitespace, should_halt})
240+
defp do_get_pipe_call(text, acc \\ {"", false, false})
241+
242+
defp do_get_pipe_call(_text, {acc, _, true}), do: acc
243+
defp do_get_pipe_call("", {acc, _, _}), do: acc
244+
245+
defp do_get_pipe_call(<<?\r::utf16, ?\n::utf16, _::bitstring>>, {acc, true, _}),
246+
do: <<?\r::utf16, ?\n::utf16, acc::bitstring>>
247+
248+
defp do_get_pipe_call(<<0, c::utf8, _::bitstring>>, {acc, true, _})
249+
when c in [?\t, ?\v, ?\r, ?\n, ?\s],
250+
do: <<c::utf16, acc::bitstring>>
251+
252+
defp do_get_pipe_call(<<0, ?\r, 0, ?\n, text::bitstring>>, {acc, false, _}),
253+
do: do_get_pipe_call(text, {<<?\r::utf16, ?\n::utf16, acc::bitstring>>, false, false})
254+
255+
defp do_get_pipe_call(<<0, c::utf8, text::bitstring>>, {acc, false, _})
256+
when c in [?\t, ?\v, ?\n, ?\s],
257+
do: do_get_pipe_call(text, {<<c::utf16, acc::bitstring>>, false, false})
258+
259+
defp do_get_pipe_call(<<0, c::utf8, text::bitstring>>, {acc, _, _})
260+
when c in [?|, ?>],
261+
do: do_get_pipe_call(text, {<<c::utf16, acc::bitstring>>, false, false})
262+
263+
defp do_get_pipe_call(<<c::utf16, text::bitstring>>, {acc, _, _}),
264+
do: do_get_pipe_call(text, {<<c::utf16, acc::bitstring>>, true, false})
265+
266+
defp get_function_call_before(head) do
267+
call_without_function_name =
268+
head
269+
|> String.reverse()
270+
|> do_get_function_call(")", "(")
271+
|> String.reverse()
272+
273+
function_name =
274+
head
275+
|> String.trim_trailing(call_without_function_name)
276+
|> get_function_name_from_tail()
277+
278+
function_name <> call_without_function_name
279+
end
280+
281+
defp get_function_name_from_tail(s) do
282+
s
283+
|> String.reverse()
284+
|> String.graphemes()
285+
|> Enum.reduce_while([], fn c, acc ->
286+
if String.match?(c, ~r/\s/) do
287+
{:halt, acc}
288+
else
289+
{:cont, [c | acc]}
290+
end
291+
end)
292+
|> IO.iodata_to_binary()
293+
end
294+
295+
defp count_newlines_and_get_tail(s, acc \\ {0, 0})
296+
297+
defp count_newlines_and_get_tail("", acc), do: acc
298+
299+
defp count_newlines_and_get_tail(s, {line_count, tail_length}) do
300+
case String.next_grapheme(s) do
301+
{g, tail} when g in ["\r\n", "\r", "\n"] ->
302+
count_newlines_and_get_tail(tail, {line_count + 1, 0})
303+
304+
{_, tail} ->
305+
count_newlines_and_get_tail(tail, {line_count, tail_length + 1})
306+
end
307+
end
308+
end
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
defmodule ElixirLS.LanguageServer.Providers.ExecuteCommand.ManipulatePipes.AST do
2+
@moduledoc """
3+
AST manipulation helpers for the `ElixirLS.LanguageServer.Providers.ExecuteCommand.ManipulatePipes`\
4+
command.
5+
"""
6+
7+
@doc "Parses a string and converts the first function call, pre-order depth-first, into a pipe."
8+
def to_pipe(code_string) do
9+
{piped_ast, _} =
10+
code_string
11+
|> Code.string_to_quoted!()
12+
|> Macro.prewalk(%{has_piped: false}, &do_to_pipe/2)
13+
14+
Macro.to_string(piped_ast)
15+
end
16+
17+
@doc "Parses a string and converts the first pipe call, post-order depth-first, into a function call."
18+
def from_pipe(code_string) do
19+
{unpiped_ast, _} =
20+
code_string
21+
|> Code.string_to_quoted!()
22+
|> Macro.postwalk(%{has_unpiped: false}, fn
23+
{:|>, line, [h, {{:., line, [{_, _, nil}]} = anonymous_function_node, line, t}]},
24+
%{has_unpiped: false} = acc ->
25+
{{anonymous_function_node, line, [h | t]}, Map.put(acc, :has_unpiped, true)}
26+
27+
{:|>, line, [left, {function, _, args}]}, %{has_unpiped: false} = acc ->
28+
{{function, line, [left | args]}, Map.put(acc, :has_unpiped, true)}
29+
30+
node, acc ->
31+
{node, acc}
32+
end)
33+
34+
Macro.to_string(unpiped_ast)
35+
end
36+
37+
defp do_to_pipe({:|>, line, [left, right]}, %{has_piped: false} = acc) do
38+
{{:|>, line, [left |> do_to_pipe(acc) |> elem(0), right]}, Map.put(acc, :has_piped, true)}
39+
end
40+
41+
defp do_to_pipe(
42+
{{:., line, [{_, _, nil}]} = anonymous_function_node, _meta, [h | t]},
43+
%{has_piped: false} = acc
44+
) do
45+
{{:|>, line, [h, {anonymous_function_node, line, t}]}, Map.put(acc, :has_piped, true)}
46+
end
47+
48+
defp do_to_pipe({{:., line, _args} = function, _meta, args}, %{has_piped: false} = acc)
49+
when args != [] do
50+
{{:|>, line, [hd(args), {function, line, tl(args)}]}, Map.put(acc, :has_piped, true)}
51+
end
52+
53+
defp do_to_pipe({function, line, [h | t]} = node, %{has_piped: false} = acc)
54+
when is_atom(function) and function not in [:., :__aliases__, :"::", :{}, :|>] and t != [] do
55+
with :error <- Code.Identifier.binary_op(function),
56+
:error <- Code.Identifier.unary_op(function) do
57+
{{:|>, line, [h, {function, line, t}]}, Map.put(acc, :has_piped, true)}
58+
else
59+
_ ->
60+
{node, acc}
61+
end
62+
end
63+
64+
defp do_to_pipe(node, acc) do
65+
{node, acc}
66+
end
67+
end

0 commit comments

Comments
 (0)