Skip to content

Commit e14a611

Browse files
authored
feat: alias-refactor workspace command (#386)
Adds an alias refactor workspace command to Next LS. Your cursor should be at the target module that you wish to alias. It will insert the alias at the of the nearest defmodule definition scoping the refactor only to the current module instead of the whole file.
1 parent b3bf75b commit e14a611

File tree

9 files changed

+758
-26
lines changed

9 files changed

+758
-26
lines changed

lib/next_ls.ex

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,8 @@ defmodule NextLS do
154154
execute_command_provider: %GenLSP.Structures.ExecuteCommandOptions{
155155
commands: [
156156
"to-pipe",
157-
"from-pipe"
157+
"from-pipe",
158+
"alias-refactor"
158159
]
159160
},
160161
hover_provider: true,
@@ -769,6 +770,19 @@ defmodule NextLS do
769770
position: position
770771
})
771772

773+
"alias-refactor" ->
774+
[arguments] = params.arguments
775+
776+
uri = arguments["uri"]
777+
position = arguments["position"]
778+
text = lsp.assigns.documents[uri]
779+
780+
NextLS.Commands.Alias.run(%{
781+
uri: uri,
782+
text: text,
783+
position: position
784+
})
785+
772786
_ ->
773787
NextLS.Logger.show_message(
774788
lsp.logger,
@@ -783,7 +797,7 @@ defmodule NextLS do
783797
%WorkspaceEdit{} = edit ->
784798
GenLSP.request(lsp, %WorkspaceApplyEdit{
785799
id: System.unique_integer([:positive]),
786-
params: %ApplyWorkspaceEditParams{label: "Pipe", edit: edit}
800+
params: %ApplyWorkspaceEditParams{label: NextLS.Commands.label(command), edit: edit}
787801
})
788802

789803
_reply ->

lib/next_ls/commands.ex

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
defmodule NextLS.Commands do
2+
@moduledoc false
3+
4+
@labels %{
5+
"from-pipe" => "Inlined pipe",
6+
"to-pipe" => "Extracted to a pipe",
7+
"alias-refactor" => "Refactored with an alias"
8+
}
9+
@doc "Creates a label for the workspace apply struct from the command name"
10+
def label(command) when is_map_key(@labels, command), do: @labels[command]
11+
12+
def label(command) do
13+
raise ArgumentError, "command #{inspect(command)} not supported"
14+
end
15+
end

lib/next_ls/commands/alias.ex

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
defmodule NextLS.Commands.Alias do
2+
@moduledoc """
3+
Refactors a module with fully qualified calls to an alias.
4+
The cursor position should be under the module name that you wish to alias.
5+
"""
6+
import Schematic
7+
8+
alias GenLSP.Enumerations.ErrorCodes
9+
alias GenLSP.Structures.Position
10+
alias GenLSP.Structures.Range
11+
alias GenLSP.Structures.TextEdit
12+
alias GenLSP.Structures.WorkspaceEdit
13+
alias NextLS.ASTHelpers
14+
alias NextLS.EditHelpers
15+
alias Sourceror.Zipper, as: Z
16+
17+
@line_length 121
18+
19+
defp opts do
20+
map(%{
21+
position: Position.schematic(),
22+
uri: str(),
23+
text: list(str())
24+
})
25+
end
26+
27+
def run(opts) do
28+
with {:ok, %{text: text, uri: uri, position: position}} <- unify(opts(), Map.new(opts)),
29+
{:ok, ast, comments} = parse(text),
30+
{:ok, defm} <- ASTHelpers.get_surrounding_module(ast, position),
31+
{:ok, {:__aliases__, _, modules}} <- get_node(ast, position) do
32+
range = make_range(defm)
33+
indent = EditHelpers.get_indent(text, range.start.line)
34+
aliased = get_aliased(defm, modules)
35+
36+
comments =
37+
Enum.filter(comments, fn comment ->
38+
comment.line > range.start.line && comment.line <= range.end.line
39+
end)
40+
41+
to_algebra_opts = [comments: comments]
42+
doc = Code.quoted_to_algebra(aliased, to_algebra_opts)
43+
formatted = doc |> Inspect.Algebra.format(@line_length) |> IO.iodata_to_binary()
44+
45+
%WorkspaceEdit{
46+
changes: %{
47+
uri => [
48+
%TextEdit{
49+
new_text:
50+
EditHelpers.add_indent_to_edit(
51+
formatted,
52+
indent
53+
),
54+
range: range
55+
}
56+
]
57+
}
58+
}
59+
else
60+
{:error, message} ->
61+
%GenLSP.ErrorResponse{code: ErrorCodes.parse_error(), message: inspect(message)}
62+
end
63+
end
64+
65+
defp parse(lines) do
66+
lines
67+
|> Enum.join("\n")
68+
|> Spitfire.parse_with_comments(literal_encoder: &{:ok, {:__block__, &2, [&1]}})
69+
|> case do
70+
{:error, ast, comments, _errors} ->
71+
{:ok, ast, comments}
72+
73+
other ->
74+
other
75+
end
76+
end
77+
78+
defp make_range(original_ast) do
79+
range = Sourceror.get_range(original_ast)
80+
81+
%Range{
82+
start: %Position{line: range.start[:line] - 1, character: range.start[:column] - 1},
83+
end: %Position{line: range.end[:line] - 1, character: range.end[:column] - 1}
84+
}
85+
end
86+
87+
def get_node(ast, pos) do
88+
pos = [line: pos.line + 1, column: pos.character + 1]
89+
90+
result =
91+
ast
92+
|> Z.zip()
93+
|> Z.traverse(nil, fn tree, acc ->
94+
node = Z.node(tree)
95+
range = Sourceror.get_range(node)
96+
97+
if not is_nil(range) and
98+
match?({:__aliases__, _context, _modules}, node) &&
99+
Sourceror.compare_positions(range.start, pos) in [:lt, :eq] &&
100+
Sourceror.compare_positions(range.end, pos) in [:gt, :eq] do
101+
{tree, node}
102+
else
103+
{tree, acc}
104+
end
105+
end)
106+
107+
case result do
108+
{_, nil} ->
109+
{:error, "could not find a module to alias at the cursor position"}
110+
111+
{_, {_t, _m, []}} ->
112+
{:error, "could not find a module to alias at the cursor position"}
113+
114+
{_, {_t, _m, [_argument | _rest]} = node} ->
115+
{:ok, node}
116+
end
117+
end
118+
119+
defp get_aliased(defm, modules) do
120+
last = List.last(modules)
121+
122+
replaced =
123+
Macro.prewalk(defm, fn
124+
{:__aliases__, context, ^modules} -> {:__aliases__, context, [last]}
125+
ast -> ast
126+
end)
127+
128+
alias_to_add = {:alias, [alias: false], [{:__aliases__, [], modules}]}
129+
130+
{:defmodule, context, [module, [{do_block, block}]]} = replaced
131+
132+
case block do
133+
{:__block__, block_context, defs} ->
134+
{:defmodule, context, [module, [{do_block, {:__block__, block_context, [alias_to_add | defs]}}]]}
135+
136+
{_, _, _} = original ->
137+
{:defmodule, context, [module, [{do_block, {:__block__, [], [alias_to_add, original]}}]]}
138+
end
139+
end
140+
end

lib/next_ls/extensions/elixir_extension/code_action/require.ex

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ defmodule NextLS.ElixirExtension.CodeAction.Require do
77
alias GenLSP.Structures.Range
88
alias GenLSP.Structures.TextEdit
99
alias GenLSP.Structures.WorkspaceEdit
10+
alias NextLS.ASTHelpers
1011

1112
@one_indentation_level " "
1213
@spec new(diagnostic :: Diagnostic.t(), [text :: String.t()], uri :: String.t()) :: [CodeAction.t()]
@@ -15,7 +16,7 @@ defmodule NextLS.ElixirExtension.CodeAction.Require do
1516

1617
with {:ok, require_module} <- get_edit(diagnostic.message),
1718
{:ok, ast} <- parse_ast(text),
18-
{:ok, defm} <- nearest_defmodule(ast, range),
19+
{:ok, defm} <- ASTHelpers.get_surrounding_module(ast, range.start),
1920
indentation <- get_indent(text, defm),
2021
nearest <- find_nearest_node_for_require(defm),
2122
range <- get_edit_range(nearest) do
@@ -47,27 +48,6 @@ defmodule NextLS.ElixirExtension.CodeAction.Require do
4748
|> Spitfire.parse()
4849
end
4950

50-
defp nearest_defmodule(ast, range) do
51-
defmodules =
52-
ast
53-
|> Macro.prewalker()
54-
|> Enum.filter(fn
55-
{:defmodule, _, _} -> true
56-
_ -> false
57-
end)
58-
59-
if defmodules != [] do
60-
defm =
61-
Enum.min_by(defmodules, fn {_, ctx, _} ->
62-
range.start.character - ctx[:line] + 1
63-
end)
64-
65-
{:ok, defm}
66-
else
67-
{:error, "no defmodule definition"}
68-
end
69-
end
70-
7151
@module_name ~r/require\s+([^\s]+)\s+before/
7252
defp get_edit(message) do
7353
case Regex.run(@module_name, message) do

lib/next_ls/helpers/ast_helpers.ex

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
defmodule NextLS.ASTHelpers do
22
@moduledoc false
3+
alias GenLSP.Structures.Position
34
alias Sourceror.Zipper
45

56
defmodule Attributes do
@@ -154,6 +155,29 @@ defmodule NextLS.ASTHelpers do
154155
end)
155156
end
156157

