Skip to content

Commit f77999b

Browse files
polvalenteaxelson
andauthored
fix: expose manipulate pipes command (#521)
* fix: expose manipulate pipes command * test: add test for known bug * fix: make test retrocompatible * feat: treat escape chars after macro to_string * docs: add comment for treatment * feat: import code from Elixir source code to deal with sigils * fix: use ast_to_string instead of Macro.to_string * chore: remove redundant test * fix: various fixes due to new tests * test: make test retrocompatible * fix: do not raise for any position in the file * docs: add todo comment * fix: also pipe 1-arg functions * test: fix test assertion * Proposed changes Make `to_pipe_at_cursor/3` public to facilitate testing. I think we should still keep some integration-level testing, but I think the majority of the test cases can be simplified. I didn't implement making `from_pipe_at_cursor/3` public, but I think it would make sense as well. Introduce `ElixirLS.LanguageServer.Protocol.TextEdit` and return it from `to_pipe_at_cursor/3` and `from_pipe_at_cursor/3` Add an example of a working simpler test, and add a few examples of broken tests (as mentioned in #521 (review)) * chore: unbreak tests * fix: make tests pass * test: add broken test * Handle more cases for manipulating pipes Changed the function call detection to not "jump forward", as part of this if the user requests an index that is not on a function call, then no action is performed. Also fix handling of piping function calls that span multiple lines by adjusting the returned line and column to point to the correct start location by counting backward. * chore: add suggestions from code review * chore: format code Co-authored-by: Jason Axelson <jason.axelson@gmail.com>
1 parent 206240a commit f77999b

File tree

7 files changed

+858
-134
lines changed

7 files changed

+858
-134
lines changed
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
defmodule ElixirLS.LanguageServer.Protocol.TextEdit do
2+
@moduledoc """
3+
Corresponds to the LSP interface of the same name.
4+
5+
For details see https://microsoft.github.io/language-server-protocol/specification#textEdit
6+
"""
7+
@derive JasonVendored.Encoder
8+
defstruct [:range, :newText]
9+
end

apps/language_server/lib/language_server/providers/execute_command/manipulate_pipes.ex

Lines changed: 160 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ defmodule ElixirLS.LanguageServer.Providers.ExecuteCommand.ManipulatePipes do
88
import ElixirLS.LanguageServer.Protocol
99

1010
alias ElixirLS.LanguageServer.{JsonRpc, Server}
11+
alias ElixirLS.LanguageServer.SourceFile
12+
alias ElixirLS.LanguageServer.Protocol.TextEdit
1113

1214
alias __MODULE__.AST
1315

@@ -22,7 +24,13 @@ defmodule ElixirLS.LanguageServer.Providers.ExecuteCommand.ManipulatePipes do
2224
# line and col are assumed to be 0-indexed
2325
source_file = Server.get_source_file(state, uri)
2426

25-
{:ok, %{edited_text: edited_text, edit_range: edit_range}} =
27+
label =
28+
case operation do
29+
"toPipe" -> "Convert function call to pipe operator"
30+
"fromPipe" -> "Convert pipe operator to function call"
31+
end
32+
33+
processing_result =
2634
case operation do
2735
"toPipe" ->
2836
to_pipe_at_cursor(source_file.text, line, col)
@@ -31,33 +39,29 @@ defmodule ElixirLS.LanguageServer.Providers.ExecuteCommand.ManipulatePipes do
3139
from_pipe_at_cursor(source_file.text, line, col)
3240
end
3341

34-
label =
35-
case operation do
36-
"toPipe" -> "Convert function call to pipe operator"
37-
"fromPipe" -> "Convert pipe operator to function call"
38-
end
42+
with {:ok, %TextEdit{} = text_edit} <- processing_result,
43+
{:ok, %{"applied" => true}} <-
44+
JsonRpc.send_request("workspace/applyEdit", %{
45+
"label" => label,
46+
"edit" => %{
47+
"changes" => %{
48+
uri => [text_edit]
49+
}
50+
}
51+
}) do
52+
{:ok, nil}
53+
else
54+
{:error, reason} ->
55+
{:error, reason}
3956

40-
edit_result =
41-
JsonRpc.send_request("workspace/applyEdit", %{
42-
"label" => label,
43-
"edit" => %{
44-
"changes" => %{
45-
uri => [%{"range" => edit_range, "newText" => edited_text}]
46-
}
47-
}
48-
})
49-
50-
case edit_result do
51-
{:ok, %{"applied" => true}} ->
52-
{:ok, nil}
53-
54-
other ->
57+
error ->
5558
{:error, :server_error,
56-
"cannot insert spec, workspace/applyEdit returned #{inspect(other)}"}
59+
"cannot execute pipe conversion, workspace/applyEdit returned #{inspect(error)}"}
5760
end
5861
end
5962

