Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit edb4308

Browse files
committedMay 3, 2023
Add extract_function/4 based on AST modification with Sourceror.Zipper
1 parent 4895dc1 commit edb4308

File tree

2 files changed

+424
-0
lines changed

2 files changed

+424
-0
lines changed
 
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
defmodule ElixirLS.LanguageServer.Experimental.CodeMod.ExtractFunction do
2+
@moduledoc """
3+
Elixir refactoring functions.
4+
"""
5+
6+
alias Sourceror.Zipper, as: Z
7+
8+
@doc """
9+
Return zipper containing AST with extracted function.
10+
"""
11+
def extract_function(zipper, start_line, end_line, function_name) do
12+
{quoted, acc} = extract_lines(zipper, start_line, end_line, function_name)
13+
zipper = Z.zip(quoted)
14+
15+
declares = vars_declared(function_name, [], acc.lines) |> Enum.uniq()
16+
used = vars_used(function_name, [], acc.lines) |> Enum.uniq()
17+
args = Enum.map(used -- declares, fn var -> {var, [], nil} end)
18+
returns = declares |> Enum.filter(&(&1 in acc.vars))
19+
{zipper, extracted} = return_declared(zipper, returns, function_name, args, acc.lines)
20+
21+
enclosing = acc.def
22+
23+
zipper
24+
|> top_find(fn
25+
{:def, _meta, [{^enclosing, _, _}, _]} -> true
26+
_ -> false
27+
end)
28+
|> Z.insert_right(extracted)
29+
|> fix_block()
30+
|> Z.root()
31+
end
32+
33+
@doc """
34+
Return zipper containing AST for lines in the range from-to.
35+
"""
36+
def extract_lines(zipper, start_line, end_line, replace_with \\ nil) do
37+
remove_range(zipper, start_line, end_line, %{
38+
lines: [],
39+
def: nil,
40+
def_end: nil,
41+
vars: [],
42+
replace_with: replace_with
43+
})
44+
end
45+
46+
defp next_remove_range(zipper, from, to, acc) do
47+
if next = Z.next(zipper) do
48+
remove_range(next, from, to, acc)
49+
else
50+
# return zipper with lines removed
51+
{
52+
elem(Z.top(zipper), 0),
53+
%{acc | lines: Enum.reverse(acc.lines), vars: Enum.reverse(acc.vars)}
54+
}
55+
end
56+
end
57+
58+
defp remove_range({{:def, meta, [{marker, _, _}, _]}, _list} = zipper, from, to, acc) do
59+
acc =
60+
if meta[:line] < from do
61+
x = put_in(acc.def, marker)
62+
put_in(x.def_end, meta[:end][:line])
63+
else
64+
acc
65+
end
66+
67+
next_remove_range(zipper, from, to, acc)
68+
end
69+
70+
defp remove_range({{marker, meta, children}, _list} = zipper, from, to, acc) do
71+
if meta[:line] < from || meta[:line] > to || marker == :__block__ do
72+
next_remove_range(
73+
zipper,
74+
from,
75+
to,
76+
if meta[:line] > to && meta[:line] < acc.def_end && is_atom(marker) && is_nil(children) do
77+
put_in(acc.vars, [marker | acc.vars] |> Enum.uniq())
78+
else
79+
acc
80+
end
81+
)
82+
else
83+
acc = put_in(acc.lines, [Z.node(zipper) | acc.lines])
84+
85+
if is_nil(acc.replace_with) do
86+
zipper
87+
|> Z.remove()
88+
|> next_remove_range(from, to, acc)
89+
else
90+
function_name = acc.replace_with
91+
acc = put_in(acc.replace_with, nil)
92+
93+
zipper
94+
|> Z.replace({function_name, [], []})
95+
|> next_remove_range(from, to, acc)
96+
end
97+
end
98+
end
99+
100+
defp remove_range(zipper, from, to, acc) do
101+
next_remove_range(zipper, from, to, acc)
102+
end
103+
104+
defp vars_declared(function_name, args, lines) do
105+
function_name
106+
|> new_function(args, lines)
107+
|> Z.zip()
108+
|> vars_declared(%{vars: []})
109+
end
110+
111+
defp vars_declared(nil, acc) do
112+
Enum.reverse(acc.vars)
113+
end
114+
115+
defp vars_declared({{:=, _, [{var, _, nil}, _]}, _rest} = zipper, acc) when is_atom(var) do
116+
zipper
117+
|> Z.next()
118+
|> vars_declared(put_in(acc.vars, [var | acc.vars]))
119+
end
120+
121+
defp vars_declared(zipper, acc) do
122+
zipper
123+
|> Z.next()
124+
|> vars_declared(acc)
125+
end
126+
127+
defp vars_used(function_name, args, lines) do
128+
function_name
129+
|> new_function(args, lines)
130+
|> Z.zip()
131+
|> vars_used(%{vars: []})
132+
end
133+
134+
defp vars_used(nil, acc) do
135+
Enum.reverse(acc.vars)
136+
end
137+
138+
defp vars_used({{marker, _meta, nil}, _rest} = zipper, acc) when is_atom(marker) do
139+
zipper
140+
|> Z.next()
141+
|> vars_used(put_in(acc.vars, [marker | acc.vars]))
142+
end
143+
144+
defp vars_used(zipper, acc) do
145+
zipper
146+
|> Z.next()
147+
|> vars_used(acc)
148+
end
149+
150+
defp return_declared(zipper, nil = _declares, function_name, args, lines) do
151+
{zipper, new_function(function_name, args, lines)}
152+
end
153+
154+
defp return_declared(zipper, [var], function_name, args, lines) when is_atom(var) do
155+
zipper =
156+
zipper
157+
|> top_find(fn
158+
{^function_name, [], []} -> true
159+
_ -> false
160+
end)
161+
|> Z.replace({:=, [], [{var, [], nil}, {function_name, [], args}]})
162+
163+
{zipper, new_function(function_name, args, Enum.concat(lines, [{var, [], nil}]))}
164+
end
165+
166+
defp return_declared(zipper, declares, function_name, args, lines) when is_list(declares) do
167+
declares = Enum.reduce(declares, {}, fn var, acc -> Tuple.append(acc, {var, [], nil}) end)
168+
169+
zipper =
170+
zipper
171+
|> top_find(fn
172+
{^function_name, [], []} -> true
173+
_ -> false
174+
end)
175+
|> Z.replace(
176+
{:=, [],
177+
[
178+
{:__block__, [],
179+
[
180+
declares
181+
]},
182+
{function_name, [], args}
183+
]}
184+
)
185+
186+
{zipper,
187+
new_function(
188+
function_name,
189+
args,
190+
Enum.concat(lines, [
191+
{:__block__, [],
192+
[
193+
declares
194+
]}
195+
])
196+
)}
197+
end
198+
199+
defp new_function(function_name, args, lines) do
200+
{:def, [do: [], end: []],
201+
[
202+
{function_name, [], args},
203+
[
204+
{
205+
{:__block__, [], [:do]},
206+
{:__block__, [], lines}
207+
}
208+
]
209+
]}
210+
end
211+
212+
defp fix_block(zipper) do
213+
zipper
214+
|> top_find(fn
215+
{:{}, [], _children} -> true
216+
_ -> false
217+
end)
218+
|> case do
219+
nil ->
220+
zipper
221+
222+
{{:{}, [], [block | defs]}, meta} ->
223+
{
224+
{
225+
block,
226+
{:__block__, [], defs}
227+
},
228+
meta
229+
}
230+
end
231+
end
232+
233+
defp top_find(zipper, function) do
234+
zipper
235+
|> Z.top()
236+
|> Z.find(function)
237+
end
238+
end
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
defmodule ElixirLS.LanguageServer.Experimental.CodeMod.ExtractFunctionTest do
2+
alias ElixirLS.LanguageServer.Experimental.CodeMod.ExtractFunction
3+
4+
use ExUnit.Case
5+
alias Sourceror.Zipper, as: Z
6+
7+
setup ctx do
8+
if Map.has_key?(ctx, :no_setup) do
9+
{:ok, []}
10+
else
11+
no = Map.get(ctx, :no, "")
12+
13+
{:ok,
14+
quoted:
15+
"""
16+
defmodule Baz#{no} do
17+
def foo(one, two) do
18+
three = 3
19+
IO.inspect(one)
20+
IO.inspect(two)
21+
IO.inspect(three)
22+
four = 4
23+
IO.inspect(three)
24+
IO.inspect(four)
25+
# comment
26+
end
27+
end
28+
"""
29+
|> Sourceror.parse_string!()}
30+
end
31+
end
32+
33+
describe "extract_function" do
34+
@tag no: 1
35+
test "extract one line to function", %{quoted: quoted} do
36+
zipper = ExtractFunction.extract_function(Z.zip(quoted), 3, 3, :bar)
37+
source = Sourceror.to_string(zipper)
38+
39+
assert [
40+
"defmodule Baz1 do",
41+
" def foo(one, two) do",
42+
" three = bar()",
43+
" IO.inspect(one)",
44+
" IO.inspect(two)",
45+
" IO.inspect(three)",
46+
" four = 4",
47+
" IO.inspect(three)",
48+
" IO.inspect(four)",
49+
"",
50+
" # comment",
51+
" end",
52+
"",
53+
" def bar() do",
54+
" three = 3",
55+
" three",
56+
" end",
57+
"end"
58+
] ==
59+
source |> String.split("\n")
60+
61+
Code.eval_string(source)
62+
end
63+
64+
@tag no: 2
65+
test "extract multiple lines to function", %{quoted: quoted} do
66+
zipper = ExtractFunction.extract_function(Z.zip(quoted), 3, 4, :bar)
67+
source = Sourceror.to_string(zipper)
68+
69+
assert [
70+
"defmodule Baz2 do",
71+
" def foo(one, two) do",
72+
" three = bar(one)",
73+
" IO.inspect(two)",
74+
" IO.inspect(three)",
75+
" four = 4",
76+
" IO.inspect(three)",
77+
" IO.inspect(four)",
78+
"",
79+
" # comment",
80+
" end",
81+
"",
82+
" def bar(one) do",
83+
" three = 3",
84+
" IO.inspect(one)",
85+
" three",
86+
" end",
87+
"end"
88+
] ==
89+
source |> String.split("\n")
90+
91+
Code.eval_string(source)
92+
end
93+
94+
@tag no: 3
95+
test "extract multiple lines with multiple returns to function", %{quoted: quoted} do
96+
zipper = ExtractFunction.extract_function(Z.zip(quoted), 3, 7, :bar)
97+
source = Sourceror.to_string(zipper)
98+
99+
assert [
100+
"defmodule Baz3 do",
101+
" def foo(one, two) do",
102+
" {three, four} = bar(one, two)",
103+
" IO.inspect(three)",
104+
" IO.inspect(four)",
105+
"",
106+
" # comment",
107+
" end",
108+
"",
109+
" def bar(one, two) do",
110+
" three = 3",
111+
" IO.inspect(one)",
112+
" IO.inspect(two)",
113+
" IO.inspect(three)",
114+
" four = 4",
115+
" {three, four}",
116+
" end",
117+
"end"
118+
] ==
119+
source |> String.split("\n")
120+
121+
Code.eval_string(source)
122+
end
123+
124+
@tag no: 4
125+
test "extract multiple lines with single return value to function", %{quoted: quoted} do
126+
zipper = ExtractFunction.extract_function(Z.zip(quoted), 3, 8, :bar)
127+
source = Sourceror.to_string(zipper)
128+
129+
assert [
130+
"defmodule Baz4 do",
131+
" def foo(one, two) do",
132+
" four = bar(one, two)",
133+
" IO.inspect(four)",
134+
"",
135+
" # comment",
136+
" end",
137+
"",
138+
" def bar(one, two) do",
139+
" three = 3",
140+
" IO.inspect(one)",
141+
" IO.inspect(two)",
142+
" IO.inspect(three)",
143+
" four = 4",
144+
" IO.inspect(three)",
145+
" four",
146+
" end",
147+
"end"
148+
] ==
149+
source |> String.split("\n")
150+
151+
Code.eval_string(source)
152+
end
153+
end
154+
155+
describe "extract_lines/3" do
156+
test "extract one line to function", %{quoted: quoted} do
157+
{zipper, lines} = ExtractFunction.extract_lines(Z.zip(quoted), 3, 3)
158+
159+
assert "defmodule Baz do\n def foo(one, two) do\n IO.inspect(one)\n IO.inspect(two)\n IO.inspect(three)\n four = 4\n IO.inspect(three)\n IO.inspect(four)\n\n # comment\n end\nend" ==
160+
Sourceror.to_string(zipper)
161+
162+
assert [
163+
"{:def, :foo}",
164+
"{:def_end, 11}",
165+
"{:lines, [three = 3]}",
166+
_,
167+
"{:vars, [:one, :two, :three, :four]}"
168+
] = lines |> Enum.map(&Sourceror.to_string(&1))
169+
end
170+
171+
test "extract multiple lines to function", %{quoted: quoted} do
172+
{zipper, lines} = ExtractFunction.extract_lines(Z.zip(quoted), 3, 4)
173+
174+
assert "defmodule Baz do\n def foo(one, two) do\n IO.inspect(two)\n IO.inspect(three)\n four = 4\n IO.inspect(three)\n IO.inspect(four)\n\n # comment\n end\nend" =
175+
Sourceror.to_string(zipper)
176+
177+
assert [
178+
"{:def, :foo}",
179+
"{:def_end, 11}",
180+
"{:lines, [three = 3, IO.inspect(one)]}",
181+
_,
182+
"{:vars, [:two, :three, :four]}"
183+
] = lines |> Enum.map(&Sourceror.to_string(&1))
184+
end
185+
end
186+
end

0 commit comments

Comments
 (0)
Please sign in to comment.