158+
@spec get_surrounding_module(ast :: Macro.t(), position :: Position.t()) :: {:ok, Macro.t()} | {:error, String.t()}
159+
def get_surrounding_module(ast, position) do
160+
defm =
161+
ast
162+
|> Macro.prewalker()
163+
|> Enum.filter(fn node -> match?({:defmodule, _, _}, node) end)
164+
|> Enum.filter(fn {_, ctx, _} ->
165+
position.line + 1 - ctx[:line] >= 0
166+
end)
167+
|> Enum.min_by(
168+
fn {_, ctx, _} ->
169+
abs(ctx[:line] - 1 - position.line)
170+
end,
171+
fn -> nil end
172+
)
173+
174+
if defm do
175+
{:ok, defm}
176+
else
177+
{:error, "no defmodule definition"}
178+
end
179+
end
180+
157181
def find_cursor(ast) do
158182
with nil <-
159183
ast

test/next_ls/alias_test.exs

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
defmodule NextLS.AliasTest do
2+
use ExUnit.Case, async: true
3+
4+
import GenLSP.Test
5+
import NextLS.Support.Utils
6+
7+
@moduletag :tmp_dir
8+
@moduletag root_paths: ["my_proj"]
9+
10+
setup %{tmp_dir: tmp_dir} do
11+
File.mkdir_p!(Path.join(tmp_dir, "my_proj/lib"))
12+
File.write!(Path.join(tmp_dir, "my_proj/mix.exs"), mix_exs())
13+
14+
cwd = Path.join(tmp_dir, "my_proj")
15+
16+
foo_path = Path.join(cwd, "lib/foo.ex")
17+
18+
foo = """
19+
defmodule Foo do
20+
def to_list() do
21+
Foo.Bar.to_list(Map.new())
22+
end
23+
24+
def to_map() do
25+
Foo.Bar.to_map(List.new())
26+
end
27+
end
28+
"""
29+
30+
File.write!(foo_path, foo)
31+
32+
[foo: foo, foo_path: foo_path]
33+
end
34+
35+
setup :with_lsp
36+
37+
setup context do
38+
assert :ok == notify(context.client, %{method: "initialized", jsonrpc: "2.0", params: %{}})
39+
assert_is_ready(context, "my_proj")
40+
assert_compiled(context, "my_proj")
41+
assert_notification "$/progress", %{"value" => %{"kind" => "end", "message" => "Finished indexing!"}}
42+
43+
did_open(context.client, context.foo_path, context.foo)
44+
context
45+
end
46+
47+
test "refactors with alias", %{client: client, foo_path: foo} do
48+
foo_uri = uri(foo)
49+
id = 1
50+
51+
request client, %{
52+
method: "workspace/executeCommand",
53+
id: id,
54+
jsonrpc: "2.0",
55+
params: %{
56+
command: "alias-refactor",
57+
arguments: [%{uri: foo_uri, position: %{line: 2, character: 8}}]
58+
}
59+
}
60+
61+
expected_edit =
62+
String.trim("""
63+
defmodule Foo do
64+
alias Foo.Bar
65+
66+
def to_list() do
67+
Bar.to_list(Map.new())
68+
end
69+
70+
def to_map() do
71+
Bar.to_map(List.new())
72+
end
73+
end
74+
""")
75+
76+
assert_request(client, "workspace/applyEdit", 500, fn params ->
77+
assert %{"edit" => edit, "label" => "Refactored with an alias"} = params
78+
79+
assert %{
80+
"changes" => %{
81+
^foo_uri => [%{"newText" => text, "range" => range}]
82+
}
83+
} = edit
84+
85+
assert text == expected_edit
86+
87+
assert range["start"] == %{"character" => 0, "line" => 0}
88+
assert range["end"] == %{"character" => 3, "line" => 8}
89+
end)
90+
end
91+
end

0 commit comments

Comments
 (0)