60-
defp to_pipe_at_cursor(text, line, col) do
63+
@doc false
64+
def to_pipe_at_cursor(text, line, col) do
6165
result =
6266
ElixirSense.Core.Source.walk_text(
6367
text,
@@ -67,27 +71,40 @@ defmodule ElixirLS.LanguageServer.Providers.ExecuteCommand.ManipulatePipes do
6771
{:ok, function_call, call_range} =
6872
get_function_call(line, col, acc.walked_text, current_char, remaining_text)
6973

70-
{remaining_text,
71-
%{
72-
acc
73-
| walked_text: acc.walked_text <> current_char,
74-
function_call: function_call,
75-
range: call_range
76-
}}
74+
if function_call_includes_cursor(call_range, line, col) do
75+
{remaining_text,
76+
%{
77+
acc
78+
| walked_text: acc.walked_text <> current_char,
79+
function_call: function_call,
80+
range: call_range
81+
}}
82+
else
83+
# The cursor was not inside a function call so we cannot
84+
# manipulate the pipes
85+
{remaining_text,
86+
%{
87+
acc
88+
| walked_text: acc.walked_text <> current_char
89+
}}
90+
end
7791
else
7892
{remaining_text, %{acc | walked_text: acc.walked_text <> current_char}}
7993
end
8094
end
8195
)
8296

83-
case result do
84-
%{function_call: nil} ->
97+
with {:result, %{function_call: function_call, range: range}}
98+
when not is_nil(function_call) and not is_nil(range) <- {:result, result},
99+
{:ok, piped_text} <- AST.to_pipe(function_call) do
100+
text_edit = %TextEdit{newText: piped_text, range: range}
101+
{:ok, text_edit}
102+
else
103+
{:result, %{function_call: nil}} ->
85104
{:error, :function_call_not_found}
86105

87-
%{function_call: function_call, range: range} ->
88-
piped_text = AST.to_pipe(function_call)
89-
90-
{:ok, %{edited_text: piped_text, edit_range: range}}
106+
{:error, :invalid_code} ->
107+
{:error, :invalid_code}
91108
end
92109
end
93110

@@ -98,33 +115,53 @@ defmodule ElixirLS.LanguageServer.Providers.ExecuteCommand.ManipulatePipes do
98115
%{walked_text: "", pipe_call: nil, range: nil},
99116
fn current_char, remaining_text, current_line, current_col, acc ->
100117
if current_line - 1 == line and current_col - 1 == col do
101-
{:ok, pipe_call, call_range} =
102-
get_pipe_call(line, col, acc.walked_text, current_char, remaining_text)
103-
104-
{remaining_text,
105-
%{
106-
acc
107-
| walked_text: acc.walked_text <> current_char,
108-
pipe_call: pipe_call,
109-
range: call_range
110-
}}
118+
case get_pipe_call(line, col, acc.walked_text, current_char, remaining_text) do
119+
{:ok, pipe_call, call_range} ->
120+
{remaining_text,
121+
%{
122+
acc
123+
| walked_text: acc.walked_text <> current_char,
124+
pipe_call: pipe_call,
125+
range: call_range
126+
}}
127+
128+
{:error, :no_pipe_at_selection} ->
129+
{remaining_text,
130+
%{
131+
acc
132+
| walked_text: acc.walked_text <> current_char
133+
}}
134+
end
111135
else
112136
{remaining_text, %{acc | walked_text: acc.walked_text <> current_char}}
113137
end
114138
end
115139
)
116140

117-
case result do
118-
%{pipe_call: nil} ->
141+
with {:result, %{pipe_call: pipe_call, range: range}}
142+
when not is_nil(pipe_call) and not is_nil(range) <- {:result, result},
143+
{:ok, unpiped_text} <- AST.from_pipe(pipe_call) do
144+
text_edit = %TextEdit{newText: unpiped_text, range: range}
145+
{:ok, text_edit}
146+
else
147+
{:result, %{pipe_call: nil}} ->
119148
{:error, :pipe_not_found}
120149

121-
%{pipe_call: pipe_call, range: range} ->
122-
unpiped_text = AST.from_pipe(pipe_call)
123-
124-
{:ok, %{edited_text: unpiped_text, edit_range: range}}
150+
{:error, :invalid_code} ->
151+
{:error, :invalid_code}
125152
end
126153
end
127154

155+
defp get_function_call(line, col, head, cur, original_tail) when cur in ["\n", "\r", "\r\n"] do
156+
{head, new_cur} = String.split_at(head, -1)
157+
get_function_call(line, col - 1, head, new_cur, cur <> original_tail)
158+
end
159+
160+
defp get_function_call(line, col, head, ")", original_tail) do
161+
{head, cur} = String.split_at(head, -1)
162+
get_function_call(line, col - 1, head, cur, ")" <> original_tail)
163+
end
164+
128165
defp get_function_call(line, col, head, current, original_tail) do
129166
tail = do_get_function_call(original_tail, "(", ")")
130167

