Skip to content

Commit 908d9e8

Browse files
authored
Add refactoring code action to extract function (#1506)
1 parent 0543c32 commit 908d9e8

11 files changed

+528
-111
lines changed

apps/els_core/src/els_poi.erl

+1
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
| import_entry
4444
| include
4545
| include_lib
46+
| keyword_expr
4647
| macro
4748
| module
4849
| parse_transform

apps/els_core/src/els_utils.erl

+26-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@
2626
jaro_distance/2,
2727
is_windows/0,
2828
system_tmp_dir/0,
29-
race/2
29+
race/2,
30+
uniq/1
3031
]).
3132

3233
%%==============================================================================
@@ -295,6 +296,30 @@ race(Funs, Timeout) ->
295296
error(timeout)
296297
end.
297298

299+
%% uniq/1: return a new list with the unique elements of the given list
300+
-spec uniq(List1) -> List2 when
301+
List1 :: [T],
302+
List2 :: [T],
303+
T :: term().
304+
305+
uniq(L) ->
306+
uniq(L, #{}).
307+
308+
-spec uniq(List1, Map) -> List2 when
309+
Map :: map(),
310+
List1 :: [T],
311+
List2 :: [T],
312+
T :: term().
313+
uniq([X | Xs], M) ->
314+
case is_map_key(X, M) of
315+
true ->
316+
uniq(Xs, M);
317+
false ->
318+
[X | uniq(Xs, M#{X => true})]
319+
end;
320+
uniq([], _) ->
321+
[].
322+
298323
%%==============================================================================
299324
%% Internal functions
300325
%%==============================================================================
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
-module(extract_function).
2+
-export([f/2]).
3+
4+
f(A, B) ->
5+
C = 1,
6+
F = A + B + C,
7+
G = case A of
8+
1 -> one;
9+
_ -> other
10+
end,
11+
H = [X || X <- [A, B, C], X > 1],
12+
I = {A, B, A},
13+
ok.
14+
15+
other_function() ->
16+
hello.

apps/els_lsp/src/els_code_action_provider.erl

+4-1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
%%==============================================================================
1414
-spec handle_request(any()) -> {response, any()}.
1515
handle_request({document_codeaction, Params}) ->
16+
%% TODO: Make code actions run async?
17+
%% TODO: Extract document here
1618
#{
1719
<<"textDocument">> := #{<<"uri">> := Uri},
1820
<<"range">> := RangeLSP,
@@ -30,7 +32,8 @@ handle_request({document_codeaction, Params}) ->
3032
code_actions(Uri, Range, #{<<"diagnostics">> := Diagnostics}) ->
3133
lists:usort(
3234
lists:flatten([make_code_actions(Uri, D) || D <- Diagnostics]) ++
33-
wrangler_handler:get_code_actions(Uri, Range)
35+
wrangler_handler:get_code_actions(Uri, Range) ++
36+
els_code_actions:extract_function(Uri, Range)
3437
).
3538

3639
-spec make_code_actions(uri(), map()) -> [map()].

apps/els_lsp/src/els_code_actions.erl

+54
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
-module(els_code_actions).
22
-export([
3+
extract_function/2,
34
create_function/4,
45
export_function/4,
56
fix_module_name/4,
@@ -197,6 +198,59 @@ fix_atom_typo(Uri, Range, _Data, [Atom]) ->
197198
)
198199
].
199200

201+
-spec extract_function(uri(), range()) -> [map()].
202+
extract_function(Uri, Range) ->
203+
{ok, [Document]} = els_dt_document:lookup(Uri),
204+
#{from := From = {Line, Column}, to := To} = els_range:to_poi_range(Range),
205+
%% We only want to extract if selection is large enough
206+
%% and cursor is inside a function
207+
case
208+
large_enough_range(From, To) andalso
209+
not contains_function_clause(Document, Line) andalso
210+
els_dt_document:wrapping_functions(Document, Line, Column) /= []
211+
of
212+
true ->
213+
[
214+
#{
215+
title => <<"Extract function">>,
216+
kind => <<"refactor.extract">>,
217+
command => make_extract_function_command(Range, Uri)
218+
}
219+
];
220+
false ->
221+
[]
222+
end.
223+
224+
-spec make_extract_function_command(range(), uri()) -> map().
225+
make_extract_function_command(Range, Uri) ->
226+
els_command:make_command(
227+
<<"Extract function">>,
228+
<<"refactor.extract">>,
229+
[#{uri => Uri, range => Range}]
230+
).
231+
232+
-spec contains_function_clause(
233+
els_dt_document:item(),
234+
non_neg_integer()
235+
) -> boolean().
236+
contains_function_clause(Document, Line) ->
237+
POIs = els_dt_document:get_element_at_pos(Document, Line, 1),
238+
lists:any(
239+
fun
240+
(#{kind := 'function_clause'}) ->
241+
true;
242+
(_) ->
243+
false
244+
end,
245+
POIs
246+
).
247+
248+
-spec large_enough_range(pos(), pos()) -> boolean().
249+
large_enough_range({Line, FromC}, {Line, ToC}) when (ToC - FromC) < 2 ->
250+
false;
251+
large_enough_range(_From, _To) ->
252+
true.
253+
200254
-spec undefined_callback(uri(), range(), binary(), [binary()]) -> [map()].
201255
undefined_callback(Uri, _Range, _Data, [_Function, Behaviour]) ->
202256
Title = <<"Add missing callbacks for: ", Behaviour/binary>>,

apps/els_lsp/src/els_document_highlight_provider.erl

+13-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ handle_request({document_highlight, Params}) ->
2525
} = Params,
2626
{ok, Document} = els_utils:lookup_document(Uri),
2727
Highlights =
28-
case els_dt_document:get_element_at_pos(Document, Line + 1, Character + 1) of
28+
case valid_highlight_pois(Document, Line, Character) of
2929
[POI | _] -> find_highlights(Document, POI);
3030
[] -> null
3131
end,
@@ -35,6 +35,18 @@ handle_request({document_highlight, Params}) ->
3535
%% overwrites them for more transparent Wrangler forms.
3636
end.
3737

38+
-spec valid_highlight_pois(els_dt_document:item(), integer(), integer()) ->
39+
[els_poi:poi()].
40+
valid_highlight_pois(Document, Line, Character) ->
41+
POIs = els_dt_document:get_element_at_pos(Document, Line + 1, Character + 1),
42+
[
43+
P
44+
|| #{kind := Kind} = P <- POIs,
45+
Kind /= keyword_expr,
46+
Kind /= module,
47+
Kind /= function_clause
48+
].
49+
3850
%%==============================================================================
3951
%% Internal functions
4052
%%==============================================================================

apps/els_lsp/src/els_execute_command_provider.erl

+140
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ options() ->
2424
<<"show-behaviour-usages">>,
2525
<<"suggest-spec">>,
2626
<<"function-references">>,
27+
<<"refactor.extract">>,
2728
<<"add-behaviour-callbacks">>
2829
],
2930
#{
@@ -106,6 +107,14 @@ execute_command(<<"suggest-spec">>, [
106107
},
107108
els_server:send_request(Method, Params),
108109
[];
110+
execute_command(<<"refactor.extract">>, [
111+
#{
112+
<<"uri">> := Uri,
113+
<<"range">> := Range
114+
}
115+
]) ->
116+
ok = extract_function(Uri, Range),
117+
[];
109118
execute_command(<<"add-behaviour-callbacks">>, [
110119
#{
111120
<<"uri">> := Uri,
@@ -197,6 +206,137 @@ execute_command(Command, Arguments) ->
197206
end,
198207
[].
199208

209+
-spec extract_function(uri(), range()) -> ok.
210+
extract_function(Uri, Range) ->
211+
{ok, [#{text := Text} = Document]} = els_dt_document:lookup(Uri),
212+
ExtractRange = extract_range(Document, Range),
213+
#{from := {FromL, FromC} = From, to := {ToL, ToC}} = ExtractRange,
214+
ExtractString0 = els_text:range(Text, From, {ToL, ToC}),
215+
%% Trim whitespace
216+
ExtractString = string:trim(ExtractString0, both, " \n\r\t"),
217+
%% Trim trailing termination symbol
218+
ExtractStringTrimmed = string:trim(ExtractString, trailing, ",.;"),
219+
Method = <<"workspace/applyEdit">>,
220+
case els_dt_document:wrapping_functions(Document, FromL, FromC) of
221+
[WrappingFunPOI | _] when ExtractStringTrimmed /= <<>> ->
222+
%% WrappingFunPOI is the function that we are currently in
223+
#{
224+
data := #{
225+
wrapping_range :=
226+
#{
227+
from := {FunBeginLine, _},
228+
to := {FunEndLine, _}
229+
}
230+
}
231+
} = WrappingFunPOI,
232+
%% Get args needed for the new function
233+
Args = get_args(ExtractRange, Document, FromL, FunBeginLine),
234+
ArgsBin = unicode:characters_to_binary(string:join(Args, ", ")),
235+
FunClause = <<"new_function(", ArgsBin/binary, ")">>,
236+
%% Place the new function after the current function
237+
EndSymbol = end_symbol(ExtractString),
238+
NewRange = els_protocol:range(
239+
#{from => {FunEndLine + 1, 1}, to => {FunEndLine + 1, 1}}
240+
),
241+
FunBody = unicode:characters_to_list(
242+
<<FunClause/binary, " ->\n", ExtractStringTrimmed/binary, ".">>
243+
),
244+
{ok, FunBodyFormatted, _} = erlfmt:format_string(FunBody, []),
245+
NewFun = unicode:characters_to_binary(FunBodyFormatted ++ "\n"),
246+
Changes = [
247+
#{
248+
newText => <<FunClause/binary, EndSymbol/binary>>,
249+
range => els_protocol:range(ExtractRange)
250+
},
251+
#{
252+
newText => NewFun,
253+
range => NewRange
254+
}
255+
],
256+
Params = #{edit => #{changes => #{Uri => Changes}}},
257+
els_server:send_request(Method, Params);
258+
_ ->
259+
?LOG_INFO("No wrapping function found"),
260+
ok
261+
end.
262+
263+
-spec end_symbol(binary()) -> binary().
264+
end_symbol(ExtractString) ->
265+
case binary:last(ExtractString) of
266+
$. -> <<".">>;
267+
$, -> <<",">>;
268+
$; -> <<";">>;
269+
_ -> <<>>
270+
end.
271+
272+
%% @doc Find all variables defined in the function before the current.
273+
%% If they are used inside the selected range, they need to be
274+
%% sent in as arguments to the new function.
275+
-spec get_args(
276+
els_poi:poi_range(),
277+
els_dt_document:item(),
278+
non_neg_integer(),
279+
non_neg_integer()
280+
) -> [string()].
281+
get_args(PoiRange, Document, FromL, FunBeginLine) ->
282+
%% TODO: Possible improvement. To make this bullet proof we should
283+
%% ignore vars defined inside LCs and funs()
284+
VarPOIs = els_poi:sort(els_dt_document:pois(Document, [variable])),
285+
BeforeRange = #{from => {FunBeginLine, 1}, to => {FromL, 1}},
286+
VarsBefore = ids_in_range(BeforeRange, VarPOIs),
287+
VarsInside = ids_in_range(PoiRange, VarPOIs),
288+
els_utils:uniq([
289+
atom_to_list(Id)
290+
|| Id <- VarsInside,
291+
lists:member(Id, VarsBefore)
292+
]).
293+
294+
-spec ids_in_range(els_poi:poi_range(), [els_poi:poi()]) -> [atom()].
295+
ids_in_range(PoiRange, VarPOIs) ->
296+
[
297+
Id
298+
|| #{range := R, id := Id} <- VarPOIs,
299+
els_range:in(R, PoiRange)
300+
].
301+
302+
-spec extract_range(els_dt_document:item(), range()) -> els_poi:poi_range().
303+
extract_range(#{text := Text} = Document, Range) ->
304+
PoiRange = els_range:to_poi_range(Range),
305+
#{from := {CurrL, CurrC} = From, to := To} = PoiRange,
306+
POIs = els_dt_document:get_element_at_pos(Document, CurrL, CurrC),
307+
MarkedText = els_text:range(Text, From, To),
308+
case is_keyword_expr(MarkedText) of
309+
true ->
310+
case sort_by_range_size([P || #{kind := keyword_expr} = P <- POIs]) of
311+
[] ->
312+
PoiRange;
313+
[{_Size, #{range := SmallestRange}} | _] ->
314+
SmallestRange
315+
end;
316+
false ->
317+
PoiRange
318+
end.
319+
320+
-spec is_keyword_expr(binary()) -> boolean().
321+
is_keyword_expr(Text) ->
322+
lists:member(Text, [
323+
<<"begin">>,
324+
<<"case">>,
325+
<<"fun">>,
326+
<<"if">>,
327+
<<"maybe">>,
328+
<<"receive">>,
329+
<<"try">>
330+
]).
331+
332+
-spec sort_by_range_size(_) -> _.
333+
sort_by_range_size(POIs) ->
334+
lists:sort([{range_size(P), P} || P <- POIs]).
335+
336+
-spec range_size(_) -> _.
337+
range_size(#{range := #{from := {FromL, FromC}, to := {ToL, ToC}}}) ->
338+
{ToL - FromL, ToC - FromC}.
339+
200340
-spec spec_text(binary()) -> binary().
201341
spec_text(<<"-callback", Rest/binary>>) ->
202342
<<"-spec", Rest/binary>>;

apps/els_lsp/src/els_parser.erl

+16-1
Original file line numberDiff line numberDiff line change
@@ -272,13 +272,28 @@ do_points_of_interest(Tree) ->
272272
type_application(Tree);
273273
record_type ->
274274
record_type(Tree);
275-
_ ->
275+
Type when
276+
Type == block_expr;
277+
Type == case_expr;
278+
Type == if_expr;
279+
Type == implicit_fun;
280+
Type == maybe_expr;
281+
Type == receive_expr;
282+
Type == try_expr
283+
->
284+
keyword_expr(Type, Tree);
285+
_Other ->
276286
[]
277287
end
278288
catch
279289
throw:syntax_error -> []
280290
end.
281291

292+
-spec keyword_expr(atom(), tree()) -> [els_poi:poi()].
293+
keyword_expr(Type, Tree) ->
294+
Pos = erl_syntax:get_pos(Tree),
295+
[poi(Pos, keyword_expr, Type)].
296+
282297
-spec application(tree()) -> [els_poi:poi()].
283298
application(Tree) ->
284299
case application_mfa(Tree) of

0 commit comments

Comments
 (0)