@@ -141,10 +178,15 @@ defmodule ElixirLS.LanguageServer.Providers.ExecuteCommand.ManipulatePipes do
141178
text = head <> current <> tail
142179

143180
call = get_function_call_before(text)
181+
orig_head = head
144182

145-
{head, _tail} = String.split_at(call, -String.length(tail))
183+
{head, _new_tail} =
184+
case String.length(tail) do
185+
0 -> {call, ""}
186+
length -> String.split_at(call, -length)
187+
end
146188

147-
col = if head == "", do: col + 2, else: col - String.length(head) + 1
189+
{line, col} = fix_start_of_range(orig_head, head, line, col)
148190

149191
{:ok, call, range(line, col, end_line, end_col)}
150192
end
@@ -180,6 +222,8 @@ defmodule ElixirLS.LanguageServer.Providers.ExecuteCommand.ManipulatePipes do
180222
do_get_function_call(tail, start_char, end_char, %{acc | text: [acc.text | [c]]})
181223
end
182224

225+
defp do_get_function_call(_, _, _, acc), do: acc
226+
183227
defp get_pipe_call(line, col, head, current, tail) do
184228
pipe_right = do_get_function_call(tail, "(", ")")
185229

@@ -230,7 +274,11 @@ defmodule ElixirLS.LanguageServer.Providers.ExecuteCommand.ManipulatePipes do
230274
col + tail_length
231275
end
232276

233-
{:ok, pipe_call, range(start_line, start_col, end_line, end_col)}
277+
if String.contains?(pipe_call, "|>") do
278+
{:ok, pipe_call, range(start_line, start_col, end_line, end_col)}
279+
else
280+
{:error, :no_pipe_at_selection}
281+
end
234282
end
235283

236284
# do_get_pipe_call(text :: utf16 binary, {utf16 binary, has_passed_through_whitespace, should_halt})
@@ -267,20 +315,24 @@ defmodule ElixirLS.LanguageServer.Providers.ExecuteCommand.ManipulatePipes do
267315
|> do_get_function_call(")", "(")
268316
|> String.reverse()
269317

270-
function_name =
318+
if call_without_function_name == "" do
271319
head
272-
|> String.trim_trailing(call_without_function_name)
273-
|> get_function_name_from_tail()
320+
else
321+
function_name =
322+
head
323+
|> String.trim_trailing(call_without_function_name)
324+
|> get_function_name_from_tail()
274325

275-
function_name <> call_without_function_name
326+
function_name <> call_without_function_name
327+
end
276328
end
277329

278330
defp get_function_name_from_tail(s) do
279331
s
280332
|> String.reverse()
281333
|> String.graphemes()
282334
|> Enum.reduce_while([], fn c, acc ->
283-
if String.match?(c, ~r/\s/) do
335+
if String.match?(c, ~r/[\s\(\[\{]/) do
284336
{:halt, acc}
285337
else
286338
{:cont, [c | acc]}
@@ -302,4 +354,52 @@ defmodule ElixirLS.LanguageServer.Providers.ExecuteCommand.ManipulatePipes do
302354
count_newlines_and_get_tail(tail, {line_count, tail_length + 1})
303355
end
304356
end
357+
358+
# Fixes the line and column returned, finding the correct position on previous lines
359+
defp fix_start_of_range(orig_head, head, line, col)
360+
defp fix_start_of_range(_, "", line, col), do: {line, col + 2}
361+
362+
defp fix_start_of_range(orig_head, head, line, col) do
363+
new_col = col - String.length(head) + 1
364+
365+
if new_col < 0 do
366+
lines =
367+
SourceFile.lines(orig_head)
368+
|> Enum.take(line)
369+
|> Enum.reverse()
370+
371+
# Go back through previous lines to find the correctly adjusted line and
372+
# column number for the start of head (where the function starts)
373+
Enum.reduce_while(lines, {line, new_col}, fn
374+
_line_text, {cur_line, cur_col} when cur_col >= 0 ->
375+
{:halt, {cur_line, cur_col}}
376+
377+
line_text, {cur_line, cur_col} ->
378+
# The +1 is for the line separator
379+
{:cont, {cur_line - 1, cur_col + String.length(line_text) + 1}}
380+
end)
381+
else
382+
{line, new_col}
383+
end
384+
end
385+
386+
defp function_call_includes_cursor(call_range, line, char) do
387+
range(start_line, start_character, end_line, end_character) = call_range
388+
389+
starts_before =
390+
cond do
391+
start_line < line -> true
392+
start_line == line and start_character <= char -> true
393+
true -> false
394+
end
395+
396+
ends_after =
397+
cond do
398+
end_line > line -> true
399+
end_line == line and end_character >= char -> true
400+
true -> false
401+
end
402+
403+
starts_before and ends_after
404+
end
305405
end

0 commit comments

Comments
 (0)